From 2c9a5b219ba8c2fcd6d51339f69dfd840a8cb1b9 Mon Sep 17 00:00:00 2001 From: codytseng Date: Tue, 22 Apr 2025 22:36:53 +0800 Subject: [PATCH] feat: emoji reactions --- package-lock.json | 20 ++++ package.json | 1 + src/components/Emoji/index.tsx | 15 ++- src/components/EmojiPicker/index.tsx | 38 ++++++ src/components/NoteStats/LikeButton.tsx | 108 ++++++++++++++---- src/components/NoteStats/Likes.tsx | 78 +++++++++++++ src/components/NoteStats/TopZaps.tsx | 29 +++-- src/components/NoteStats/index.tsx | 15 ++- .../NotificationItem/ZapNotification.tsx | 2 +- src/components/SuggestedEmojis/index.tsx | 52 +++++++++ src/components/ZapDialog/index.tsx | 14 ++- src/lib/content-parser.ts | 2 +- src/lib/draft-event.ts | 14 ++- src/lib/event.ts | 5 +- src/providers/NoteStatsProvider.tsx | 39 +++++-- 15 files changed, 382 insertions(+), 50 deletions(-) create mode 100644 src/components/EmojiPicker/index.tsx create mode 100644 src/components/NoteStats/Likes.tsx create mode 100644 src/components/SuggestedEmojis/index.tsx diff --git a/package-lock.json b/package-lock.json index 7be7a8f3..e8e95c24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.5.1", + "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", @@ -5341,6 +5342,20 @@ "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": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5828,6 +5843,11 @@ "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", diff --git a/package.json b/package.json index 59007f5f..39f73eb8 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.5.1", + "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index c2016358..66715db9 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -1,5 +1,6 @@ import { cn } from '@/lib/utils' import { TEmoji } from '@/types' +import { Heart } from 'lucide-react' import { HTMLAttributes, useState } from 'react' export default function Emoji({ @@ -7,11 +8,21 @@ export default function Emoji({ className = '' }: HTMLAttributes & { className?: string - emoji: TEmoji + emoji: TEmoji | string }) { const [hasError, setHasError] = useState(false) - if (hasError) return `:${emoji.shortcode}:` + if (typeof emoji === 'string') { + return emoji === '+' ? ( + + ) : ( + {emoji} + ) + } + + if (hasError) { + return {`:${emoji.shortcode}:`} + } return ( + ) +} diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 99276746..89da53c5 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -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 { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' 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 { useMemo, useState } from 'react' 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 }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() const { pubkey, publish, checkLogin } = useNostr() const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats() 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) || {} - 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]) - const canLike = !hasLiked && !liking - const like = async (e: React.MouseEvent) => { - e.stopPropagation() + const like = async (emoji: string) => { checkLogin(async () => { - if (!canLike || !pubkey) return + if (liking || !pubkey) return setLiking(true) const timer = setTimeout(() => setLiking(false), 5000) try { 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 + await fetchNoteStats(event) } - const reaction = createReactionDraftEvent(event) + const reaction = createReactionDraftEvent(event, emoji) const evt = await publish(reaction) updateNoteStatsByEvents([evt]) } catch (error) { @@ -48,22 +56,82 @@ export default function LikeButton({ event }: { event: Event }) { }) } - return ( + const trigger = ( ) + + if (isSmallScreen) { + return ( + <> + {trigger} + + setIsEmojiReactionsOpen(false)} /> + + { + setIsEmojiReactionsOpen(false) + like(data.emoji) + }} + /> + + + + ) + } + + return ( + { + setIsEmojiReactionsOpen(open) + if (open) { + setIsPickerOpen(false) + } + }} + > + {trigger} + + {isPickerOpen ? ( + { + e.stopPropagation() + setIsEmojiReactionsOpen(false) + like(data.emoji) + }} + /> + ) : ( + { + setIsEmojiReactionsOpen(false) + like(emoji) + }} + onMoreButtonClick={() => { + setIsPickerOpen(true) + }} + /> + )} + + + ) } diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx new file mode 100644 index 00000000..b24ade7a --- /dev/null +++ b/src/components/NoteStats/Likes.tsx @@ -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(null) + const likes = useMemo(() => { + const _likes = noteStatsMap.get(event.id)?.likes + if (!_likes) return [] + + const stats = new Map }>() + _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 ( + +
+ {likes.map(({ key, emoji, pubkeys }) => ( +
{ + e.stopPropagation() + like(key, emoji) + }} + > + {liking === key ? : } +
{pubkeys.size}
+
+ ))} +
+ +
+ ) +} diff --git a/src/components/NoteStats/TopZaps.tsx b/src/components/NoteStats/TopZaps.tsx index 0f6b8aee..560b08fe 100644 --- a/src/components/NoteStats/TopZaps.tsx +++ b/src/components/NoteStats/TopZaps.tsx @@ -1,16 +1,15 @@ -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 { useMemo, useState } from 'react' import { SimpleUserAvatar } from '../UserAvatar' +import ZapDialog from '../ZapDialog' export default function TopZaps({ event }: { event: Event }) { - const { push } = useSecondaryPage() const { noteStatsMap } = useNoteStats() + const [zapIndex, setZapIndex] = useState(-1) const topZaps = useMemo(() => { const stats = noteStatsMap.get(event.id) || {} return stats.zaps?.slice(0, 10) || [] @@ -21,19 +20,35 @@ export default function TopZaps({ event }: { event: Event }) { return (
- {topZaps.map((zap) => ( + {topZaps.map((zap, index) => (
{ e.stopPropagation() - push(toProfile(zap.pubkey)) + setZapIndex(index) }} >
{formatAmount(zap.amount)}
{zap.comment}
+
e.stopPropagation()}> + { + if (open) { + setZapIndex(index) + } else { + setZapIndex(-1) + } + }} + pubkey={event.pubkey} + eventId={event.id} + defaultAmount={zap.amount} + defaultComment={zap.comment} + /> +
))}
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 9185b141..f104027a 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -2,9 +2,10 @@ import { cn } from '@/lib/utils' import { useNoteStats } from '@/providers/NoteStatsProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { Event } from 'nostr-tools' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import BookmarkButton from '../BookmarkButton' import LikeButton from './LikeButton' +import Likes from './Likes' import ReplyButton from './ReplyButton' import RepostButton from './RepostButton' import SeenOnButton from './SeenOnButton' @@ -28,19 +29,23 @@ export default function NoteStats({ }) { const { isSmallScreen } = useScreenSize() const { fetchNoteStats } = useNoteStats() + const [loading, setLoading] = useState(false) useEffect(() => { if (!fetchIfNotExisting) return - fetchNoteStats(event) + setLoading(true) + fetchNoteStats(event).finally(() => setLoading(false)) }, [event, fetchIfNotExisting]) if (isSmallScreen) { return (
+
e.stopPropagation()} @@ -59,8 +64,12 @@ export default function NoteStats({ return (
+
-
e.stopPropagation()}> +
e.stopPropagation()} + > diff --git a/src/components/NotificationList/NotificationItem/ZapNotification.tsx b/src/components/NotificationList/NotificationItem/ZapNotification.tsx index 1ac6ae86..fa25e151 100644 --- a/src/components/NotificationList/NotificationItem/ZapNotification.tsx +++ b/src/components/NotificationList/NotificationItem/ZapNotification.tsx @@ -34,7 +34,7 @@ export function ZapNotification({ return (
(event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)} + onClick={() => (eventId ? push(toNote(eventId)) : pubkey ? push(toProfile(pubkey)) : null)} >
diff --git a/src/components/SuggestedEmojis/index.tsx b/src/components/SuggestedEmojis/index.tsx new file mode 100644 index 00000000..0a5c734b --- /dev/null +++ b/src/components/SuggestedEmojis/index.tsx @@ -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([ + '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 ( +
e.stopPropagation()}> + {suggestedEmojis.map((emoji, index) => ( +
onEmojiClick(parseNativeEmoji(emoji))} + > + {parseNativeEmoji(emoji)} +
+ ))} + +
+ ) +} diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 08d87426..3f1be6c6 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -33,13 +33,15 @@ export default function ZapDialog({ setOpen, pubkey, eventId, - defaultAmount + defaultAmount, + defaultComment }: { open: boolean setOpen: Dispatch> pubkey: string eventId?: string defaultAmount?: number + defaultComment?: string }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -88,6 +90,7 @@ export default function ZapDialog({ recipient={pubkey} eventId={eventId} defaultAmount={defaultAmount} + defaultComment={defaultComment} /> @@ -102,7 +105,7 @@ export default function ZapDialog({
{t('Zap to')}
- +
@@ -121,13 +125,15 @@ function ZapDialogContent({ setOpen, recipient, eventId, - defaultAmount + defaultAmount, + defaultComment }: { open: boolean setOpen: Dispatch> recipient: string eventId?: string defaultAmount?: number + defaultComment?: string }) { const { t } = useTranslation() const { toast } = useToast() @@ -135,7 +141,7 @@ function ZapDialogContent({ const { defaultZapSats, defaultZapComment } = useZap() const { addZap } = useNoteStats() const [sats, setSats] = useState(defaultAmount ?? defaultZapSats) - const [comment, setComment] = useState(defaultZapComment) + const [comment, setComment] = useState(defaultComment ?? defaultZapComment) const [zapping, setZapping] = useState(false) const handleZap = async () => { diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts index caee2765..2d112703 100644 --- a/src/lib/content-parser.ts +++ b/src/lib/content-parser.ts @@ -67,7 +67,7 @@ export const EmbeddedNormalUrlParser: TContentParser = { export const EmbeddedEmojiParser: TContentParser = { type: 'emoji', - regex: /:[a-zA-Z0-9_]+:/g + regex: /:[a-zA-Z0-9_-]+:/g } export function parseContent(content: string, parsers: TContentParser[]) { diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index a811cc35..aede0bf0 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1,6 +1,6 @@ import { ApplicationDataKey, ExtendedKind } from '@/constants' import client from '@/services/client.service' -import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types' +import { TDraftEvent, TEmoji, TMailboxRelay, TRelaySet } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { @@ -14,7 +14,7 @@ import { } from './event' // 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 hint = client.getEventHint(event.id) 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)]) } + let content: string + if (typeof emoji === 'string') { + content = emoji + } else { + content = `:${emoji.shortcode}:` + tags.push(['emoji', emoji.shortcode, emoji.url]) + } + return { kind: kinds.Reaction, - content: '+', + content, tags, created_at: dayjs().unix() } diff --git a/src/lib/event.ts b/src/lib/event.ts index c312bd9e..e461048f 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -426,7 +426,8 @@ export function extractZapInfoFromReceipt(receiptEvent: Event) { let description: string | undefined let preimage: string | undefined try { - receiptEvent.tags.forEach(([tagName, tagValue]) => { + receiptEvent.tags.forEach((tag) => { + const [tagName, tagValue] = tag switch (tagName) { case 'P': senderPubkey = tagValue @@ -435,7 +436,7 @@ export function extractZapInfoFromReceipt(receiptEvent: Event) { recipientPubkey = tagValue break case 'e': - eventId = tagValue + eventId = generateEventIdFromETag(tag) break case 'bolt11': invoice = tagValue diff --git a/src/providers/NoteStatsProvider.tsx b/src/providers/NoteStatsProvider.tsx index f4cc195e..cb033bc7 100644 --- a/src/providers/NoteStatsProvider.tsx +++ b/src/providers/NoteStatsProvider.tsx @@ -1,13 +1,14 @@ -import { extractZapInfoFromReceipt } from '@/lib/event' +import { extractEmojiInfosFromTags, extractZapInfoFromReceipt } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' import client from '@/services/client.service' +import { TEmoji } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' export type TNoteStats = { - likes: Set + likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] reposts: Set zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] replyCount: number @@ -123,7 +124,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) { const updateNoteStatsByEvents = (events: Event[]) => { const newRepostsMap = new Map>() - const newLikesMap = new Map>() + const newLikesMap = new Map< + string, + { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] + >() const newZapsMap = new Map< 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) { const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1] if (targetEventId) { - const newLikes = newLikesMap.get(targetEventId) || new Set() - newLikes.add(evt.pubkey) + const newLikes = newLikesMap.get(targetEventId) || [] + 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) } return @@ -168,8 +187,14 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) { }) newLikesMap.forEach((newLikes, eventId) => { const old = prev.get(eventId) || {} - const likes = old.likes || new Set() - newLikes.forEach((like) => likes.add(like)) + const likes = old.likes || [] + 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 }) }) newZapsMap.forEach((newZaps, eventId) => {