feat: embedded emoji

This commit is contained in:
codytseng
2025-04-17 17:09:22 +08:00
parent c40609c8ac
commit 319ae5a0ba
10 changed files with 93 additions and 14 deletions

View File

@@ -1,4 +1,5 @@
import { import {
EmbeddedEmojiParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedImageParser, EmbeddedImageParser,
@@ -8,7 +9,7 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { isNsfwEvent } from '@/lib/event' import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag' import { extractImageInfoFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TImageInfo } from '@/types' import { TImageInfo } from '@/types'
@@ -21,6 +22,7 @@ import {
EmbeddedNote, EmbeddedNote,
EmbeddedWebsocketUrl EmbeddedWebsocketUrl
} from '../Embedded' } from '../Embedded'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer' import VideoPlayer from '../VideoPlayer'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
@@ -42,13 +44,16 @@ const Content = memo(
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedHashtagParser EmbeddedHashtagParser,
EmbeddedEmojiParser
]) ])
const imageInfos = event.tags const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag)) .map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[] .filter(Boolean) as TImageInfo[]
const emojiInfos = extractEmojiInfosFromTags(event.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl = const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
@@ -107,6 +112,12 @@ const Content = memo(
if (node.type === 'hashtag') { if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} /> return <EmbeddedHashtag hashtag={node.data} key={index} />
} }
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji emoji={emoji} key={index} className="size-4" />
}
return null return null
})} })}
{lastNormalUrl && ( {lastNormalUrl && (

View File

@@ -1,15 +1,18 @@
import { import {
EmbeddedEmojiParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedImageParser, EmbeddedImageParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedVideoParser, EmbeddedVideoParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { extractEmojiInfosFromTags } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { EmbeddedMentionText } from '../Embedded' import { EmbeddedMentionText } from '../Embedded'
import Emoji from '../Emoji'
export default function ContentPreview({ export default function ContentPreview({
event, event,
@@ -26,10 +29,13 @@ export default function ContentPreview({
EmbeddedImageParser, EmbeddedImageParser,
EmbeddedVideoParser, EmbeddedVideoParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedMentionParser EmbeddedMentionParser,
EmbeddedEmojiParser
]) ])
}, [event]) }, [event])
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
return ( return (
<div className={cn('pointer-events-none', className)}> <div className={cn('pointer-events-none', className)}>
{nodes.map((node, index) => { {nodes.map((node, index) => {
@@ -48,6 +54,12 @@ export default function ContentPreview({
if (node.type === 'mention') { if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} /> return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
} }
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} />
}
})} })}
</div> </div>
) )

View File

@@ -0,0 +1,29 @@
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { HTMLAttributes, useState } from 'react'
export default function Emoji({
emoji,
className = ''
}: HTMLAttributes<HTMLDivElement> & {
className?: string
emoji: TEmoji
}) {
const [hasError, setHasError] = useState(false)
if (hasError) return `:${emoji.shortcode}:`
return (
<img
src={emoji.url}
alt={emoji.shortcode}
className={cn('inline-block size-4', className)}
onLoad={() => {
setHasError(false)
}}
onError={() => {
setHasError(true)
}}
/>
)
}

View File

@@ -2,7 +2,7 @@ import Image from '@/components/Image'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { extractEmojiFromEventTags, tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@@ -37,7 +37,8 @@ export function ReactionNotification({
const emojiName = /^:([^:]+):$/.exec(notification.content)?.[1] const emojiName = /^:([^:]+):$/.exec(notification.content)?.[1]
if (emojiName) { if (emojiName) {
const emojiUrl = extractEmojiFromEventTags(emojiName, notification.tags) const emojiTag = notification.tags.find((tag) => tag[0] === 'emoji' && tag[1] === emojiName)
const emojiUrl = emojiTag?.[2]
if (emojiUrl) { if (emojiUrl) {
return ( return (
<Image <Image

View File

@@ -1,11 +1,12 @@
import { import {
EmbeddedEmojiParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedNormalUrlParser, EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { extractImageInfosFromEventTags, isNsfwEvent } from '@/lib/event' import { extractEmojiInfosFromTags, extractImageInfosFromEventTags, isNsfwEvent } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
@@ -16,6 +17,7 @@ import {
EmbeddedWebsocketUrl EmbeddedWebsocketUrl
} from '../Embedded' } from '../Embedded'
import { ImageCarousel } from '../ImageCarousel' import { ImageCarousel } from '../ImageCarousel'
import Emoji from '../Emoji'
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => { const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
const images = useMemo(() => extractImageInfosFromEventTags(event), [event]) const images = useMemo(() => extractImageInfosFromEventTags(event), [event])
@@ -25,9 +27,12 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
EmbeddedNormalUrlParser, EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser EmbeddedMentionParser,
EmbeddedEmojiParser
]) ])
const emojiInfos = extractEmojiInfosFromTags(event.tags)
return ( return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}> <div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
<ImageCarousel images={images} isNsfw={isNsfw} /> <ImageCarousel images={images} isNsfw={isNsfw} />
@@ -48,6 +53,12 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
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 = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} />
}
})} })}
</div> </div>
</div> </div>

View File

@@ -56,7 +56,7 @@ export default function WebPreview({
{image && ( {image && (
<Image <Image
image={{ url: image }} image={{ url: image }}
className={`rounded-lg aspect-[4/3] object-cover bg-foreground ${size === 'normal' ? 'h-44' : 'h-24'}`} className={`rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground ${size === 'normal' ? 'h-44' : 'h-24'}`}
hideIfError hideIfError
/> />
)} )}

View File

@@ -9,6 +9,7 @@ export type TEmbeddedNodeType =
| 'hashtag' | 'hashtag'
| 'websocket-url' | 'websocket-url'
| 'url' | 'url'
| 'emoji'
export type TEmbeddedNode = export type TEmbeddedNode =
| { | {
@@ -64,6 +65,11 @@ export const EmbeddedNormalUrlParser: TContentParser = {
regex: /https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+/gu regex: /https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+/gu
} }
export const EmbeddedEmojiParser: TContentParser = {
type: 'emoji',
regex: /:[a-zA-Z0-9_]+:/g
}
export function parseContent(content: string, parsers: TContentParser[]) { export function parseContent(content: string, parsers: TContentParser[]) {
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }] let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]

View File

@@ -1,6 +1,6 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TImageInfo, TRelayList, TRelaySet } from '@/types' import { TEmoji, TImageInfo, TRelayList, TRelaySet } from '@/types'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
@@ -505,3 +505,12 @@ export function getLatestEvent(events: Event[]) {
export function getReplaceableEventIdentifier(event: Event) { export function getReplaceableEventIdentifier(event: Event) {
return event.tags.find(tagNameEquals('d'))?.[1] ?? '' return event.tags.find(tagNameEquals('d'))?.[1] ?? ''
} }
export function extractEmojiInfosFromTags(tags: string[][] = []) {
return tags
.map((tag) => {
if (tag.length < 3 || tag[0] !== 'emoji') return null
return { shortcode: tag[1], url: tag[2] }
})
.filter(Boolean) as TEmoji[]
}

View File

@@ -66,11 +66,6 @@ export function extractPubkeysFromEventTags(tags: string[][]) {
) )
} }
export function extractEmojiFromEventTags(emojiName: string, tags: string[][]) {
const emojiTag = tags.find((tag) => tag[0] === 'emoji' && tag[1] === emojiName)
return emojiTag?.[2]
}
export function isSameTag(tag1: string[], tag2: string[]) { export function isSameTag(tag1: string[], tag2: string[]) {
if (tag1.length !== tag2.length) return false if (tag1.length !== tag2.length) return false
for (let i = 0; i < tag1.length; i++) { for (let i = 0; i < tag1.length; i++) {

View File

@@ -115,3 +115,8 @@ export type TNip66RelayInfo = TRelayInfo & {
relayType?: string relayType?: string
countryCode?: string countryCode?: string
} }
export type TEmoji = {
shortcode: string
url: string
}