diff --git a/src/components/CustomEmoji.tsx b/src/components/CustomEmoji.tsx new file mode 100644 index 00000000..6e87f1ee --- /dev/null +++ b/src/components/CustomEmoji.tsx @@ -0,0 +1,132 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Checks if a string is a NIP-30 custom emoji shortcode (`:shortcode:` format). + */ +export function isCustomEmoji(content: string): boolean { + return /^:[a-zA-Z0-9_]+:$/.test(content); +} + +/** + * Extracts the custom emoji URL from a NostrEvent's tags for a given shortcode. + * The shortcode should include the colons (e.g., `:soapbox:`). + */ +export function getCustomEmojiUrl(shortcode: string, tags: string[][]): string | undefined { + const name = shortcode.slice(1, -1); // Remove surrounding colons + const emojiTag = tags.find(([tagName, tagShortcode]) => tagName === 'emoji' && tagShortcode === name); + return emojiTag?.[2]; +} + +/** + * Builds a map of shortcode -> URL from an event's emoji tags. + */ +export function buildEmojiMap(tags: string[][]): Map { + const map = new Map(); + for (const tag of tags) { + if (tag[0] === 'emoji' && tag[1] && tag[2]) { + map.set(tag[1], tag[2]); + } + } + return map; +} + +interface CustomEmojiImgProps { + /** The shortcode name (without colons). */ + name: string; + /** The image URL. */ + url: string; + /** CSS class name for the img element. */ + className?: string; +} + +/** + * Renders a single custom emoji as an inline image. + */ +export function CustomEmojiImg({ name, url, className = 'inline-block h-5 w-5 align-text-bottom' }: CustomEmojiImgProps) { + return ( + {`:${name}:`} + ); +} + +interface ReactionEmojiProps { + /** The reaction content (could be a unicode emoji, `:shortcode:`, `+`, or empty). */ + content: string; + /** The tags from the kind 7 event, used to look up custom emoji URLs. */ + tags?: string[][]; + /** CSS class name for the wrapper span (used for unicode) or img (used for custom emoji). */ + className?: string; +} + +/** + * Renders a reaction emoji, handling both unicode and NIP-30 custom emojis. + * + * For custom emojis (`:shortcode:` format), it looks up the URL from the event's + * emoji tags and renders an inline image. For unicode emojis, it renders the text directly. + */ +export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps) { + // Normalize '+' and empty to thumbs up + const emoji = (content === '+' || content === '') ? '👍' : content; + + // Check for custom emoji + if (isCustomEmoji(emoji) && tags) { + const url = getCustomEmojiUrl(emoji, tags); + if (url) { + const name = emoji.slice(1, -1); + return ; + } + } + + // Unicode emoji or fallback for unresolved custom emoji + return {emoji}; +} + +/** + * Represents a resolved reaction emoji that can be rendered. + * For custom emojis, includes the URL; for unicode, just the content string. + */ +export interface ResolvedEmoji { + /** The display content — unicode emoji string or `:shortcode:` */ + content: string; + /** For custom emojis, the image URL. Undefined for unicode emojis. */ + url?: string; + /** For custom emojis, the shortcode name (without colons). */ + name?: string; +} + +/** + * Resolves a reaction emoji from a kind 7 event into a ResolvedEmoji. + */ +export function resolveReactionEmoji(event: NostrEvent): ResolvedEmoji { + const content = event.content.trim(); + const emoji = (content === '+' || content === '') ? '👍' : content; + + if (content === '-') { + return { content: emoji }; + } + + if (isCustomEmoji(emoji)) { + const url = getCustomEmojiUrl(emoji, event.tags); + if (url) { + return { content: emoji, url, name: emoji.slice(1, -1) }; + } + } + + return { content: emoji }; +} + +/** + * Renders a ResolvedEmoji inline. + */ +export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) { + if (emoji.url && emoji.name) { + return ; + } + return {emoji.content}; +} diff --git a/src/components/InteractionsModal.tsx b/src/components/InteractionsModal.tsx index 00a05d03..bd5689b3 100644 --- a/src/components/InteractionsModal.tsx +++ b/src/components/InteractionsModal.tsx @@ -12,6 +12,7 @@ import { import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; +import { CustomEmojiImg, isCustomEmoji } from '@/components/CustomEmoji'; import { useEventInteractions, type RepostEntry, type QuoteEntry, type ReactionEntry, type ZapEntry } from '@/hooks/useEventInteractions'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -172,21 +173,32 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) { return (
- {grouped.map(([emoji, entries]) => ( -
- {/* Emoji group header */} -
- {emoji} - {entries.length} + {grouped.map(([emoji, entries]) => { + // Check if this is a custom emoji — use the URL from the first entry + const firstEntry = entries[0]; + const customUrl = firstEntry?.emojiUrl; + const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined; + + return ( +
+ {/* Emoji group header */} +
+ {customUrl && customName ? ( + + ) : ( + {emoji} + )} + {entries.length} +
+ {/* Users who reacted with this emoji */} +
+ {entries.map((entry, i) => ( + + ))} +
- {/* Users who reacted with this emoji */} -
- {entries.map((entry, i) => ( - - ))} -
-
- ))} + ); + })}
); } diff --git a/src/components/QuickReactMenu.tsx b/src/components/QuickReactMenu.tsx index 21061c15..ab05c74b 100644 --- a/src/components/QuickReactMenu.tsx +++ b/src/components/QuickReactMenu.tsx @@ -8,6 +8,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEmojiUsage } from '@/hooks/useEmojiUsage'; import { cn } from '@/lib/utils'; import type { EventStats } from '@/hooks/useTrending'; +import type { ResolvedEmoji } from '@/components/CustomEmoji'; interface QuickReactMenuProps { /** The event ID being reacted to. */ @@ -51,19 +52,20 @@ export function QuickReactMenu({ // Optimistically update stats cache immediately const displayEmoji = (emoji === '+' || emoji === '') ? '👍' : emoji; + const resolvedEmoji: ResolvedEmoji = { content: displayEmoji }; const prevStats = queryClient.getQueryData(['event-stats', eventId]); if (prevStats) { queryClient.setQueryData(['event-stats', eventId], { ...prevStats, reactions: prevStats.reactions + 1, - reactionEmojis: prevStats.reactionEmojis.includes(displayEmoji) + reactionEmojis: prevStats.reactionEmojis.some((e) => e.content === displayEmoji) ? prevStats.reactionEmojis - : [...prevStats.reactionEmojis, displayEmoji], + : [...prevStats.reactionEmojis, resolvedEmoji], }); } // Store user's own reaction for this event - queryClient.setQueryData(['user-reaction', eventId], displayEmoji); + queryClient.setQueryData(['user-reaction', eventId], resolvedEmoji); // Publish kind 7 reaction publishEvent( diff --git a/src/components/ReactionButton.tsx b/src/components/ReactionButton.tsx index 46934dd5..bd363e9f 100644 --- a/src/components/ReactionButton.tsx +++ b/src/components/ReactionButton.tsx @@ -3,6 +3,7 @@ import { Heart } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { QuickReactMenu } from '@/components/QuickReactMenu'; +import { RenderResolvedEmoji } from '@/components/CustomEmoji'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUserReaction } from '@/hooks/useUserReaction'; import { cn } from '@/lib/utils'; @@ -71,8 +72,10 @@ export function ReactionButton({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {hasReacted ? ( - {userReaction} + {hasReacted && userReaction ? ( + + + ) : ( )} diff --git a/src/hooks/useEventInteractions.ts b/src/hooks/useEventInteractions.ts index 4c07b50a..a8950e94 100644 --- a/src/hooks/useEventInteractions.ts +++ b/src/hooks/useEventInteractions.ts @@ -2,6 +2,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useNip85EventStats } from '@/hooks/useNip85Stats'; +import { isCustomEmoji, getCustomEmojiUrl } from '@/components/CustomEmoji'; export interface RepostEntry { pubkey: string; @@ -11,6 +12,8 @@ export interface RepostEntry { export interface ReactionEntry { pubkey: string; emoji: string; + /** For NIP-30 custom emojis, the image URL. */ + emojiUrl?: string; createdAt: number; } @@ -166,10 +169,13 @@ export function useEventInteractions(eventId: string | undefined) { }); break; case 7: { - const emoji = e.content.trim(); + const rawEmoji = e.content.trim(); + const emoji = (rawEmoji === '+' || rawEmoji === '') ? '👍' : rawEmoji; + const emojiUrl = isCustomEmoji(emoji) ? getCustomEmojiUrl(emoji, e.tags) : undefined; reactions.push({ pubkey: e.pubkey, - emoji: (emoji === '+' || emoji === '') ? '👍' : emoji, + emoji, + emojiUrl, createdAt: e.created_at, }); break; diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts index 8d30b6f0..01bc7fe0 100644 --- a/src/hooks/useTrending.ts +++ b/src/hooks/useTrending.ts @@ -2,6 +2,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { useAppContext } from '@/hooks/useAppContext'; +import { type ResolvedEmoji, isCustomEmoji, getCustomEmojiUrl } from '@/components/CustomEmoji'; /** The sole relay used for trend data. */ const DITTO_RELAY = 'wss://relay.ditto.pub'; @@ -199,7 +200,7 @@ export interface EventStats { reactions: number; zapAmount: number; zapCount: number; - reactionEmojis: string[]; + reactionEmojis: ResolvedEmoji[]; } const EMPTY_STATS: EventStats = { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, zapCount: 0, reactionEmojis: [] }; @@ -212,7 +213,7 @@ function computeStats(eventId: string, events: NostrEvent[]): EventStats { let reactions = 0; let zapAmount = 0; let zapCount = 0; - const reactionEmojiSet = new Set(); + const reactionEmojiMap = new Map(); for (const e of events) { // Check if this event references our target via e-tag or q-tag @@ -232,11 +233,21 @@ function computeStats(eventId: string, events: NostrEvent[]): EventStats { case 6: reposts++; break; case 7: { reactions++; - const emoji = e.content.trim(); - if (emoji === '+' || emoji === '') { - reactionEmojiSet.add('👍'); - } else if (emoji !== '-') { - reactionEmojiSet.add(emoji); + const rawEmoji = e.content.trim(); + if (rawEmoji === '+' || rawEmoji === '') { + if (!reactionEmojiMap.has('👍')) { + reactionEmojiMap.set('👍', { content: '👍' }); + } + } else if (rawEmoji !== '-') { + if (!reactionEmojiMap.has(rawEmoji)) { + if (isCustomEmoji(rawEmoji)) { + const url = getCustomEmojiUrl(rawEmoji, e.tags); + const name = rawEmoji.slice(1, -1); + reactionEmojiMap.set(rawEmoji, url ? { content: rawEmoji, url, name } : { content: rawEmoji }); + } else { + reactionEmojiMap.set(rawEmoji, { content: rawEmoji }); + } + } } break; } @@ -251,7 +262,7 @@ function computeStats(eventId: string, events: NostrEvent[]): EventStats { } } - return { replies, reposts, quotes, reactions, zapAmount, zapCount, reactionEmojis: Array.from(reactionEmojiSet) }; + return { replies, reposts, quotes, reactions, zapAmount, zapCount, reactionEmojis: Array.from(reactionEmojiMap.values()) }; } /** Counts engagement (replies, reposts, quotes, reactions, zaps) for a given event. */ diff --git a/src/hooks/useUserReaction.ts b/src/hooks/useUserReaction.ts index 40e1eb8b..bcfe049f 100644 --- a/src/hooks/useUserReaction.ts +++ b/src/hooks/useUserReaction.ts @@ -1,26 +1,27 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { type ResolvedEmoji, resolveReactionEmoji } from '@/components/CustomEmoji'; /** - * Returns the current user's reaction emoji for a given event, if any. + * Returns the current user's reaction for a given event, if any. * * Checks the optimistic cache first (set by QuickReactMenu on react), * then falls back to querying the relay for the user's kind 7 events. * - * Returns undefined while loading, null if no reaction, or the emoji string. + * Returns undefined while loading, null if no reaction, or a ResolvedEmoji. */ -export function useUserReaction(eventId: string | undefined): string | null | undefined { +export function useUserReaction(eventId: string | undefined): ResolvedEmoji | null | undefined { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); // Check optimistic cache first - const optimistic = queryClient.getQueryData(['user-reaction', eventId ?? '']); + const optimistic = queryClient.getQueryData(['user-reaction', eventId ?? '']); const { data } = useQuery({ queryKey: ['user-reaction', eventId ?? ''], - queryFn: async ({ signal }) => { + queryFn: async ({ signal }): Promise => { if (!eventId || !user) return null; const events = await nostr.query( @@ -36,9 +37,9 @@ export function useUserReaction(eventId: string | undefined): string | null | un if (events.length === 0) return null; const content = events[0].content.trim(); - if (content === '+' || content === '') return '👍'; if (content === '-') return null; - return content; + + return resolveReactionEmoji(events[0]); }, enabled: !!eventId && !!user && !optimistic, staleTime: 5 * 60 * 1000, diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 99f9e818..a78130b7 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -29,6 +29,7 @@ import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { Nip05Badge } from '@/components/Nip05Badge'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; +import { ReactionEmoji } from '@/components/CustomEmoji'; type NotificationTab = 'all' | 'mentions'; @@ -170,11 +171,6 @@ function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean const referencedEventId = getReferencedEventId(event); const { data: referencedEvent } = useEvent(referencedEventId); - // Get the actual emoji from the reaction content - // '+' or empty string defaults to 👍 (like), '-' is ignored - const content = event.content.trim(); - const emoji = content === '+' || content === '' ? '👍' : content; - return (
{isNew && ( @@ -182,7 +178,11 @@ function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean )} {emoji}} + icon={ + + + + } action="reacted to your post" /> {referencedEvent && ( diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 80e22415..b5963456 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -23,6 +23,7 @@ import { ReactionButton } from '@/components/ReactionButton'; import { RepostMenu } from '@/components/RepostMenu'; import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal'; import { ZapDialog } from '@/components/ZapDialog'; +import { RenderResolvedEmoji } from '@/components/CustomEmoji'; import { PollContent } from '@/components/PollContent'; import { GeocacheContent } from '@/components/GeocacheContent'; import { FoundLogContent } from '@/components/FoundLogContent'; @@ -731,7 +732,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) { > {stats.reactions}{' '} {stats.reactionEmojis && stats.reactionEmojis.length > 0 - ? stats.reactionEmojis.slice(0, 3).join('') + ? stats.reactionEmojis.slice(0, 3).map((emoji, i) => ( + + )) : `Like${stats.reactions !== 1 ? 's' : ''}`} ) : null}