feat: support NIP-30 custom emojis in bio and display name (#660)

This commit is contained in:
Alex Gleason
2025-11-14 08:01:29 -06:00
committed by GitHub
parent f8cca5522f
commit 82c13006ff
8 changed files with 105 additions and 11 deletions

View File

@@ -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 ? (
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
) : (
formatUserId(props.node.attrs.id)
)}
</NodeViewWrapper>
)
}

View File

@@ -109,7 +109,7 @@ export default function Profile({ id }: { id?: string }) {
}
if (!profile) return <NotFound />
const { banner, username, about, pubkey, website, lightningAddress } = profile
const { banner, username, about, pubkey, website, lightningAddress, emojis } = profile
return (
<>
<div ref={topContainerRef}>
@@ -161,6 +161,7 @@ export default function Profile({ id }: { id?: string }) {
<Collapsible>
<ProfileAbout
about={about}
emojis={emojis}
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
/>
</Collapsible>

View File

@@ -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<string, TEmoji>()
emojis?.forEach((emoji) => {
emojiMap.set(emoji.shortcode, emoji)
})
return nodes.map((node, index) => {
if (node.type === 'url') {
return <ExternalLink key={index} url={node.data} />
@@ -46,9 +65,15 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiMap.get(shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
return node.data
})
}, [about, translatedAbout])
}, [about, translatedAbout, emojis])
const handleTranslate = async () => {
if (translating || translatedAbout) return

View File

@@ -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 (
<div className="w-full flex flex-col gap-2 not-prose">
@@ -24,6 +24,7 @@ export default function ProfileCard({ userId }: { userId: string }) {
{about && (
<ProfileAbout
about={about}
emojis={emojis}
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis line-clamp-6"
/>
)}

View File

@@ -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<string, TEmoji>()
emojis?.forEach((emoji) => {
map.set(emoji.shortcode, emoji)
})
return map
}, [emojis])
return (
<span className={className}>
{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 <Emoji key={index} emoji={emoji} classNames={{ img: emojiClassName }} />
}
return null
})}
</span>
)
}

View File

@@ -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}
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
</SecondaryPageLink>
</div>
</HoverCardTrigger>
@@ -73,12 +74,12 @@ export function SimpleUsername({
}
if (!profile) return null
const { username } = profile
const { username, emojis } = profile
return (
<div className={className}>
{showAt && '@'}
{username}
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
</div>
)
}

View File

@@ -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)

View File

@@ -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 = {