feat: zap (#107)
This commit is contained in:
parent
407a6fb802
commit
249593d547
72 changed files with 2582 additions and 818 deletions
|
|
@ -1,10 +1,13 @@
|
|||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { CODY_PUBKEY } from '@/constants'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
|
||||
import { useState } from 'react'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
|
|
@ -13,12 +16,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
|||
A beautiful nostr client focused on browsing relay feeds
|
||||
</div>
|
||||
<div>
|
||||
Made by{' '}
|
||||
<Username
|
||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||
className="inline-block text-primary"
|
||||
showAt
|
||||
/>
|
||||
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
|
|
@ -30,30 +28,26 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
|||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
If you like this project, you can buy me a coffee ☕️ <br />
|
||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
|
||||
<div className="text-sm text-muted-foreground">
|
||||
If you like Jumble, please consider giving it a star ⭐
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="p-4">{content}</div>
|
||||
<div className="p-4 space-y-4">{content}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>{content}</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { Bell } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function NotificationsButton() {
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
const { hasNewNotification } = useNotification()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'notifications'}
|
||||
onClick={() => navigate('notifications')}
|
||||
>
|
||||
<Bell />
|
||||
<div className="relative">
|
||||
<Bell />
|
||||
{hasNewNotification && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
49
src/components/Donation/index.tsx
Normal file
49
src/components/Donation/index.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { CODY_PUBKEY } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function Donation({ className }: { className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
||||
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{t('Your donation helps me maintain Jumble and make it better! 😊')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ amount: 1000, text: '☕️ 1k' },
|
||||
{ amount: 10000, text: '🍜 10k' },
|
||||
{ amount: 100000, text: '🍣 100k' },
|
||||
{ amount: 1000000, text: '✈️ 1M' }
|
||||
].map(({ amount, text }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
key={amount}
|
||||
onClick={() => {
|
||||
setDonationAmount(amount)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ZapDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
pubkey={CODY_PUBKEY}
|
||||
defaultAmount={donationAmount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,14 +13,7 @@ export function EmbeddedMention({ userId }: { userId: string }) {
|
|||
}
|
||||
|
||||
export function EmbeddedMentionText({ userId }: { userId: string }) {
|
||||
return (
|
||||
<SimpleUsername
|
||||
userId={userId}
|
||||
showAt
|
||||
className="font-normal inline truncate"
|
||||
withoutSkeleton
|
||||
/>
|
||||
)
|
||||
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
|
||||
}
|
||||
|
||||
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||
const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList()
|
||||
const { followings, follow, unfollow } = useFollowList()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||
|
||||
if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null
|
||||
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null
|
||||
|
||||
const handleFollow = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -39,7 +39,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||
const handleUnfollow = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!isFollowing || !followListEvent) return
|
||||
if (!isFollowing) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export default function Image({
|
|||
)}
|
||||
onLoad={() => {
|
||||
setIsLoading(false)
|
||||
setHasError(false)
|
||||
setTimeout(() => setDisplayBlurHash(false), 500)
|
||||
}}
|
||||
onError={() => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
|
|||
return (
|
||||
nip05Name &&
|
||||
nip05Domain && (
|
||||
<div className="flex items-center space-x-1 truncate [&_svg]:size-5">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
{nip05Name !== '_' ? (
|
||||
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
|
||||
) : null}
|
||||
|
|
@ -33,7 +33,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
|
|||
className={`flex items-center space-x-1 hover:underline truncate ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{nip05IsVerified ? <BadgeCheck /> : <BadgeAlert />}
|
||||
{nip05IsVerified ? <BadgeCheck className="size-4" /> : <BadgeAlert className="size-4" />}
|
||||
<div className="text-sm truncate">{nip05Domain}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export default function RepostDescription({
|
|||
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
|
||||
<Repeat2 size={16} className="shrink-0" />
|
||||
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
|
||||
<div>{t('reposted')}</div>
|
||||
<div className="shrink-0">{t('reposted')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import PictureNoteCard from '../PictureNoteCard'
|
|||
|
||||
const LIMIT = 100
|
||||
const ALGO_LIMIT = 500
|
||||
const SHOW_COUNT = 20
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
|
|
@ -266,7 +266,7 @@ function ListModeSwitch({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform',
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform select-none',
|
||||
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,57 +5,44 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
|
|||
import client from '@/services/client.service'
|
||||
import { Heart, Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function LikeButton({
|
||||
event,
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
export default function LikeButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
const { likeCount, hasLiked } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const { likeCount, hasLiked } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false }
|
||||
}, [noteStatsMap, event, pubkey])
|
||||
const canLike = !hasLiked && !liking
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (likeCount === undefined) {
|
||||
fetchNoteLikeCount(event)
|
||||
}
|
||||
if (hasLiked === undefined) {
|
||||
fetchNoteLikedStatus(event)
|
||||
}
|
||||
}, [canFetch, event])
|
||||
|
||||
const like = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canLike) return
|
||||
if (!canLike || !pubkey) return
|
||||
|
||||
setLiking(true)
|
||||
const timer = setTimeout(() => setLiking(false), 5000)
|
||||
|
||||
try {
|
||||
const [liked] = await Promise.all([
|
||||
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
|
||||
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
|
||||
])
|
||||
if (liked) return
|
||||
const noteStats = noteStatsMap.get(event.id)
|
||||
const hasLiked = noteStats?.likes?.has(pubkey)
|
||||
if (hasLiked) return
|
||||
if (!noteStats?.updatedAt) {
|
||||
const stats = await fetchNoteStats(event)
|
||||
if (stats?.likes?.has(pubkey)) return
|
||||
}
|
||||
|
||||
const targetRelayList = await client.fetchRelayList(event.pubkey)
|
||||
const reaction = createReactionDraftEvent(event)
|
||||
await publish(reaction, { additionalRelayUrls: targetRelayList.read.slice(0, 4) })
|
||||
markNoteAsLiked(event.id)
|
||||
const evt = await publish(reaction, {
|
||||
additionalRelayUrls: targetRelayList.read.slice(0, 4)
|
||||
})
|
||||
updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -12,60 +12,47 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
|
|||
import client from '@/services/client.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({
|
||||
event,
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
export default function RepostButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||
useNoteStats()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
repostCount: stats.reposts?.size,
|
||||
hasReposted: pubkey ? stats.reposts?.has(pubkey) : false
|
||||
}
|
||||
}, [noteStatsMap, event.id])
|
||||
const canRepost = !hasReposted && !reposting
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (repostCount === undefined) {
|
||||
fetchNoteRepostCount(event)
|
||||
}
|
||||
if (hasReposted === undefined) {
|
||||
fetchNoteRepostedStatus(event)
|
||||
}
|
||||
}, [canFetch, event])
|
||||
|
||||
const repost = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canRepost) return
|
||||
if (!canRepost || !pubkey) return
|
||||
|
||||
setReposting(true)
|
||||
const timer = setTimeout(() => setReposting(false), 5000)
|
||||
|
||||
try {
|
||||
const [reposted] = await Promise.all([
|
||||
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
|
||||
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
|
||||
])
|
||||
if (reposted) return
|
||||
const noteStats = noteStatsMap.get(event.id)
|
||||
const hasReposted = noteStats?.reposts?.has(pubkey)
|
||||
if (hasReposted) return
|
||||
if (!noteStats?.updatedAt) {
|
||||
const stats = await fetchNoteStats(event)
|
||||
if (stats?.reposts?.has(pubkey)) return
|
||||
}
|
||||
|
||||
const targetRelayList = await client.fetchRelayList(event.pubkey)
|
||||
const repost = createRepostDraftEvent(event)
|
||||
await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
|
||||
markNoteAsReposted(event.id)
|
||||
const evt = await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
|
||||
updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
|
|
|
|||
43
src/components/NoteStats/TopZaps.tsx
Normal file
43
src/components/NoteStats/TopZaps.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
|
||||
export default function TopZaps({ event }: { event: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const topZaps = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return stats.zaps?.slice(0, 10) || []
|
||||
}, [noteStatsMap, event])
|
||||
|
||||
if (!topZaps.length) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="pb-2 mb-1">
|
||||
<div className="flex gap-1">
|
||||
{topZaps.map((zap) => (
|
||||
<div
|
||||
key={zap.pr}
|
||||
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted items-center text-yellow-400 clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toProfile(zap.pubkey))
|
||||
}}
|
||||
>
|
||||
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
|
||||
<Zap className="size-3 fill-yellow-400" />
|
||||
<div className="font-semibold">{formatAmount(zap.amount)}</div>
|
||||
<div className="truncate">{zap.comment}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
147
src/components/NoteStats/ZapButton.tsx
Normal file
147
src/components/NoteStats/ZapButton.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useToast } from '@/hooks'
|
||||
import { getLightningAddressFromProfile } from '@/lib/lightning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import client from '@/services/client.service'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import { Loader, Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function ZapButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, addZap } = useNoteStats()
|
||||
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
||||
const [openZapDialog, setOpenZapDialog] = useState(false)
|
||||
const [zapping, setZapping] = useState(false)
|
||||
const { zapAmount, hasZapped } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
zapAmount: stats.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
|
||||
hasZapped: pubkey ? stats.zaps?.some((zap) => zap.pubkey === pubkey) : false
|
||||
}
|
||||
}, [noteStatsMap, event, pubkey])
|
||||
const [showButton, setShowButton] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLongPressRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
client.fetchProfile(event.pubkey).then((profile) => {
|
||||
if (!profile) return
|
||||
const lightningAddress = getLightningAddressFromProfile(profile)
|
||||
if (lightningAddress) setShowButton(true)
|
||||
})
|
||||
}, [event])
|
||||
|
||||
if (!showButton) return null
|
||||
|
||||
const handleZap = async () => {
|
||||
try {
|
||||
if (!pubkey) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
setZapping(true)
|
||||
const { invoice } = await lightning.zap(
|
||||
pubkey,
|
||||
event.pubkey,
|
||||
defaultZapSats,
|
||||
defaultZapComment,
|
||||
event.id
|
||||
)
|
||||
addZap(event.id, invoice, defaultZapSats, defaultZapComment)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('Zap failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
setZapping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickStart = (e: MouseEvent | TouchEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
isLongPressRef.current = false
|
||||
|
||||
if (quickZap) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPressRef.current = true
|
||||
checkLogin(() => {
|
||||
setOpenZapDialog(true)
|
||||
setZapping(true)
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickEnd = (e: MouseEvent | TouchEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
if (!quickZap) {
|
||||
checkLogin(() => {
|
||||
setOpenZapDialog(true)
|
||||
setZapping(true)
|
||||
})
|
||||
} else if (!isLongPressRef.current) {
|
||||
checkLogin(() => handleZap())
|
||||
}
|
||||
isLongPressRef.current = false
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-yellow-400 gap-1 select-none',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
onMouseDown={handleClickStart}
|
||||
onMouseUp={handleClickEnd}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleClickStart}
|
||||
onTouchEnd={handleClickEnd}
|
||||
>
|
||||
{zapping ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Zap size={16} className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
)}
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
setOpenZapDialog(open)
|
||||
setZapping(open)
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
eventId={event.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAmount(amount: number) {
|
||||
if (amount < 1000) return amount
|
||||
if (amount < 1000000) return `${Math.round(amount / 100) / 10}k`
|
||||
return `${Math.round(amount / 100000) / 10}M`
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect } from 'react'
|
||||
import LikeButton from './LikeButton'
|
||||
import NoteOptions from './NoteOptions'
|
||||
import ReplyButton from './ReplyButton'
|
||||
import RepostButton from './RepostButton'
|
||||
import SeenOnButton from './SeenOnButton'
|
||||
import TopZaps from './TopZaps'
|
||||
import ZapButton from './ZapButton'
|
||||
|
||||
export default function NoteStats({
|
||||
event,
|
||||
|
|
@ -17,16 +21,27 @@ export default function NoteStats({
|
|||
fetchIfNotExisting?: boolean
|
||||
variant?: 'note' | 'reply'
|
||||
}) {
|
||||
const { fetchNoteStats } = useNoteStats()
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
fetchNoteStats(event)
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ReplyButton event={event} variant={variant} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
</div>
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SeenOnButton event={event} />
|
||||
<NoteOptions event={event} />
|
||||
<div className={cn('select-none', className)}>
|
||||
<TopZaps event={event} />
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ReplyButton event={event} variant={variant} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
</div>
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SeenOnButton event={event} />
|
||||
<NoteOptions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import ContentPreview from '../../ContentPreview'
|
||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
||||
import UserAvatar from '../../UserAvatar'
|
||||
|
||||
export function CommentNotification({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
|
||||
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
|
||||
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
|
||||
if (
|
||||
!rootEventId ||
|
||||
!rootPubkey ||
|
||||
!rootKind ||
|
||||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={notification}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import ContentPreview from '../../ContentPreview'
|
||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
||||
import UserAvatar from '../../UserAvatar'
|
||||
|
||||
export function ReactionNotification({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { pubkey } = useNostr()
|
||||
const eventId = useMemo(() => {
|
||||
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
|
||||
if (targetPubkey !== pubkey) return undefined
|
||||
|
||||
const eTag = notification.tags.findLast(tagNameEquals('e'))
|
||||
return eTag?.[1]
|
||||
}, [notification, pubkey])
|
||||
const { event } = useFetchEvent(eventId)
|
||||
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<div className="text-xl min-w-6 text-center">
|
||||
{!notification.content || notification.content === '+' ? (
|
||||
<Heart size={24} className="text-red-400" />
|
||||
) : (
|
||||
notification.content
|
||||
)}
|
||||
</div>
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import ContentPreview from '../../ContentPreview'
|
||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
||||
import UserAvatar from '../../UserAvatar'
|
||||
|
||||
export function ReplyNotification({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(notification))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={notification}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import client from '@/services/client.service'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { Event, validateEvent } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import ContentPreview from '../../ContentPreview'
|
||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
||||
import UserAvatar from '../../UserAvatar'
|
||||
|
||||
export function RepostNotification({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const event = useMemo(() => {
|
||||
try {
|
||||
const event = JSON.parse(notification.content) as Event
|
||||
const isValid = validateEvent(event)
|
||||
if (!isValid) return null
|
||||
client.addEventToCache(event)
|
||||
return event
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [notification.content])
|
||||
if (!event) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Repeat size={24} className="text-green-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { useFetchEvent } from '@/hooks'
|
||||
import { extractZapInfoFromReceipt } from '@/lib/event'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { toNote, toProfile } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ContentPreview from '../../ContentPreview'
|
||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
||||
import UserAvatar from '../../UserAvatar'
|
||||
|
||||
export function ZapNotification({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { pubkey } = useNostr()
|
||||
const { senderPubkey, eventId, amount, comment } = useMemo(
|
||||
() => extractZapInfoFromReceipt(notification) ?? ({} as any),
|
||||
[notification]
|
||||
)
|
||||
const { event } = useFetchEvent(eventId)
|
||||
|
||||
if (!senderPubkey || !amount) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => (event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1 w-0">
|
||||
<UserAvatar userId={senderPubkey} size="small" />
|
||||
<Zap size={24} className="text-yellow-400 shrink-0" />
|
||||
<div className="font-semibold text-yellow-400 shrink-0">
|
||||
{formatAmount(amount)} {t('sats')}
|
||||
</div>
|
||||
{comment && <div className="text-yellow-400 truncate">{comment}</div>}
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/NotificationList/NotificationItem/index.tsx
Normal file
37
src/components/NotificationList/NotificationItem/index.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { CommentNotification } from './CommentNotification'
|
||||
import { ReactionNotification } from './ReactionNotification'
|
||||
import { ReplyNotification } from './ReplyNotification'
|
||||
import { RepostNotification } from './RepostNotification'
|
||||
import { ZapNotification } from './ZapNotification'
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
isNew = false
|
||||
}: {
|
||||
notification: Event
|
||||
isNew?: boolean
|
||||
}) {
|
||||
const { mutePubkeys } = useMuteList()
|
||||
if (mutePubkeys.includes(notification.pubkey)) {
|
||||
return null
|
||||
}
|
||||
if (notification.kind === kinds.Reaction) {
|
||||
return <ReactionNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.ShortTextNote) {
|
||||
return <ReplyNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.Zap) {
|
||||
return <ZapNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
return <CommentNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react'
|
||||
import { Event, kinds, validateEvent } from 'nostr-tools'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
|
@ -21,9 +21,7 @@ import {
|
|||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import ContentPreview from '../ContentPreview'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import { NotificationItem } from './NotificationItem'
|
||||
|
||||
const LIMIT = 100
|
||||
const SHOW_COUNT = 30
|
||||
|
|
@ -31,13 +29,30 @@ const SHOW_COUNT = 30
|
|||
const NotificationList = forwardRef((_, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const { updateNoteStatsByEvents } = useNoteStats()
|
||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||
const [lastReadTime, setLastReadTime] = useState(0)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [notifications, setNotifications] = useState<Event[]>([])
|
||||
const [newNotifications, setNewNotifications] = useState<Event[]>([])
|
||||
const [oldNotifications, setOldNotifications] = useState<Event[]>([])
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const filterKinds = useMemo(() => {
|
||||
switch (notificationType) {
|
||||
case 'mentions':
|
||||
return [kinds.ShortTextNote, COMMENT_EVENT_KIND]
|
||||
case 'reactions':
|
||||
return [kinds.Reaction, kinds.Repost]
|
||||
case 'zaps':
|
||||
return [kinds.Zap]
|
||||
default:
|
||||
return [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, COMMENT_EVENT_KIND]
|
||||
}
|
||||
}, [notificationType])
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
|
@ -57,6 +72,9 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
|
||||
const init = async () => {
|
||||
setRefreshing(true)
|
||||
setNotifications([])
|
||||
setShowCount(SHOW_COUNT)
|
||||
setLastReadTime(storage.getLastReadNotificationTime(pubkey))
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
let eventCount = 0
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
|
|
@ -65,7 +83,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
: relayList.read.concat(BIG_RELAY_URLS).slice(0, 4),
|
||||
{
|
||||
'#p': [pubkey],
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND],
|
||||
kinds: filterKinds,
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
|
|
@ -76,6 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
if (eosed) {
|
||||
setRefreshing(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
updateNoteStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
|
|
@ -89,6 +108,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
}
|
||||
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
|
||||
})
|
||||
updateNoteStatsByEvents([event])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -100,7 +120,19 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [pubkey, refreshCount])
|
||||
}, [pubkey, refreshCount, filterKinds])
|
||||
|
||||
useEffect(() => {
|
||||
const visibleNotifications = notifications.slice(0, showCount)
|
||||
const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime)
|
||||
if (index === -1) {
|
||||
setNewNotifications(visibleNotifications)
|
||||
setOldNotifications([])
|
||||
} else {
|
||||
setNewNotifications(visibleNotifications.slice(0, index))
|
||||
setOldNotifications(visibleNotifications.slice(index))
|
||||
}
|
||||
}, [notifications, lastReadTime, showCount])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (showCount < notifications.length) {
|
||||
|
|
@ -153,160 +185,103 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
}, [loadMore])
|
||||
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
{notifications.slice(0, showCount).map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{until || refreshing ? (
|
||||
<div ref={bottomRef}>
|
||||
<div className="flex gap-2 items-center h-11 py-2">
|
||||
<Skeleton className="w-7 h-7 rounded-full" />
|
||||
<Skeleton className="h-6 flex-1 w-0" />
|
||||
</div>
|
||||
<div>
|
||||
<NotificationTypeSwitch
|
||||
type={notificationType}
|
||||
setType={(type) => {
|
||||
setShowCount(SHOW_COUNT)
|
||||
setNotificationType(type)
|
||||
}}
|
||||
/>
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div className="px-4 pt-2">
|
||||
{newNotifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} isNew />
|
||||
))}
|
||||
{!!newNotifications.length && (
|
||||
<div className="relative my-2">
|
||||
<Separator />
|
||||
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
||||
{t('Earlier notifications')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t('no more notifications')
|
||||
)}
|
||||
{oldNotifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{until || refreshing ? (
|
||||
<div ref={bottomRef}>
|
||||
<div className="flex gap-2 items-center h-11 py-2">
|
||||
<Skeleton className="w-7 h-7 rounded-full" />
|
||||
<Skeleton className="h-6 flex-1 w-0" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t('no more notifications')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
NotificationList.displayName = 'NotificationList'
|
||||
export default NotificationList
|
||||
|
||||
function NotificationItem({ notification }: { notification: Event }) {
|
||||
const { mutePubkeys } = useMuteList()
|
||||
if (mutePubkeys.includes(notification.pubkey)) {
|
||||
return null
|
||||
}
|
||||
if (notification.kind === kinds.Reaction) {
|
||||
return <ReactionNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.ShortTextNote) {
|
||||
return <ReplyNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
return <CommentNotification notification={notification} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ReactionNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { pubkey } = useNostr()
|
||||
const eventId = useMemo(() => {
|
||||
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
|
||||
if (targetPubkey !== pubkey) return undefined
|
||||
|
||||
const eTag = notification.tags.findLast(tagNameEquals('e'))
|
||||
return eTag?.[1]
|
||||
}, [notification, pubkey])
|
||||
const { event } = useFetchEvent(eventId)
|
||||
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
|
||||
return null
|
||||
}
|
||||
function NotificationTypeSwitch({
|
||||
type,
|
||||
setType
|
||||
}: {
|
||||
type: TNotificationType
|
||||
setType: (type: TNotificationType) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
className={cn(
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform select-none',
|
||||
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Heart size={24} className="text-red-400" />
|
||||
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
|
||||
<ContentPreview className="truncate flex-1 w-0" event={event} />
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'all' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('all')}
|
||||
>
|
||||
{t('All')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'mentions' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('mentions')}
|
||||
>
|
||||
{t('Mentions')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'reactions' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('reactions')}
|
||||
>
|
||||
{t('Reactions')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'zaps' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('zaps')}
|
||||
>
|
||||
{t('Zaps')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(notification))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepostNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const event = useMemo(() => {
|
||||
try {
|
||||
const event = JSON.parse(notification.content) as Event
|
||||
const isValid = validateEvent(event)
|
||||
if (!isValid) return null
|
||||
client.addEventToCache(event)
|
||||
return event
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [notification.content])
|
||||
if (!event) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Repeat size={24} className="text-green-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={event} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
|
||||
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
|
||||
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
|
||||
if (
|
||||
!rootEventId ||
|
||||
!rootPubkey ||
|
||||
!rootKind ||
|
||||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
<div
|
||||
className={`w-1/4 px-4 sm:px-6 transition-transform duration-500 ${type === 'mentions' ? 'translate-x-full' : type === 'reactions' ? 'translate-x-[200%]' : type === 'zaps' ? 'translate-x-[300%]' : ''} `}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ContentPreview from '../ContentPreview'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
|
||||
export default function Title({ parentEvent }: { parentEvent?: Event }) {
|
||||
|
|
@ -9,7 +10,7 @@ export default function Title({ parentEvent }: { parentEvent?: Event }) {
|
|||
<div className="flex gap-2 items-center w-full">
|
||||
<div className="shrink-0">{t('Reply to')}</div>
|
||||
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" />
|
||||
<div className="flex-1 w-0 truncate">{parentEvent.content}</div>
|
||||
<ContentPreview className="flex-1 w-0 truncate h-5" event={parentEvent} />
|
||||
</div>
|
||||
) : (
|
||||
t('New Note')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
|
|
@ -27,7 +26,7 @@ export default function ProfileBanner({
|
|||
<Image
|
||||
image={{ url: bannerUrl }}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('rounded-lg', className)}
|
||||
className={className}
|
||||
onError={() => setBannerUrl(defaultBanner)}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
<Ellipsis className="text-muted-foreground hover:text-foreground cursor-pointer" />
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
|
|
|
|||
24
src/components/ProfileZapButton/index.tsx
Normal file
24
src/components/ProfileZapButton/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function ProfileZapButton({ pubkey }: { pubkey: string }) {
|
||||
const { checkLogin } = useNostr()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => checkLogin(() => setOpen(true))}
|
||||
>
|
||||
<Zap className="text-yellow-400" />
|
||||
</Button>
|
||||
<ZapDialog open={open} setOpen={setOpen} pubkey={pubkey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,13 +4,14 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { toProfile, toWallet } from '@/lib/link'
|
||||
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { LogIn } from 'lucide-react'
|
||||
import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoginDialog from '../LoginDialog'
|
||||
|
|
@ -57,15 +58,26 @@ function ProfileButton() {
|
|||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||
<DropdownMenuContent className="w-56" side="top">
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>
|
||||
<UserRound />
|
||||
{t('Profile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => push(toWallet())}>
|
||||
<Wallet />
|
||||
{t('Wallet')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||
<ArrowDownUp />
|
||||
{t('Switch account')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setLogoutDialogOpen(true)}
|
||||
>
|
||||
<LogOut />
|
||||
{t('Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { Bell } from 'lucide-react'
|
||||
import SidebarItem from './SidebarItem'
|
||||
|
||||
export default function NotificationsButton() {
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
const { hasNewNotification } = useNotification()
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
|
|
@ -11,7 +13,12 @@ export default function NotificationsButton() {
|
|||
onClick={() => navigate('notifications')}
|
||||
active={current === 'notifications'}
|
||||
>
|
||||
<Bell strokeWidth={3} />
|
||||
<div className="relative">
|
||||
<Bell strokeWidth={3} />
|
||||
{hasNewNotification && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function PrimaryPageSidebar() {
|
|||
return (
|
||||
<div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0">
|
||||
<div className="space-y-2">
|
||||
<div className="px-2 mb-8 w-full">
|
||||
<div className="px-3 xl:px-4 mb-6 w-full">
|
||||
<Icon className="xl:hidden" />
|
||||
<Logo className="max-xl:hidden" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export default function UserAvatar({
|
|||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
return (
|
||||
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
)
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
|
|
@ -42,7 +44,7 @@ export default function UserAvatar({
|
|||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)}>
|
||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={pubkey} />
|
||||
|
|
@ -64,7 +66,7 @@ export function SimpleUserAvatar({
|
|||
onClick
|
||||
}: {
|
||||
userId: string
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
|
|
@ -75,12 +77,14 @@ export function SimpleUserAvatar({
|
|||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
return (
|
||||
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
)
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
return (
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={pubkey} />
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ export default function VideoPlayer({
|
|||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
|
||||
className={cn('rounded-lg', size === 'small' ? 'h-[15vh]' : 'h-[30vh]', className)}
|
||||
src={src}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</div>
|
||||
|
|
|
|||
162
src/components/ZapDialog/index.tsx
Normal file
162
src/components/ZapDialog/index.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/hooks'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function ZapDialog({
|
||||
open,
|
||||
setOpen,
|
||||
pubkey,
|
||||
eventId,
|
||||
defaultAmount
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
defaultAmount?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex gap-2 items-center">
|
||||
<div className="shrink-0">{t('Zap to')}</div>
|
||||
<UserAvatar size="small" userId={pubkey} />
|
||||
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ZapDialogContent
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
recipient={pubkey}
|
||||
eventId={eventId}
|
||||
defaultAmount={defaultAmount}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ZapDialogContent({
|
||||
setOpen,
|
||||
recipient,
|
||||
eventId,
|
||||
defaultAmount
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
recipient: string
|
||||
eventId?: string
|
||||
defaultAmount?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { pubkey } = useNostr()
|
||||
const { defaultZapSats, defaultZapComment } = useZap()
|
||||
const { addZap } = useNoteStats()
|
||||
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
|
||||
const [comment, setComment] = useState(defaultZapComment)
|
||||
const [zapping, setZapping] = useState(false)
|
||||
|
||||
const handleZap = async () => {
|
||||
try {
|
||||
if (!pubkey) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
setZapping(true)
|
||||
const { invoice } = await lightning.zap(pubkey, recipient, sats, comment, eventId, () =>
|
||||
setOpen(false)
|
||||
)
|
||||
if (eventId) {
|
||||
addZap(eventId, invoice, sats, comment)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('Zap failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
setZapping(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sats slider or input */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex justify-center w-full">
|
||||
<input
|
||||
id="sats"
|
||||
value={sats}
|
||||
onChange={(e) => {
|
||||
setSats((pre) => {
|
||||
if (e.target.value === '') {
|
||||
return 0
|
||||
}
|
||||
let num = parseInt(e.target.value, 10)
|
||||
if (isNaN(num) || num < 0) {
|
||||
num = pre
|
||||
}
|
||||
return num
|
||||
})
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
requestAnimationFrame(() => {
|
||||
const val = e.target.value
|
||||
e.target.setSelectionRange(val.length, val.length)
|
||||
})
|
||||
}}
|
||||
className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="sats">{t('Sats')}</Label>
|
||||
</div>
|
||||
|
||||
{/* Preset sats buttons */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{[
|
||||
{ display: '21', val: 21 },
|
||||
{ display: '66', val: 66 },
|
||||
{ display: '210', val: 210 },
|
||||
{ display: '666', val: 666 },
|
||||
{ display: '1k', val: 1000 },
|
||||
{ display: '2.1k', val: 2100 },
|
||||
{ display: '6.6k', val: 6666 },
|
||||
{ display: '10k', val: 10000 },
|
||||
{ display: '21k', val: 21000 },
|
||||
{ display: '66k', val: 66666 },
|
||||
{ display: '100k', val: 100000 },
|
||||
{ display: '210k', val: 210000 }
|
||||
].map(({ display, val }) => (
|
||||
<Button variant="secondary" key={val} onClick={() => setSats(val)}>
|
||||
{display}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
<div>
|
||||
<Label htmlFor="comment">{t('zapComment')}</Label>
|
||||
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleZap}>
|
||||
{zapping && <Loader className="animate-spin" />} {t('Zap n sats', { n: sats })}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue