Fix NIP-30 custom emoji reactions rendering as raw :shortcode: text

Custom emoji reactions from kind 7 events with emoji tags were displaying
the raw shortcode text (e.g., ':soapbox:') instead of rendering the image.
Add custom emoji resolution throughout the reaction pipeline: data hooks
now extract emoji URLs from event tags, and all rendering locations
(ReactionButton, PostDetailPage stats, InteractionsModal, Notifications)
now display custom emojis as inline images.
This commit is contained in:
Alex Gleason
2026-02-19 18:39:43 -06:00
parent d58f4bb632
commit 7e3f104ace
9 changed files with 213 additions and 43 deletions
+132
View File
@@ -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<string, string> {
const map = new Map<string, string>();
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 (
<img
src={url}
alt={`:${name}:`}
title={`:${name}:`}
className={className}
loading="lazy"
decoding="async"
/>
);
}
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 <CustomEmojiImg name={name} url={url} className={className ?? 'inline-block h-5 w-5 align-text-bottom'} />;
}
}
// Unicode emoji or fallback for unresolved custom emoji
return <span className={className}>{emoji}</span>;
}
/**
* 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 <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline-block h-5 w-5 align-text-bottom'} />;
}
return <span className={className}>{emoji.content}</span>;
}
+26 -14
View File
@@ -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 (
<div>
{grouped.map(([emoji, entries]) => (
<div key={emoji}>
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
<span className="text-lg">{emoji}</span>
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
{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 (
<div key={emoji}>
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
) : (
<span className="text-lg">{emoji}</span>
)}
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
</div>
{/* Users who reacted with this emoji */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<UserRow key={`${entry.pubkey}-${i}`} pubkey={entry.pubkey} subtitle={timeAgo(entry.createdAt)} />
))}
</div>
</div>
{/* Users who reacted with this emoji */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<UserRow key={`${entry.pubkey}-${i}`} pubkey={entry.pubkey} subtitle={timeAgo(entry.createdAt)} />
))}
</div>
</div>
))}
);
})}
</div>
);
}
+5 -3
View File
@@ -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<EventStats>(['event-stats', eventId]);
if (prevStats) {
queryClient.setQueryData<EventStats>(['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<string>(['user-reaction', eventId], displayEmoji);
queryClient.setQueryData<ResolvedEmoji>(['user-reaction', eventId], resolvedEmoji);
// Publish kind 7 reaction
publishEvent(
+5 -2
View File
@@ -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 ? (
<span className="size-5 flex items-center justify-center text-base leading-none">{userReaction}</span>
{hasReacted && userReaction ? (
<span className="size-5 flex items-center justify-center text-base leading-none">
<RenderResolvedEmoji emoji={userReaction} className="inline-block h-5 w-5" />
</span>
) : (
<Heart className="size-5" />
)}
+8 -2
View File
@@ -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;
+19 -8
View File
@@ -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<string>();
const reactionEmojiMap = new Map<string, ResolvedEmoji>();
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. */
+8 -7
View File
@@ -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<string>(['user-reaction', eventId ?? '']);
const optimistic = queryClient.getQueryData<ResolvedEmoji>(['user-reaction', eventId ?? '']);
const { data } = useQuery({
queryKey: ['user-reaction', eventId ?? ''],
queryFn: async ({ signal }) => {
queryFn: async ({ signal }): Promise<ResolvedEmoji | null> => {
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,
+6 -6
View File
@@ -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 (
<div className={cn('px-4 pt-3 pb-1 relative', isNew && 'bg-primary/5')}>
{isNew && (
@@ -182,7 +178,11 @@ function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean
)}
<NotificationHeader
actorPubkey={event.pubkey}
icon={<span className="text-base leading-none size-4 flex items-center justify-center">{emoji}</span>}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={event.content.trim()} tags={event.tags} className="inline-block h-4 w-4" />
</span>
}
action="reacted to your post"
/>
{referencedEvent && (
+4 -1
View File
@@ -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 }) {
>
<span className="font-bold text-foreground">{stats.reactions}</span>{' '}
{stats.reactionEmojis && stats.reactionEmojis.length > 0
? stats.reactionEmojis.slice(0, 3).join('')
? stats.reactionEmojis.slice(0, 3).map((emoji, i) => (
<RenderResolvedEmoji key={i} emoji={emoji} className="inline-block h-5 w-5 align-text-bottom" />
))
: `Like${stats.reactions !== 1 ? 's' : ''}`}
</button>
) : null}