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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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>
)
}