feat: custom emoji
This commit is contained in:
@@ -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) {
|
||||
|
||||
118
src/services/custom-emoji.service.ts
Normal file
118
src/services/custom-emoji.service.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user