import { Event, Filter, VerifiedEvent, kinds, validateEvent, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { EventEmitter } from 'tseep' import { NostrController, relayController } from '.' import { localCache } from '../services' import { ProfileMetadata, RelaySet } from '../types' import { findRelayListAndUpdateCache, findRelayListInCache, getDefaultRelaySet, getUserRelaySet, isOlderThanOneDay, unixNow } from '../utils' import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const' export class MetadataController extends EventEmitter { private nostrController: NostrController private specialMetadataRelay = 'wss://purplepag.es' private pendingFetches = new Map>() // Track pending fetches constructor() { super() this.nostrController = NostrController.getInstance() } /** * Asynchronously checks for more recent metadata events authored by a specific key. * If a more recent metadata event is found, it is handled and returned. * If no more recent event is found, the current event is returned. * @param hexKey The hexadecimal key of the author to filter metadata events. * @param currentEvent The current metadata event, if any, to compare with newer events. * @returns A promise resolving to the most recent metadata event found, or null if none is found. */ private async checkForMoreRecentMetadata( hexKey: string, currentEvent: Event | null ): Promise { // Return the ongoing fetch promise if one exists for the same hexKey if (this.pendingFetches.has(hexKey)) { return this.pendingFetches.get(hexKey)! } // Define the event filter to only include metadata events authored by the given key const eventFilter: Filter = { kinds: [kinds.Metadata], authors: [hexKey] } const fetchPromise = relayController .fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST) .catch((err) => { console.error(err) return null }) .finally(() => { this.pendingFetches.delete(hexKey) }) this.pendingFetches.set(hexKey, fetchPromise) const metadataEvent = await fetchPromise if ( metadataEvent && validateEvent(metadataEvent) && verifyEvent(metadataEvent) ) { if ( !currentEvent || metadataEvent.created_at >= currentEvent.created_at ) { this.handleNewMetadataEvent(metadataEvent) } return metadataEvent } // todo/implement: if no valid metadata event is found in DEFAULT_LOOK_UP_RELAY_LIST // try to query user relay list // if current event is null we should cache empty metadata event for provided hexKey if (!currentEvent) { const emptyMetadata = this.getEmptyMetadataEvent(hexKey) this.handleNewMetadataEvent(emptyMetadata as VerifiedEvent) } return currentEvent } /** * Handle new metadata events and emit them to subscribers */ private async handleNewMetadataEvent(event: VerifiedEvent) { // update the event in local cache localCache.addUserMetadata(event) // Emit the event to subscribers. this.emit(event.pubkey, event.kind, event) } /** * Finds metadata for a given hexadecimal key. * * @param hexKey - The hexadecimal key to search for metadata. * @returns A promise that resolves to the metadata event. */ public findMetadata = async (hexKey: string): Promise => { // Attempt to retrieve the metadata event from the local cache const cachedMetadataEvent = await localCache.getUserMetadata(hexKey) // If cached metadata is found, check its validity if (cachedMetadataEvent) { // Check if the cached metadata is older than one day if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) { // If older than one week, find the metadata from relays in background this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) } // Return the cached metadata event return cachedMetadataEvent.event } // If no cached metadata is found, retrieve it from relays return this.checkForMoreRecentMetadata(hexKey, null) } /** * Based on the hexKey of the current user, this method attempts to retrieve a relay set. * @func findRelayListInCache first checks if there is already an up-to-date * relay list available in cache; if not - * @func findRelayListAndUpdateCache checks if the relevant relay event is available from * the purple pages relay; * @func findRelayListAndUpdateCache will run again if the previous two calls return null and * check if the relevant relay event can be obtained from 'most popular relays' * If relay event is found, it will be saved in cache for future use * @param hexKey of the current user * @return RelaySet which will contain either relays extracted from the user Relay Event * or a fallback RelaySet with Sigit's Relay */ public findRelayListMetadata = async (hexKey: string): Promise => { const relayEvent = (await findRelayListInCache(hexKey)) || (await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey)) return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet() } public extractProfileMetadataContent = (event: Event) => { try { if (!event.content) return {} return JSON.parse(event.content) as ProfileMetadata } catch (error) { console.log('error in parsing metadata event content :>> ', error) return null } } /** * Function will not sign provided event if the SIG exists */ public publishMetadataEvent = async (event: Event) => { let signedMetadataEvent = event if (event.sig.length < 1) { const timestamp = unixNow() // Metadata event to publish to the wss://purplepag.es relay const newMetadataEvent: Event = { ...event, created_at: timestamp } signedMetadataEvent = await this.nostrController.signEvent(newMetadataEvent) } await relayController .publish(signedMetadataEvent, [this.specialMetadataRelay]) .then((relays) => { if (relays.length) { toast.success(`Metadata event published on: ${relays.join('\n')}`) this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) } else { toast.error('Could not publish metadata event to any relay!') } }) .catch((err) => { toast.error(err.message) }) } public validate = (event: Event) => validateEvent(event) && verifyEvent(event) public getEmptyMetadataEvent = (pubkey?: string): Event => { return { content: '', created_at: new Date().valueOf(), id: '', kind: 0, pubkey: pubkey || '', sig: '', tags: [] } } }