feat: emoji reactions

This commit is contained in:
codytseng
2025-04-22 22:36:53 +08:00
parent 40b487994d
commit 2c9a5b219b
15 changed files with 382 additions and 50 deletions

20
package-lock.json generated
View File

@@ -33,6 +33,7 @@
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
@@ -5341,6 +5342,20 @@
"embla-carousel": "8.5.1" "embla-carousel": "8.5.1"
} }
}, },
"node_modules/emoji-picker-react": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
"integrity": "sha512-6PDYZGlhidt+Kc0ay890IU4HLNfIR7/OxPvcNxw+nJ4HQhMKd8pnGnPn4n2vqC/arRFCNWQhgJP8rpsYKsz0GQ==",
"dependencies": {
"flairup": "1.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -5828,6 +5843,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/flairup": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA=="
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",

View File

@@ -43,6 +43,7 @@
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Heart } from 'lucide-react'
import { HTMLAttributes, useState } from 'react' import { HTMLAttributes, useState } from 'react'
export default function Emoji({ export default function Emoji({
@@ -7,11 +8,21 @@ export default function Emoji({
className = '' className = ''
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
className?: string className?: string
emoji: TEmoji emoji: TEmoji | string
}) { }) {
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
if (hasError) return `:${emoji.shortcode}:` if (typeof emoji === 'string') {
return emoji === '+' ? (
<Heart className={cn('size-4 text-red-400 fill-red-400', className)} />
) : (
<span className={cn('whitespace-nowrap', className)}>{emoji}</span>
)
}
if (hasError) {
return <span className={cn('whitespace-nowrap', className)}>{`:${emoji.shortcode}:`}</span>
}
return ( return (
<img <img

View File

@@ -0,0 +1,38 @@
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import EmojiPickerReact, {
EmojiStyle,
SkinTonePickerLocation,
SuggestionMode,
Theme
} from 'emoji-picker-react'
import { MouseDownEvent } from 'emoji-picker-react/dist/config/config'
export default function EmojiPicker({ onEmojiClick }: { onEmojiClick: MouseDownEvent }) {
const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize()
return (
<EmojiPickerReact
theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
}
width={isSmallScreen ? '100%' : 350}
autoFocusSearch={false}
searchDisabled
emojiStyle={EmojiStyle.NATIVE}
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW}
style={
{
'--epr-bg-color': 'hsl(var(--background))',
'--epr-category-label-bg-color': 'hsl(var(--background))',
'--epr-text-color': 'hsl(var(--foreground))',
'--epr-hover-bg-color': 'hsl(var(--muted) / 0.5)',
'--epr-picker-border-color': 'transparent'
} as React.CSSProperties
}
suggestedEmojisMode={SuggestionMode.FREQUENT}
onEmojiClick={onEmojiClick}
/>
)
}

View File

@@ -1,42 +1,50 @@
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { createReactionDraftEvent } from '@/lib/draft-event' import { createReactionDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Heart, Loader } from 'lucide-react' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatCount } from './utils' import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
export default function LikeButton({ event }: { event: Event }) { export default function LikeButton({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats() const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const { likeCount, hasLiked } = useMemo(() => { const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false)
const myLastEmoji = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {} const stats = noteStatsMap.get(event.id) || {}
return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false } const like = stats.likes?.find((like) => like.pubkey === pubkey)
return like?.emoji
}, [noteStatsMap, event, pubkey]) }, [noteStatsMap, event, pubkey])
const canLike = !hasLiked && !liking
const like = async (e: React.MouseEvent) => { const like = async (emoji: string) => {
e.stopPropagation()
checkLogin(async () => { checkLogin(async () => {
if (!canLike || !pubkey) return if (liking || !pubkey) return
setLiking(true) setLiking(true)
const timer = setTimeout(() => setLiking(false), 5000) const timer = setTimeout(() => setLiking(false), 5000)
try { try {
const noteStats = noteStatsMap.get(event.id) const noteStats = noteStatsMap.get(event.id)
const hasLiked = noteStats?.likes?.has(pubkey)
if (hasLiked) return
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
const stats = await fetchNoteStats(event) await fetchNoteStats(event)
if (stats?.likes?.has(pubkey)) return
} }
const reaction = createReactionDraftEvent(event) const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction) const evt = await publish(reaction)
updateNoteStatsByEvents([evt]) updateNoteStatsByEvents([evt])
} catch (error) { } catch (error) {
@@ -48,22 +56,82 @@ export default function LikeButton({ event }: { event: Event }) {
}) })
} }
return ( const trigger = (
<button <button
className={cn( className={cn(
'flex items-center enabled:hover:text-red-400 gap-1 px-3 h-full', 'flex items-center enabled:hover:text-primary gap-1 px-3 h-full',
hasLiked ? 'text-red-400' : 'text-muted-foreground' !myLastEmoji ? 'text-muted-foreground' : ''
)} )}
onClick={like}
disabled={!canLike}
title={t('Like')} title={t('Like')}
onClick={() => {
if (isSmallScreen) {
setIsEmojiReactionsOpen(true)
}
}}
> >
{liking ? ( {liking ? (
<Loader className="animate-spin" /> <Loader className="animate-spin" />
) : myLastEmoji ? (
<div className="h-5 w-5 flex items-center justify-center">
<Emoji emoji={myLastEmoji} />
</div>
) : ( ) : (
<Heart className={hasLiked ? 'fill-red-400' : ''} /> <SmilePlus />
)} )}
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</button> </button>
) )
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
<DrawerContent hideOverlay>
<EmojiPicker
onEmojiClick={(data) => {
setIsEmojiReactionsOpen(false)
like(data.emoji)
}}
/>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu
open={isEmojiReactionsOpen}
onOpenChange={(open) => {
setIsEmojiReactionsOpen(open)
if (open) {
setIsPickerOpen(false)
}
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit">
{isPickerOpen ? (
<EmojiPicker
onEmojiClick={(data, e) => {
e.stopPropagation()
setIsEmojiReactionsOpen(false)
like(data.emoji)
}}
/>
) : (
<SuggestedEmojis
onEmojiClick={(emoji) => {
setIsEmojiReactionsOpen(false)
like(emoji)
}}
onMoreButtonClick={() => {
setIsPickerOpen(true)
}}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)
} }

View File

@@ -0,0 +1,78 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { createReactionDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { TEmoji } from '@/types'
import { Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import Emoji from '../Emoji'
export default function Likes({ event }: { event: Event }) {
const { pubkey, checkLogin, publish } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents } = useNoteStats()
const [liking, setLiking] = useState<string | null>(null)
const likes = useMemo(() => {
const _likes = noteStatsMap.get(event.id)?.likes
if (!_likes) return []
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
_likes.forEach((item) => {
const key = typeof item.emoji === 'string' ? item.emoji : item.emoji.url
if (!stats.has(key)) {
stats.set(key, { key, pubkeys: new Set(), emoji: item.emoji })
}
stats.get(key)?.pubkeys.add(item.pubkey)
})
return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size)
}, [noteStatsMap, event])
if (!likes.length) return null
const like = async (key: string, emoji: TEmoji | string) => {
checkLogin(async () => {
if (liking || !pubkey) return
setLiking(key)
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
try {
const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction)
updateNoteStatsByEvents([evt])
} catch (error) {
console.error('like failed', error)
} finally {
setLiking(null)
clearTimeout(timer)
}
})
}
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{likes.map(({ key, emoji, pubkeys }) => (
<div
key={key}
className={cn(
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0',
pubkey && pubkeys.has(pubkey)
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
: 'transition-colors bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground'
)}
onClick={(e) => {
e.stopPropagation()
like(key, emoji)
}}
>
{liking === key ? <Loader className="animate-spin size-5" /> : <Emoji emoji={emoji} />}
<div className="text-sm">{pubkeys.size}</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View File

@@ -1,16 +1,15 @@
import { useSecondaryPage } from '@/PageManager'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { formatAmount } from '@/lib/lightning' import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import ZapDialog from '../ZapDialog'
export default function TopZaps({ event }: { event: Event }) { export default function TopZaps({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const { noteStatsMap } = useNoteStats() const { noteStatsMap } = useNoteStats()
const [zapIndex, setZapIndex] = useState(-1)
const topZaps = useMemo(() => { const topZaps = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {} const stats = noteStatsMap.get(event.id) || {}
return stats.zaps?.slice(0, 10) || [] return stats.zaps?.slice(0, 10) || []
@@ -21,19 +20,35 @@ export default function TopZaps({ event }: { event: Event }) {
return ( return (
<ScrollArea className="pb-2 mb-1"> <ScrollArea className="pb-2 mb-1">
<div className="flex gap-1"> <div className="flex gap-1">
{topZaps.map((zap) => ( {topZaps.map((zap, index) => (
<div <div
key={zap.pr} 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" className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
push(toProfile(zap.pubkey)) setZapIndex(index)
}} }}
> >
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" /> <SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
<Zap className="size-3 fill-yellow-400" /> <Zap className="size-3 fill-yellow-400" />
<div className="font-semibold">{formatAmount(zap.amount)}</div> <div className="font-semibold">{formatAmount(zap.amount)}</div>
<div className="truncate">{zap.comment}</div> <div className="truncate">{zap.comment}</div>
<div onClick={(e) => e.stopPropagation()}>
<ZapDialog
open={zapIndex === index}
setOpen={(open) => {
if (open) {
setZapIndex(index)
} else {
setZapIndex(-1)
}
}}
pubkey={event.pubkey}
eventId={event.id}
defaultAmount={zap.amount}
defaultComment={zap.comment}
/>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,9 +2,10 @@ import { cn } from '@/lib/utils'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import LikeButton from './LikeButton' import LikeButton from './LikeButton'
import Likes from './Likes'
import ReplyButton from './ReplyButton' import ReplyButton from './ReplyButton'
import RepostButton from './RepostButton' import RepostButton from './RepostButton'
import SeenOnButton from './SeenOnButton' import SeenOnButton from './SeenOnButton'
@@ -28,19 +29,23 @@ export default function NoteStats({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { fetchNoteStats } = useNoteStats() const { fetchNoteStats } = useNoteStats()
const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
fetchNoteStats(event) setLoading(true)
fetchNoteStats(event).finally(() => setLoading(false))
}, [event, fetchIfNotExisting]) }, [event, fetchIfNotExisting])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<div className={cn('select-none', className)}> <div className={cn('select-none', className)}>
<TopZaps event={event} /> <TopZaps event={event} />
<Likes event={event} />
<div <div
className={cn( className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5', 'flex justify-between items-center h-5 [&_svg]:size-5',
loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -59,8 +64,12 @@ export default function NoteStats({
return ( return (
<div className={cn('select-none', className)}> <div className={cn('select-none', className)}>
<TopZaps event={event} /> <TopZaps event={event} />
<Likes event={event} />
<div className="flex justify-between h-5 [&_svg]:size-4"> <div className="flex justify-between h-5 [&_svg]:size-4">
<div className="flex items-center" onClick={(e) => e.stopPropagation()}> <div
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
onClick={(e) => e.stopPropagation()}
>
<ReplyButton event={event} variant={variant} /> <ReplyButton event={event} variant={variant} />
<RepostButton event={event} /> <RepostButton event={event} />
<LikeButton event={event} /> <LikeButton event={event} />

View File

@@ -34,7 +34,7 @@ export function ZapNotification({
return ( return (
<div <div
className="flex items-center justify-between cursor-pointer py-2" className="flex items-center justify-between cursor-pointer py-2"
onClick={() => (event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)} onClick={() => (eventId ? push(toNote(eventId)) : pubkey ? push(toProfile(pubkey)) : null)}
> >
<div className="flex gap-2 items-center flex-1 w-0"> <div className="flex gap-2 items-center flex-1 w-0">
<UserAvatar userId={senderPubkey} size="small" /> <UserAvatar userId={senderPubkey} size="small" />

View File

@@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button'
import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji'
import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import { MoreHorizontal } from 'lucide-react'
import { useEffect, useState } from 'react'
export default function SuggestedEmojis({
onEmojiClick,
onMoreButtonClick
}: {
onEmojiClick: (emoji: string) => void
onMoreButtonClick: () => void
}) {
const [suggestedEmojis, setSuggestedEmojis] = useState<string[]>([
'1f44d',
'2764-fe0f',
'1f602',
'1f972',
'1f440',
'1fae1',
'1fac2'
]) // 👍 ❤️ 😂 🥲 👀 🫡 🫂
useEffect(() => {
try {
const suggested = getSuggested()
const suggestEmojis = suggested.sort((a, b) => b.count - a.count).map((item) => item.unified)
setSuggestedEmojis((pre) =>
[...suggestEmojis, ...pre.filter((e) => !suggestEmojis.includes(e))].slice(0, 8)
)
} catch {
// ignore
}
}, [])
return (
<div className="flex gap-2 p-1" onClick={(e) => e.stopPropagation()}>
{suggestedEmojis.map((emoji, index) => (
<div
key={index}
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl"
onClick={() => onEmojiClick(parseNativeEmoji(emoji))}
>
{parseNativeEmoji(emoji)}
</div>
))}
<Button variant="ghost" className="w-8 h-8 text-muted-foreground" onClick={onMoreButtonClick}>
<MoreHorizontal size={24} />
</Button>
</div>
)
}

View File

@@ -33,13 +33,15 @@ export default function ZapDialog({
setOpen, setOpen,
pubkey, pubkey,
eventId, eventId,
defaultAmount defaultAmount,
defaultComment
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
pubkey: string pubkey: string
eventId?: string eventId?: string
defaultAmount?: number defaultAmount?: number
defaultComment?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@@ -88,6 +90,7 @@ export default function ZapDialog({
recipient={pubkey} recipient={pubkey}
eventId={eventId} eventId={eventId}
defaultAmount={defaultAmount} defaultAmount={defaultAmount}
defaultComment={defaultComment}
/> />
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -102,7 +105,7 @@ export default function ZapDialog({
<DialogTitle className="flex gap-2 items-center"> <DialogTitle className="flex gap-2 items-center">
<div className="shrink-0">{t('Zap to')}</div> <div className="shrink-0">{t('Zap to')}</div>
<UserAvatar size="small" userId={pubkey} /> <UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" /> <Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" />
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<ZapDialogContent <ZapDialogContent
@@ -111,6 +114,7 @@ export default function ZapDialog({
recipient={pubkey} recipient={pubkey}
eventId={eventId} eventId={eventId}
defaultAmount={defaultAmount} defaultAmount={defaultAmount}
defaultComment={defaultComment}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -121,13 +125,15 @@ function ZapDialogContent({
setOpen, setOpen,
recipient, recipient,
eventId, eventId,
defaultAmount defaultAmount,
defaultComment
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
recipient: string recipient: string
eventId?: string eventId?: string
defaultAmount?: number defaultAmount?: number
defaultComment?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { toast } = useToast() const { toast } = useToast()
@@ -135,7 +141,7 @@ function ZapDialogContent({
const { defaultZapSats, defaultZapComment } = useZap() const { defaultZapSats, defaultZapComment } = useZap()
const { addZap } = useNoteStats() const { addZap } = useNoteStats()
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats) const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
const [comment, setComment] = useState(defaultZapComment) const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const handleZap = async () => { const handleZap = async () => {

View File

@@ -67,7 +67,7 @@ export const EmbeddedNormalUrlParser: TContentParser = {
export const EmbeddedEmojiParser: TContentParser = { export const EmbeddedEmojiParser: TContentParser = {
type: 'emoji', type: 'emoji',
regex: /:[a-zA-Z0-9_]+:/g regex: /:[a-zA-Z0-9_-]+:/g
} }
export function parseContent(content: string, parsers: TContentParser[]) { export function parseContent(content: string, parsers: TContentParser[]) {

View File

@@ -1,6 +1,6 @@
import { ApplicationDataKey, ExtendedKind } from '@/constants' import { ApplicationDataKey, ExtendedKind } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types' import { TDraftEvent, TEmoji, TMailboxRelay, TRelaySet } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { import {
@@ -14,7 +14,7 @@ import {
} from './event' } from './event'
// https://github.com/nostr-protocol/nips/blob/master/25.md // https://github.com/nostr-protocol/nips/blob/master/25.md
export function createReactionDraftEvent(event: Event): TDraftEvent { export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
const tags: string[][] = [] const tags: string[][] = []
const hint = client.getEventHint(event.id) const hint = client.getEventHint(event.id)
tags.push(['e', event.id, hint, event.pubkey]) tags.push(['e', event.id, hint, event.pubkey])
@@ -27,9 +27,17 @@ export function createReactionDraftEvent(event: Event): TDraftEvent {
tags.push(hint ? ['a', getEventCoordinate(event), hint] : ['a', getEventCoordinate(event)]) tags.push(hint ? ['a', getEventCoordinate(event), hint] : ['a', getEventCoordinate(event)])
} }
let content: string
if (typeof emoji === 'string') {
content = emoji
} else {
content = `:${emoji.shortcode}:`
tags.push(['emoji', emoji.shortcode, emoji.url])
}
return { return {
kind: kinds.Reaction, kind: kinds.Reaction,
content: '+', content,
tags, tags,
created_at: dayjs().unix() created_at: dayjs().unix()
} }

View File

@@ -426,7 +426,8 @@ export function extractZapInfoFromReceipt(receiptEvent: Event) {
let description: string | undefined let description: string | undefined
let preimage: string | undefined let preimage: string | undefined
try { try {
receiptEvent.tags.forEach(([tagName, tagValue]) => { receiptEvent.tags.forEach((tag) => {
const [tagName, tagValue] = tag
switch (tagName) { switch (tagName) {
case 'P': case 'P':
senderPubkey = tagValue senderPubkey = tagValue
@@ -435,7 +436,7 @@ export function extractZapInfoFromReceipt(receiptEvent: Event) {
recipientPubkey = tagValue recipientPubkey = tagValue
break break
case 'e': case 'e':
eventId = tagValue eventId = generateEventIdFromETag(tag)
break break
case 'bolt11': case 'bolt11':
invoice = tagValue invoice = tagValue

View File

@@ -1,13 +1,14 @@
import { extractZapInfoFromReceipt } from '@/lib/event' import { extractEmojiInfosFromTags, extractZapInfoFromReceipt } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
export type TNoteStats = { export type TNoteStats = {
likes: Set<string> likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
reposts: Set<string> reposts: Set<string>
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
replyCount: number replyCount: number
@@ -123,7 +124,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
const updateNoteStatsByEvents = (events: Event[]) => { const updateNoteStatsByEvents = (events: Event[]) => {
const newRepostsMap = new Map<string, Set<string>>() const newRepostsMap = new Map<string, Set<string>>()
const newLikesMap = new Map<string, Set<string>>() const newLikesMap = new Map<
string,
{ id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
>()
const newZapsMap = new Map< const newZapsMap = new Map<
string, string,
{ pr: string; pubkey: string; amount: number; comment?: string }[] { pr: string; pubkey: string; amount: number; comment?: string }[]
@@ -141,8 +145,23 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
if (evt.kind === kinds.Reaction) { if (evt.kind === kinds.Reaction) {
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1] const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1]
if (targetEventId) { if (targetEventId) {
const newLikes = newLikesMap.get(targetEventId) || new Set() const newLikes = newLikesMap.get(targetEventId) || []
newLikes.add(evt.pubkey) if (newLikes.some((like) => like.id === evt.id)) return
let emoji: TEmoji | string = evt.content.trim()
if (!emoji) return
if (/^:[a-zA-Z0-9_-]+:$/.test(evt.content)) {
const emojiInfos = extractEmojiInfosFromTags(evt.tags)
const shortcode = evt.content.split(':')[1]
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
if (emojiInfo) {
emoji = emojiInfo
} else {
console.log(`Emoji not found for shortcode: ${shortcode}`, emojiInfos)
}
}
newLikes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
newLikesMap.set(targetEventId, newLikes) newLikesMap.set(targetEventId, newLikes)
} }
return return
@@ -168,8 +187,14 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
}) })
newLikesMap.forEach((newLikes, eventId) => { newLikesMap.forEach((newLikes, eventId) => {
const old = prev.get(eventId) || {} const old = prev.get(eventId) || {}
const likes = old.likes || new Set() const likes = old.likes || []
newLikes.forEach((like) => likes.add(like)) newLikes.forEach((like) => {
const exists = likes.find((l) => l.id === like.id)
if (!exists) {
likes.push(like)
}
})
likes.sort((a, b) => b.created_at - a.created_at)
prev.set(eventId, { ...old, likes }) prev.set(eventId, { ...old, likes })
}) })
newZapsMap.forEach((newZaps, eventId) => { newZapsMap.forEach((newZaps, eventId) => {