feat: embedded emoji
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
29
src/components/Emoji/index.tsx
Normal file
29
src/components/Emoji/index.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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() }]
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -115,3 +115,8 @@ export type TNip66RelayInfo = TRelayInfo & {
|
||||
relayType?: string
|
||||
countryCode?: string
|
||||
}
|
||||
|
||||
export type TEmoji = {
|
||||
shortcode: string
|
||||
url: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user