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,7 +1,6 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
compareEvents,
getLatestEvent,
getReplaceableCoordinate,
getReplaceableCoordinateFromEvent,
isReplaceableEvent
@@ -10,6 +9,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TRelayList, TSubRequestFilter } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import DataLoader from 'dataloader'
@@ -27,7 +27,6 @@ import {
} from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service'
import { isSafari } from '@/lib/utils'
type TTimelineRef = [string, number]
@@ -1094,47 +1093,78 @@ class ClientService extends EventTarget {
/** =========== Replaceable event dataloader =========== */
private replaceableEventDataLoader = new DataLoader<
{ pubkey: string; kind: number },
{ pubkey: string; kind: number; d?: string },
NEvent | null,
string
>(this.replaceableEventBatchLoadFn.bind(this), {
cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}`
cacheKeyFn: ({ pubkey, kind, d }) => `${kind}:${pubkey}:${d ?? ''}`
})
private async replaceableEventBatchLoadFn(params: readonly { pubkey: string; kind: number }[]) {
const results = await Promise.allSettled(
params.map(async ({ pubkey, kind }) => {
const relayList = await this.fetchRelayList(pubkey)
const events = await this.query(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), {
authors: [pubkey],
kinds: [kind]
private async replaceableEventBatchLoadFn(
params: readonly { pubkey: string; kind: number; d?: string }[]
) {
const groups = new Map<string, { kind: number; d?: string }[]>()
params.forEach(({ pubkey, kind, d }) => {
if (!groups.has(pubkey)) {
groups.set(pubkey, [])
}
groups.get(pubkey)!.push({ kind: kind, d })
})
const eventMap = new Map<string, NEvent | null>()
await Promise.allSettled(
Array.from(groups.entries()).map(async ([pubkey, _params]) => {
const groupByKind = new Map<number, string[]>()
_params.forEach(({ kind, d }) => {
if (!groupByKind.has(kind)) {
groupByKind.set(kind, [])
}
if (d) {
groupByKind.get(kind)!.push(d)
}
})
const event = getLatestEvent(events) ?? null
if (event) {
indexedDb.putReplaceableEvent(event)
} else {
indexedDb.putNullReplaceableEvent(pubkey, kind)
const filters = Array.from(groupByKind.entries()).map(
([kind, dList]) =>
(dList.length > 0
? {
authors: [pubkey],
kinds: [kind],
'#d': dList
}
: { authors: [pubkey], kinds: [kind] }) as Filter
)
const events = await this.query(BIG_RELAY_URLS, filters)
for (const event of events) {
const key = getReplaceableCoordinateFromEvent(event)
const existing = eventMap.get(key)
if (!existing || existing.created_at < event.created_at) {
eventMap.set(key, event)
}
}
return event
})
)
return results.map((result) => {
if (result.status === 'fulfilled') {
return result.value
return params.map(({ pubkey, kind, d }) => {
const key = `${kind}:${pubkey}:${d ?? ''}`
const event = eventMap.get(key)
if (event) {
indexedDb.putReplaceableEvent(event)
return event
} else {
console.error('Failed to load replaceable event:', result.reason)
indexedDb.putNullReplaceableEvent(pubkey, kind, d)
return null
}
})
}
private async fetchReplaceableEvent(pubkey: string, kind: number) {
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind)
private async fetchReplaceableEvent(pubkey: string, kind: number, d?: string) {
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (storedEvent !== undefined) {
return storedEvent
}
return await this.replaceableEventDataLoader.load({ pubkey, kind })
return await this.replaceableEventDataLoader.load({ pubkey, kind, d })
}
private async updateReplaceableEventCache(event: NEvent) {
@@ -1182,6 +1212,21 @@ class ClientService extends EventTarget {
await this.updateReplaceableEventCache(evt)
}
async fetchEmojiSetEvents(pointers: string[]) {
const params = pointers
.map((pointer) => {
const [kindStr, pubkey, d = ''] = pointer.split(':')
if (!pubkey || !kindStr) return null
const kind = parseInt(kindStr, 10)
if (kind !== kinds.Emojisets) return null
return { pubkey, kind, d }
})
.filter(Boolean) as { pubkey: string; kind: number; d: string }[]
return await this.replaceableEventDataLoader.loadMany(params)
}
// ================= Utils =================
async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) {

View File

@@ -0,0 +1,118 @@
import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata'
import { parseEmojiPickerUnified } from '@/lib/utils'
import client from '@/services/client.service'
import { TEmoji } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import { SkinTones } from 'emoji-picker-react'
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools'
class CustomEmojiService {
static instance: CustomEmojiService
private emojiMap = new Map<string, TEmoji>()
private emojiIndex = new FlexSearch.Index({
tokenize: 'full'
})
constructor() {
if (!CustomEmojiService.instance) {
CustomEmojiService.instance = this
}
return CustomEmojiService.instance
}
async init(userEmojiListEvent: Event | null) {
if (!userEmojiListEvent) return
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent)
await this.addEmojisToIndex(emojis)
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || event instanceof Error) return
await this.addEmojisToIndex(getEmojisFromEvent(event))
})
)
}
async searchEmojis(query: string = ''): Promise<string[]> {
if (!query) {
const idSet = new Set<string>()
getSuggested()
.sort((a, b) => b.count - a.count)
.map((item) => parseEmojiPickerUnified(item.unified))
.forEach((item) => {
if (item && typeof item !== 'string') {
const id = this.getEmojiId(item)
if (!idSet.has(id)) {
idSet.add(id)
}
}
})
for (const key of this.emojiMap.keys()) {
idSet.add(key)
}
return Array.from(idSet)
}
const results = await this.emojiIndex.searchAsync(query)
return results.filter((id) => typeof id === 'string') as string[]
}
getEmojiById(id?: string): TEmoji | undefined {
if (!id) return undefined
return this.emojiMap.get(id)
}
getAllCustomEmojisForPicker() {
return Array.from(this.emojiMap.values()).map((emoji) => ({
id: `:${emoji.shortcode}:${emoji.url}`,
imgUrl: emoji.url,
names: [emoji.shortcode]
}))
}
isCustomEmojiId(shortcode: string) {
return this.emojiMap.has(shortcode)
}
private async addEmojisToIndex(emojis: TEmoji[]) {
await Promise.allSettled(
emojis.map(async (emoji) => {
const id = this.getEmojiId(emoji)
this.emojiMap.set(id, emoji)
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
setSuggested(
{
n: [emoji.shortcode.toLowerCase()],
u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(),
a: '0',
imgUrl: emoji.url
},
SkinTones.NEUTRAL
)
}
}
const instance = new CustomEmojiService()
export default instance

View File

@@ -17,6 +17,8 @@ const StoreNames = {
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays'
@@ -38,7 +40,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 6)
const request = window.indexedDB.open('jumble', 7)
request.onerror = (event) => {
reject(event)
@@ -84,6 +86,12 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) {
db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
}
this.db = db
}
})
@@ -92,7 +100,7 @@ class IndexedDbService {
return this.initPromise
}
async putNullReplaceableEvent(pubkey: string, kind: number) {
async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return Promise.reject('store name not found')
@@ -105,14 +113,15 @@ class IndexedDbService {
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const getRequest = store.get(pubkey)
const key = this.getReplaceableEventKey(pubkey, d)
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue) {
transaction.commit()
return resolve(oldValue.value)
}
const putRequest = store.put(this.formatValue(pubkey, null))
const putRequest = store.put(this.formatValue(key, null))
putRequest.onsuccess = () => {
transaction.commit()
resolve(null)
@@ -144,7 +153,7 @@ class IndexedDbService {
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(event)
const key = this.getReplaceableEventKeyFromEvent(event)
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
@@ -187,7 +196,7 @@ class IndexedDbService {
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const key = d === undefined ? pubkey : `${pubkey}:${d}`
const key = this.getReplaceableEventKey(pubkey, d)
const request = store.get(key)
request.onsuccess = () => {
@@ -220,7 +229,7 @@ class IndexedDbService {
const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
let count = 0
pubkeys.forEach((pubkey, i) => {
const request = store.get(pubkey)
const request = store.get(this.getReplaceableEventKey(pubkey))
request.onsuccess = () => {
const event = (request.result as TValue<Event | null>)?.value
@@ -415,16 +424,20 @@ class IndexedDbService {
})
}
private getReplaceableEventKey(event: Event): string {
private getReplaceableEventKeyFromEvent(event: Event): string {
if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000)
) {
return event.pubkey
return this.getReplaceableEventKey(event.pubkey)
}
const [, d] = event.tags.find(tagNameEquals('d')) ?? []
return `${event.pubkey}:${d ?? ''}`
return this.getReplaceableEventKey(event.pubkey, d)
}
private getReplaceableEventKey(pubkey: string, d?: string): string {
return d === undefined ? pubkey : `${pubkey}:${d}`
}
private getStoreNameByKind(kind: number): string | undefined {
@@ -445,6 +458,10 @@ class IndexedDbService {
return StoreNames.FAVORITE_RELAYS
case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
default:
return undefined
}