custom-emoji.service.ts raw
1 import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata'
2 import { parseEmojiPickerUnified } from '@/lib/utils'
3 import client from '@/services/client.service'
4 import { TEmoji } from '@/types'
5 import { sha256 } from '@noble/hashes/sha2'
6 import { SkinTones } from 'emoji-picker-react'
7 import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
8 import FlexSearch from 'flexsearch'
9 import { Event } from 'nostr-tools'
10
11 class CustomEmojiService {
12 static instance: CustomEmojiService
13
14 private emojiMap = new Map<string, TEmoji>()
15 private emojiIndex = new FlexSearch.Index({
16 tokenize: 'full'
17 })
18
19 constructor() {
20 if (!CustomEmojiService.instance) {
21 CustomEmojiService.instance = this
22 }
23 return CustomEmojiService.instance
24 }
25
26 async init(userEmojiListEvent: Event | null) {
27 if (!userEmojiListEvent) return
28
29 const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent)
30 await this.addEmojisToIndex(emojis)
31
32 const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers, false)
33 await Promise.allSettled(
34 emojiSetEvents.map(async (event) => {
35 if (!event || event instanceof Error) return
36
37 await this.addEmojisToIndex(getEmojisFromEvent(event))
38 })
39 )
40 }
41
42 async searchEmojis(query: string = ''): Promise<string[]> {
43 if (!query) {
44 const idSet = new Set<string>()
45 getSuggested()
46 .sort((a, b) => b.count - a.count)
47 .map((item) => parseEmojiPickerUnified(item.unified))
48 .forEach((item) => {
49 if (item && typeof item !== 'string') {
50 const id = this.getEmojiId(item)
51 if (!idSet.has(id)) {
52 idSet.add(id)
53 }
54 }
55 })
56 for (const key of this.emojiMap.keys()) {
57 idSet.add(key)
58 }
59 return Array.from(idSet)
60 }
61 const results = await this.emojiIndex.searchAsync(query)
62 return results.filter((id) => typeof id === 'string') as string[]
63 }
64
65 getEmojiById(id?: string): TEmoji | undefined {
66 if (!id) return undefined
67
68 return this.emojiMap.get(id)
69 }
70
71 getAllCustomEmojisForPicker() {
72 return Array.from(this.emojiMap.values()).map((emoji) => ({
73 id: `:${emoji.shortcode}:${emoji.url}`,
74 imgUrl: emoji.url,
75 names: [emoji.shortcode]
76 }))
77 }
78
79 isCustomEmojiId(shortcode: string) {
80 return this.emojiMap.has(shortcode)
81 }
82
83 private async addEmojisToIndex(emojis: TEmoji[]) {
84 await Promise.allSettled(
85 emojis.map(async (emoji) => {
86 const id = this.getEmojiId(emoji)
87 this.emojiMap.set(id, emoji)
88 await this.emojiIndex.addAsync(id, emoji.shortcode)
89 })
90 )
91 }
92
93 getEmojiId(emoji: TEmoji) {
94 const encoder = new TextEncoder()
95 const data = encoder.encode(`${emoji.shortcode}:${emoji.url}`.toLowerCase())
96 const hashBuffer = sha256(data)
97 const hashArray = Array.from(new Uint8Array(hashBuffer))
98 return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
99 }
100
101 updateSuggested(id: string) {
102 const emoji = this.getEmojiById(id)
103 if (!emoji) return
104
105 setSuggested(
106 {
107 n: [emoji.shortcode.toLowerCase()],
108 u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(),
109 a: '0',
110 imgUrl: emoji.url
111 },
112 SkinTones.NEUTRAL
113 )
114 }
115 }
116
117 const instance = new CustomEmojiService()
118 export default instance
119