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 { useFetchProfile } from '@/hooks'
import { formatUserId } from '@/lib/pubkey' import { formatUserId } from '@/lib/pubkey'
import { cn } from '@/lib/utils' 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' : '')} 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> </NodeViewWrapper>
) )
} }

View File

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

View File

@@ -1,4 +1,5 @@
import { import {
EmbeddedEmojiParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedUrlParser, EmbeddedUrlParser,
@@ -7,13 +8,23 @@ import {
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { detectLanguage } from '@/lib/utils' import { detectLanguage } from '@/lib/utils'
import { useTranslationService } from '@/providers/TranslationServiceProvider' import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { TEmoji } from '@/types'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded' import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink' 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 { t, i18n } = useTranslation()
const { translateText } = useTranslationService() const { translateText } = useTranslationService()
const needTranslation = useMemo(() => { const needTranslation = useMemo(() => {
@@ -31,8 +42,16 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedUrlParser, 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) => { return nodes.map((node, index) => {
if (node.type === 'url') { if (node.type === 'url') {
return <ExternalLink key={index} url={node.data} /> return <ExternalLink key={index} url={node.data} />
@@ -46,9 +65,15 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
if (node.type === 'mention') { if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> 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 return node.data
}) })
}, [about, translatedAbout]) }, [about, translatedAbout, emojis])
const handleTranslate = async () => { const handleTranslate = async () => {
if (translating || translatedAbout) return if (translating || translatedAbout) return

View File

@@ -9,7 +9,7 @@ import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ userId }: { userId: string }) { export default function ProfileCard({ userId }: { userId: string }) {
const pubkey = useMemo(() => userIdToPubkey(userId), [userId]) const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
const { profile } = useFetchProfile(userId) const { profile } = useFetchProfile(userId)
const { username, about } = profile || {} const { username, about, emojis } = profile || {}
return ( return (
<div className="w-full flex flex-col gap-2 not-prose"> <div className="w-full flex flex-col gap-2 not-prose">
@@ -24,6 +24,7 @@ export default function ProfileCard({ userId }: { userId: string }) {
{about && ( {about && (
<ProfileAbout <ProfileAbout
about={about} about={about}
emojis={emojis}
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis line-clamp-6" 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 { cn } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import ProfileCard from '../ProfileCard' import ProfileCard from '../ProfileCard'
import TextWithEmojis from '../TextWithEmojis'
export default function Username({ export default function Username({
userId, userId,
@@ -39,7 +40,7 @@ export default function Username({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{showAt && '@'} {showAt && '@'}
{profile.username} <TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
</SecondaryPageLink> </SecondaryPageLink>
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
@@ -73,12 +74,12 @@ export function SimpleUsername({
} }
if (!profile) return null if (!profile) return null
const { username } = profile const { username, emojis } = profile
return ( return (
<div className={className}> <div className={className}>
{showAt && '@'} {showAt && '@'}
{username} <TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
</div> </div>
) )
} }

View File

@@ -5,7 +5,7 @@ import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event' import { getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey' import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromETag, tagNameEquals } from './tag' import { getEmojiInfosFromEmojiTags, generateBech32IdFromETag, tagNameEquals } from './tag'
import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils' import { isTorBrowser } from './utils'
@@ -54,6 +54,10 @@ export function getProfileFromEvent(event: Event) {
profileObj.display_name?.trim() || profileObj.display_name?.trim() ||
profileObj.name?.trim() || profileObj.name?.trim() ||
profileObj.nip05?.split('@')[0]?.trim() profileObj.nip05?.split('@')[0]?.trim()
// Extract emojis from emoji tags according to NIP-30
const emojis = getEmojiInfosFromEmojiTags(event.tags)
return { return {
pubkey: event.pubkey, pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '', npub: pubkeyToNpub(event.pubkey) ?? '',
@@ -67,7 +71,8 @@ export function getProfileFromEvent(event: Event) {
lud06: profileObj.lud06, lud06: profileObj.lud06,
lud16: profileObj.lud16, lud16: profileObj.lud16,
lightningAddress: getLightningAddressFromProfile(profileObj), lightningAddress: getLightningAddressFromProfile(profileObj),
created_at: event.created_at created_at: event.created_at,
emojis: emojis.length > 0 ? emojis : undefined
} }
} catch (err) { } catch (err) {
console.error(event.content, err) console.error(event.content, err)

View File

@@ -22,6 +22,7 @@ export type TProfile = {
lud16?: string lud16?: string
lightningAddress?: string lightningAddress?: string
created_at?: number created_at?: number
emojis?: TEmoji[]
} }
export type TMailboxRelayScope = 'read' | 'write' | 'both' export type TMailboxRelayScope = 'read' | 'write' | 'both'
export type TMailboxRelay = { export type TMailboxRelay = {