feat: emoji reactions
This commit is contained in:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
38
src/components/EmojiPicker/index.tsx
Normal file
38
src/components/EmojiPicker/index.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/components/NoteStats/Likes.tsx
Normal file
78
src/components/NoteStats/Likes.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
52
src/components/SuggestedEmojis/index.tsx
Normal file
52
src/components/SuggestedEmojis/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user