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