feat: custom emoji

This commit is contained in:
codytseng
2025-08-22 21:05:44 +08:00
parent 481d6a1447
commit 71d4420604
46 changed files with 885 additions and 176 deletions

View File

@@ -1,5 +1,6 @@
import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service'
import {
TDraftEvent,
@@ -78,14 +79,15 @@ export async function createShortTextNoteDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } =
await extractRelatedEventIds(content, options.parentEvent)
const hashtags = extractHashtags(content)
await extractRelatedEventIds(transformedEmojisContent, options.parentEvent)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// imeta tags
const images = extractImagesFromContent(content)
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
@@ -120,7 +122,7 @@ export async function createShortTextNoteDraftEvent(
const baseDraft = {
kind: kinds.ShortTextNote,
content,
content: transformedEmojisContent,
tags
}
const cacheKey = JSON.stringify(baseDraft)
@@ -148,44 +150,6 @@ export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDr
}
}
export async function createPictureNoteDraftEvent(
content: string,
pictureInfos: { url: string; tags: string[][] }[],
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(content)
const hashtags = extractHashtags(content)
if (!pictureInfos.length) {
throw new Error('No images found in content')
}
const tags = pictureInfos
.map((info) => buildImetaTag(info.tags))
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
.concat(mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
}
if (options.protectedEvent) {
tags.push(buildProtectedTag())
}
return {
kind: ExtendedKind.PICTURE,
content,
tags,
created_at: dayjs().unix()
}
}
const commentDraftEventCache: Map<string, TDraftEvent> = new Map()
export async function createCommentDraftEvent(
content: string,
@@ -197,6 +161,7 @@ export async function createCommentDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const {
quoteEventHexIds,
quoteReplaceableCoordinates,
@@ -205,15 +170,15 @@ export async function createCommentDraftEvent(
rootKind,
rootPubkey,
rootUrl
} = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content)
} = await extractCommentMentions(transformedEmojisContent, parentEvent)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags
.map((hashtag) => buildTTag(hashtag))
const tags = emojiTags
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
const images = extractImagesFromContent(content)
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
@@ -260,7 +225,7 @@ export async function createCommentDraftEvent(
const baseDraft = {
kind: ExtendedKind.COMMENT,
content,
content: transformedEmojisContent,
tags
}
const cacheKey = JSON.stringify(baseDraft)
@@ -374,13 +339,15 @@ export async function createPollDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(question)
const hashtags = extractHashtags(question)
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
const { quoteEventHexIds, quoteReplaceableCoordinates } =
await extractRelatedEventIds(transformedEmojisContent)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// imeta tags
const images = extractImagesFromContent(question)
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
@@ -418,7 +385,7 @@ export async function createPollDraftEvent(
}
const baseDraft = {
content: question.trim(),
content: transformedEmojisContent.trim(),
kind: ExtendedKind.POLL,
tags
}
@@ -583,6 +550,29 @@ function extractImagesFromContent(content: string) {
return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
}
export function transformCustomEmojisInContent(content: string) {
const emojiTags: string[][] = []
let processedContent = content
const matches = content.match(/:[a-zA-Z0-9]+:/g)
const emojiIdSet = new Set<string>()
matches?.forEach((m) => {
if (emojiIdSet.has(m)) return
emojiIdSet.add(m)
const emoji = customEmojiService.getEmojiById(m.slice(1, -1))
if (emoji) {
emojiTags.push(buildEmojiTag(emoji))
processedContent = processedContent.replace(new RegExp(m, 'g'), `:${emoji.shortcode}:`)
}
})
return {
emojiTags,
content: processedContent
}
}
export function buildATag(event: Event, upperCase: boolean = false) {
const coordinate = getReplaceableCoordinateFromEvent(event)
const hint = client.getEventHint(event.id)
@@ -661,10 +651,6 @@ function buildServerTag(url: string) {
return ['server', url]
}
function buildImetaTag(nip94Tags: string[][]) {
return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)]
}
function buildResponseTag(value: string) {
return ['response', value]
}

View File

@@ -1,5 +1,5 @@
import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants'
import { TPollType, TRelayList, TRelaySet } from '@/types'
import { TEmoji, TPollType, TRelayList, TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event'
@@ -336,3 +336,36 @@ export function getPollResponseFromEvent(
created_at: event.created_at
}
}
export function getEmojisAndEmojiSetsFromEvent(event: Event) {
const emojis: TEmoji[] = []
const emojiSetPointers: string[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
} else if (tagName === 'a' && tagValues[0]) {
emojiSetPointers.push(tagValues[0])
}
})
return { emojis, emojiSetPointers }
}
export function getEmojisFromEvent(event: Event): TEmoji[] {
const emojis: TEmoji[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
}
})
return emojis
}

View File

@@ -1,3 +1,5 @@
import customEmojiService from '@/services/custom-emoji.service'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { JSONContent } from '@tiptap/react'
import { nip19 } from 'nostr-tools'
@@ -38,7 +40,18 @@ function _parseEditorJsonToText(node?: JSONContent): string {
return '\n'
case 'mention':
return node.attrs ? `nostr:${node.attrs.id}` : ''
case 'emoji':
return parseEmojiNodeName(node.attrs?.name)
default:
return ''
}
}
function parseEmojiNodeName(name?: string): string {
if (!name) return ''
if (customEmojiService.isCustomEmojiId(name)) {
return `:${name}:`
}
const emoji = shortcodeToEmoji(name, emojis)
return emoji ? (emoji.emoji ?? '') : ''
}

View File

@@ -7,7 +7,9 @@ import {
URL_REGEX,
WS_URL_REGEX
} from '@/constants'
import { TEmoji } from '@/types'
import { clsx, type ClassValue } from 'clsx'
import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji'
import { franc } from 'franc-min'
import { twMerge } from 'tailwind-merge'
@@ -133,3 +135,16 @@ export function detectLanguage(text?: string): string | null {
return 'und'
}
}
export function parseEmojiPickerUnified(unified: string): string | TEmoji | undefined {
if (unified.startsWith(':')) {
const secondColonIndex = unified.indexOf(':', 1)
if (secondColonIndex < 0) return undefined
const shortcode = unified.slice(1, secondColonIndex)
const url = unified.slice(secondColonIndex + 1)
return { shortcode, url }
} else {
return parseNativeEmoji(unified)
}
}