feat: zap (#107)

This commit is contained in:
Cody Tseng 2025-03-01 23:52:05 +08:00 committed by GitHub
parent 407a6fb802
commit 249593d547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2582 additions and 818 deletions

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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
}