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 {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedImageParser,
@@ -8,7 +9,7 @@ import {
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { isNsfwEvent } from '@/lib/event'
import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { TImageInfo } from '@/types'
@@ -21,6 +22,7 @@ import {
EmbeddedNote,
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
import WebPreview from '../WebPreview'
@@ -42,13 +44,16 @@ const Content = memo(
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[]
const emojiInfos = extractEmojiInfosFromTags(event.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
@@ -107,6 +112,12 @@ const Content = memo(
if (node.type === 'hashtag') {
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
})}
{lastNormalUrl && (

View File

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

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 { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { extractEmojiFromEventTags, tagNameEquals } from '@/lib/tag'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@@ -37,7 +37,8 @@ export function ReactionNotification({
const emojiName = /^:([^:]+):$/.exec(notification.content)?.[1]
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) {
return (
<Image

View File

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

View File

@@ -56,7 +56,7 @@ export default function WebPreview({
{image && (
<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
/>
)}

View File

@@ -9,6 +9,7 @@ export type TEmbeddedNodeType =
| 'hashtag'
| 'websocket-url'
| 'url'
| 'emoji'
export type TEmbeddedNode =
| {
@@ -64,6 +65,11 @@ export const EmbeddedNormalUrlParser: TContentParser = {
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[]) {
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]

View File

@@ -1,6 +1,6 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
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 { Event, kinds, nip19 } from 'nostr-tools'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
@@ -505,3 +505,12 @@ export function getLatestEvent(events: Event[]) {
export function getReplaceableEventIdentifier(event: Event) {
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[]) {
if (tag1.length !== tag2.length) return false
for (let i = 0; i < tag1.length; i++) {

View File

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