You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

231 lines
7.6 KiB

import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata'
import { recordEmojiUsed } from '@/lib/recently-used-emojis'
import client from '@/services/client.service'
import { TEmoji } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import FlexSearch from 'flexsearch'
import { Event, kinds } from 'nostr-tools'
class CustomEmojiService {
static instance: CustomEmojiService
private emojiMap = new Map<string, TEmoji>()
/** Hex pubkey (lowercase) of the event that introduced each custom emoji into the index. */
private emojiAuthorById = new Map<string, string>()
private emojiIndex: FlexSearch.Index = new FlexSearch.Index({
tokenize: 'full'
})
private indexUpdateListeners = new Set<() => void>()
constructor() {
if (!CustomEmojiService.instance) {
CustomEmojiService.instance = this
}
return CustomEmojiService.instance
}
/** Subscribe to runs after {@link init} finishes loading emoji sets (picker can refresh custom list). */
subscribeIndexUpdate(fn: () => void): () => void {
this.indexUpdateListeners.add(fn)
return () => this.indexUpdateListeners.delete(fn)
}
private notifyIndexUpdate() {
this.indexUpdateListeners.forEach((f) => f())
}
private reset() {
this.emojiMap.clear()
this.emojiAuthorById.clear()
this.emojiIndex = new FlexSearch.Index({ tokenize: 'full' })
}
/**
* Load NIP-30 custom emoji for the account: kind 0 `emoji` tags, kind 10030 list (+ `a` → 30030 sets), and 30030 packs.
* Merges `userEmojiListEvent` / `metadataEvent` with a relay fetch so we still load when hydrate missed events.
*/
async init(
userEmojiListEvent: Event | null,
accountPubkey?: string | null,
metadataEvent?: Event | null,
/** Events we just published (or must win over a slow relay fetch), merged before inventory fetch. */
seedEvents?: Event[] | null
) {
this.reset()
const pk = accountPubkey?.trim().toLowerCase() ?? ''
const hasPk = /^[0-9a-f]{64}$/.test(pk)
const byId = new Map<string, Event>()
for (const ev of seedEvents ?? []) {
if (!ev?.id || !hasPk) continue
if (ev.pubkey.trim().toLowerCase() !== pk) continue
if (
ev.kind === kinds.Metadata ||
ev.kind === kinds.UserEmojiList ||
ev.kind === kinds.Emojisets
) {
byId.set(ev.id, ev)
}
}
if (
userEmojiListEvent &&
hasPk &&
userEmojiListEvent.pubkey.trim().toLowerCase() === pk
) {
byId.set(userEmojiListEvent.id, userEmojiListEvent)
}
if (hasPk) {
const remote = await client.fetchAuthorEmojiInventory(pk).catch(() => [] as Event[])
for (const ev of remote) {
byId.set(ev.id, ev)
}
}
const events = [...byId.values()]
const latestMetadata =
events
.filter(
(e) =>
e.kind === kinds.Metadata && e.pubkey.trim().toLowerCase() === pk
)
.sort((a, b) => b.created_at - a.created_at)[0] ??
(metadataEvent &&
metadataEvent.kind === kinds.Metadata &&
metadataEvent.pubkey.trim().toLowerCase() === pk
? metadataEvent
: null)
if (latestMetadata) {
await this.addEmojisToIndex(getEmojisFromEvent(latestMetadata), pk)
}
if (events.length === 0) {
this.notifyIndexUpdate()
return
}
const listEvents = events
.filter((e) => e.kind === kinds.UserEmojiList)
.sort((a, b) => b.created_at - a.created_at)
const latestList = listEvents[0] ?? null
const packEvents = events.filter((e) => e.kind === kinds.Emojisets)
if (latestList) {
const authorPk = latestList.pubkey.toLowerCase()
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(latestList)
await this.addEmojisToIndex(emojis, authorPk)
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || (event as any) instanceof Error) return
await this.addEmojisToIndex(getEmojisFromEvent(event), event.pubkey.toLowerCase())
})
)
}
await Promise.allSettled(
packEvents.map(async (pack) => {
await this.addEmojisToIndex(getEmojisFromEvent(pack), pack.pubkey.toLowerCase())
})
)
this.notifyIndexUpdate()
}
private sortEmojiIdsForViewer(ids: string[], viewerPubkeyLower: string): string[] {
if (!viewerPubkeyLower) return ids
const own: string[] = []
const rest: string[] = []
for (const id of ids) {
if (this.emojiAuthorById.get(id) === viewerPubkeyLower) own.push(id)
else rest.push(id)
}
return [...own, ...rest]
}
async searchEmojis(query: string = '', viewerPubkey?: string | null): Promise<string[]> {
const v = viewerPubkey?.toLowerCase() ?? ''
if (!query) {
const ids = this.sortEmojiIdsForViewer(Array.from(this.emojiMap.keys()), v)
return ids
}
const results = await this.emojiIndex.searchAsync(query)
const filtered = results.filter((id) => typeof id === 'string') as string[]
return this.sortEmojiIdsForViewer(filtered, v)
}
getEmojiById(id?: string): TEmoji | undefined {
if (!id) return undefined
if (/^[0-9a-f]{64}$/.test(id)) return this.emojiMap.get(id)
return Array.from(this.emojiMap.values()).find((e) => e.shortcode === id)
}
/** Returns the emojis that the viewer themselves authored, sorted by shortcode. */
getOwnCustomEmojis(viewerPubkey: string): TEmoji[] {
const v = viewerPubkey.toLowerCase()
const own: TEmoji[] = []
for (const [hashId, emoji] of this.emojiMap.entries()) {
if (this.emojiAuthorById.get(hashId) === v) own.push(emoji)
}
return own.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
}
getAllCustomEmojisForPicker(
viewerPubkey?: string | null
): Array<{ name: string; shortcodes: [string]; url: string; category: string }> {
const v = viewerPubkey?.toLowerCase() ?? ''
const rows = Array.from(this.emojiMap.entries()).map(([hashId, emoji]) => ({
emoji,
author: this.emojiAuthorById.get(hashId) ?? ''
}))
rows.sort((a, b) => {
if (v) {
const aOwn = a.author === v ? 0 : 1
const bOwn = b.author === v ? 0 : 1
if (aOwn !== bOwn) return aOwn - bOwn
}
return a.emoji.shortcode.localeCompare(b.emoji.shortcode)
})
return rows.map((r) => ({
name: r.emoji.shortcode,
shortcodes: [r.emoji.shortcode] as [string],
url: r.emoji.url,
category: 'Custom'
}))
}
isCustomEmojiId(name: string) {
if (!name) return false
if (/^[0-9a-f]{64}$/.test(name)) return this.emojiMap.has(name)
return Array.from(this.emojiMap.values()).some((e) => e.shortcode === name)
}
private async addEmojisToIndex(emojis: TEmoji[], authorPubkeyLower: string) {
await Promise.allSettled(
emojis.map(async (emoji) => {
const id = this.getEmojiId(emoji)
this.emojiMap.set(id, emoji)
this.emojiAuthorById.set(id, authorPubkeyLower)
await this.emojiIndex.addAsync(id, emoji.shortcode)
})
)
}
getEmojiId(emoji: TEmoji) {
const encoder = new TextEncoder()
const data = encoder.encode(`${emoji.shortcode}:${emoji.url}`.toLowerCase())
const hashBuffer = sha256(data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
updateSuggested(id: string) {
const emoji = this.getEmojiById(id)
if (!emoji) return
recordEmojiUsed(emoji)
}
}
const instance = new CustomEmojiService()
export default instance