From 82c13006ff1f487e5812b152cbad414adbf43124 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 14 Nov 2025 08:01:29 -0600 Subject: [PATCH] feat: support NIP-30 custom emojis in bio and display name (#660) --- .../PostTextarea/Mention/MentionNode.tsx | 7 ++- src/components/Profile/index.tsx | 3 +- src/components/ProfileAbout/index.tsx | 31 ++++++++++- src/components/ProfileCard/index.tsx | 3 +- src/components/TextWithEmojis/index.tsx | 55 +++++++++++++++++++ src/components/Username/index.tsx | 7 ++- src/lib/event-metadata.ts | 9 ++- src/types/index.d.ts | 1 + 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 src/components/TextWithEmojis/index.tsx diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx index bfcf619..d5b3e60 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx @@ -1,3 +1,4 @@ +import TextWithEmojis from '@/components/TextWithEmojis' import { useFetchProfile } from '@/hooks' import { formatUserId } from '@/lib/pubkey' import { cn } from '@/lib/utils' @@ -11,7 +12,11 @@ export default function MentionNode(props: NodeViewRendererProps & { selected: b className={cn('inline text-primary', props.selected ? 'bg-primary/20 rounded-sm' : '')} > {'@'} - {profile ? profile.username : formatUserId(props.node.attrs.id)} + {profile ? ( + + ) : ( + formatUserId(props.node.attrs.id) + )} ) } diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 27516b5..4cc499d 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -109,7 +109,7 @@ export default function Profile({ id }: { id?: string }) { } if (!profile) return - const { banner, username, about, pubkey, website, lightningAddress } = profile + const { banner, username, about, pubkey, website, lightningAddress, emojis } = profile return ( <>
@@ -161,6 +161,7 @@ export default function Profile({ id }: { id?: string }) { diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx index 2f733c6..f0637a0 100644 --- a/src/components/ProfileAbout/index.tsx +++ b/src/components/ProfileAbout/index.tsx @@ -1,4 +1,5 @@ import { + EmbeddedEmojiParser, EmbeddedHashtagParser, EmbeddedMentionParser, EmbeddedUrlParser, @@ -7,13 +8,23 @@ import { } from '@/lib/content-parser' import { detectLanguage } from '@/lib/utils' import { useTranslationService } from '@/providers/TranslationServiceProvider' +import { TEmoji } from '@/types' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded' +import Emoji from '../Emoji' import ExternalLink from '../ExternalLink' -export default function ProfileAbout({ about, className }: { about?: string; className?: string }) { +export default function ProfileAbout({ + about, + emojis, + className +}: { + about?: string + emojis?: TEmoji[] + className?: string +}) { const { t, i18n } = useTranslation() const { translateText } = useTranslationService() const needTranslation = useMemo(() => { @@ -31,8 +42,16 @@ export default function ProfileAbout({ about, className }: { about?: string; cla EmbeddedMentionParser, EmbeddedWebsocketUrlParser, EmbeddedUrlParser, - EmbeddedHashtagParser + EmbeddedHashtagParser, + EmbeddedEmojiParser ]) + + // Create emoji map for quick lookup + const emojiMap = new Map() + emojis?.forEach((emoji) => { + emojiMap.set(emoji.shortcode, emoji) + }) + return nodes.map((node, index) => { if (node.type === 'url') { return @@ -46,9 +65,15 @@ export default function ProfileAbout({ about, className }: { about?: string; cla if (node.type === 'mention') { return } + if (node.type === 'emoji') { + const shortcode = node.data.split(':')[1] + const emoji = emojiMap.get(shortcode) + if (!emoji) return node.data + return + } return node.data }) - }, [about, translatedAbout]) + }, [about, translatedAbout, emojis]) const handleTranslate = async () => { if (translating || translatedAbout) return diff --git a/src/components/ProfileCard/index.tsx b/src/components/ProfileCard/index.tsx index d167fb6..96e1f16 100644 --- a/src/components/ProfileCard/index.tsx +++ b/src/components/ProfileCard/index.tsx @@ -9,7 +9,7 @@ import { SimpleUserAvatar } from '../UserAvatar' export default function ProfileCard({ userId }: { userId: string }) { const pubkey = useMemo(() => userIdToPubkey(userId), [userId]) const { profile } = useFetchProfile(userId) - const { username, about } = profile || {} + const { username, about, emojis } = profile || {} return (
@@ -24,6 +24,7 @@ export default function ProfileCard({ userId }: { userId: string }) { {about && ( )} diff --git a/src/components/TextWithEmojis/index.tsx b/src/components/TextWithEmojis/index.tsx new file mode 100644 index 0000000..1a1a673 --- /dev/null +++ b/src/components/TextWithEmojis/index.tsx @@ -0,0 +1,55 @@ +import { EmbeddedEmojiParser, parseContent } from '@/lib/content-parser' +import { TEmoji } from '@/types' +import { useMemo } from 'react' +import Emoji from '../Emoji' + +/** + * Component that renders text with custom emojis replaced by emoji images + * According to NIP-30, custom emojis are defined in emoji tags and referenced as :shortcode: in text + */ +export default function TextWithEmojis({ + text, + emojis, + className, + emojiClassName +}: { + text: string + emojis?: TEmoji[] + className?: string + emojiClassName?: string +}) { + const nodes = useMemo(() => { + if (!emojis || emojis.length === 0) { + return [{ type: 'text' as const, data: text }] + } + + // Use the existing content parser infrastructure + return parseContent(text, [EmbeddedEmojiParser]) + }, [text, emojis]) + + // Create emoji map for quick lookup + const emojiMap = useMemo(() => { + const map = new Map() + emojis?.forEach((emoji) => { + map.set(emoji.shortcode, emoji) + }) + return map + }, [emojis]) + + return ( + + {nodes.map((node, index) => { + if (node.type === 'text') { + return node.data + } + if (node.type === 'emoji') { + const shortcode = node.data.split(':')[1] + const emoji = emojiMap.get(shortcode) + if (!emoji) return node.data + return + } + return null + })} + + ) +} diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index 6213c20..57161e2 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -5,6 +5,7 @@ import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' import { SecondaryPageLink } from '@/PageManager' import ProfileCard from '../ProfileCard' +import TextWithEmojis from '../TextWithEmojis' export default function Username({ userId, @@ -39,7 +40,7 @@ export default function Username({ onClick={(e) => e.stopPropagation()} > {showAt && '@'} - {profile.username} +
@@ -73,12 +74,12 @@ export function SimpleUsername({ } if (!profile) return null - const { username } = profile + const { username, emojis } = profile return (
{showAt && '@'} - {username} +
) } diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 35905e5..e667bc7 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -5,7 +5,7 @@ import { buildATag } from './draft-event' import { getReplaceableEventIdentifier } from './event' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey, pubkeyToNpub } from './pubkey' -import { generateBech32IdFromETag, tagNameEquals } from './tag' +import { getEmojiInfosFromEmojiTags, generateBech32IdFromETag, tagNameEquals } from './tag' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isTorBrowser } from './utils' @@ -54,6 +54,10 @@ export function getProfileFromEvent(event: Event) { profileObj.display_name?.trim() || profileObj.name?.trim() || profileObj.nip05?.split('@')[0]?.trim() + + // Extract emojis from emoji tags according to NIP-30 + const emojis = getEmojiInfosFromEmojiTags(event.tags) + return { pubkey: event.pubkey, npub: pubkeyToNpub(event.pubkey) ?? '', @@ -67,7 +71,8 @@ export function getProfileFromEvent(event: Event) { lud06: profileObj.lud06, lud16: profileObj.lud16, lightningAddress: getLightningAddressFromProfile(profileObj), - created_at: event.created_at + created_at: event.created_at, + emojis: emojis.length > 0 ? emojis : undefined } } catch (err) { console.error(event.content, err) diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 3df6f8a..bef490e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -22,6 +22,7 @@ export type TProfile = { lud16?: string lightningAddress?: string created_at?: number + emojis?: TEmoji[] } export type TMailboxRelayScope = 'read' | 'write' | 'both' export type TMailboxRelay = {