From 6a8589662d0b4b0ba1a29a229dffe965e500c44b Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 22 Mar 2025 19:27:24 -0400 Subject: [PATCH] updated documentation --- docs/design/Analysis/NDKFunctionHexKeys.md | 261 +++ .../Analysis/NDKSubscriptionAnalysis.md | 1397 +++++++++++++++++ docs/design/Analysis/NDKandNip19.md | 261 +++ docs/design/Analysis/OlasSocialFeed.md | 193 +++ docs/design/Social/UpdatedPlan.md | 282 ++++ 5 files changed, 2394 insertions(+) create mode 100644 docs/design/Analysis/NDKFunctionHexKeys.md create mode 100644 docs/design/Analysis/NDKSubscriptionAnalysis.md create mode 100644 docs/design/Analysis/NDKandNip19.md create mode 100644 docs/design/Analysis/OlasSocialFeed.md create mode 100644 docs/design/Social/UpdatedPlan.md diff --git a/docs/design/Analysis/NDKFunctionHexKeys.md b/docs/design/Analysis/NDKFunctionHexKeys.md new file mode 100644 index 0000000..27fc1c4 --- /dev/null +++ b/docs/design/Analysis/NDKFunctionHexKeys.md @@ -0,0 +1,261 @@ +# NDK Functions for Hex Keys and NIP-19 Encoding/Decoding + +When working with Nostr addresses (like naddr) and converting between hex and other formats, NDK provides several key functions. Here's a comprehensive overview of the main functions you'll need for handling hex keys and NIP-19 encoding/decoding in your POWR Pack implementation: + +## Core NIP-19 Functions + +NDK implements NIP-19 functionality in the `events/nip19.ts` file. The key functions you'll need are: + +### 1. Decoding NIP-19 Entities + +```typescript +import { nip19 } from '@nostr-dev-kit/ndk'; + +// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.) +function decodeNaddr(naddrString: string) { + try { + const decoded = nip19.decode(naddrString); + + // For naddr specifically, you'll get: + if (decoded.type === 'naddr') { + const { pubkey, kind, identifier } = decoded.data; + + // pubkey is the hex public key of the author + // kind is the event kind (30004 for lists) + // identifier is the 'd' tag value + + console.log('Hex pubkey:', pubkey); + console.log('Event kind:', kind); + console.log('Identifier:', identifier); + + return decoded.data; + } + + return null; + } catch (error) { + console.error('Invalid NIP-19 format:', error); + return null; + } +} +``` + +### 2. Encoding to NIP-19 Formats + +```typescript +// Create an naddr from components +function createNaddr(pubkey: string, kind: number, identifier: string) { + return nip19.naddrEncode({ + pubkey, // Hex pubkey + kind, // Event kind (number) + identifier // The 'd' tag value + }); +} + +// Create an npub from a hex public key +function hexToNpub(hexPubkey: string) { + return nip19.npubEncode(hexPubkey); +} + +// Create a note (event reference) from event ID +function eventIdToNote(eventId: string) { + return nip19.noteEncode(eventId); +} +``` + +### 3. Utility Functions for Hex Keys + +```typescript +// Convert npub to hex pubkey +function npubToHex(npub: string) { + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + return decoded.data as string; // This is the hex pubkey + } + return null; + } catch (error) { + console.error('Invalid npub format:', error); + return null; + } +} + +// Check if a string is a valid hex key (pubkey or event id) +function isValidHexKey(hexString: string) { + return /^[0-9a-f]{64}$/i.test(hexString); +} +``` + +## Using NIP-19 Functions with NDK Filters + +Here's how you would use these functions with NDK filters to fetch a POWR Pack from an naddr: + +```typescript +async function fetchPackFromNaddr(naddr: string) { + try { + // Decode the naddr to get event coordinates + const decoded = nip19.decode(naddr); + + if (decoded.type !== 'naddr') { + throw new Error('Not an naddr'); + } + + const { pubkey, kind, identifier } = decoded.data; + + // Ensure it's a list (kind 30004) + if (kind !== 30004) { + throw new Error('Not a NIP-51 list'); + } + + // Create a filter to fetch the specific list event + const filter = { + kinds: [kind], + authors: [pubkey], // Using the hex pubkey from the naddr + '#d': identifier ? [identifier] : undefined, // Using the d-tag if available + }; + + // Fetch the event + const events = await ndk.fetchEvents(filter); + + if (events.size === 0) { + throw new Error('Pack not found'); + } + + // Get the first matching event + return Array.from(events)[0]; + } catch (error) { + console.error('Error fetching pack:', error); + throw error; + } +} +``` + +## Implementing the Complete naddr Workflow for POWR Packs + +Here's a complete example for fetching and processing a POWR Pack from an naddr: + +```typescript +import NDK, { NDKEvent, NDKFilter, nip19 } from '@nostr-dev-kit/ndk'; + +async function fetchAndProcessPOWRPack(naddr: string) { + // 1. Initialize NDK + const ndk = new NDK({ + explicitRelayUrls: [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band' + ] + }); + await ndk.connect(); + + // 2. Decode the naddr + const decoded = nip19.decode(naddr); + if (decoded.type !== 'naddr') { + throw new Error('Invalid naddr format'); + } + + const { pubkey, kind, identifier } = decoded.data; + + // 3. Create filter to fetch the pack event + const packFilter: NDKFilter = { + kinds: [kind], + authors: [pubkey], + '#d': identifier ? [identifier] : undefined + }; + + // 4. Fetch the pack event + const packEvents = await ndk.fetchEvents(packFilter); + if (packEvents.size === 0) { + throw new Error('Pack not found'); + } + + const packEvent = Array.from(packEvents)[0]; + + // 5. Extract template and exercise references + const templateRefs: string[] = []; + const exerciseRefs: string[] = []; + + for (const tag of packEvent.tags) { + if (tag[0] === 'a') { + const addressPointer = tag[1]; + // Format is kind:pubkey:d-tag + if (addressPointer.startsWith('33402:')) { // Workout template + templateRefs.push(addressPointer); + } else if (addressPointer.startsWith('33401:')) { // Exercise + exerciseRefs.push(addressPointer); + } + } + } + + // 6. Fetch templates and exercises + const templates = await fetchReferencedEvents(ndk, templateRefs); + const exercises = await fetchReferencedEvents(ndk, exerciseRefs); + + // 7. Return the complete pack data + return { + pack: packEvent, + templates, + exercises + }; +} + +// Helper function to fetch events from address pointers +async function fetchReferencedEvents(ndk: NDK, addressPointers: string[]) { + const events: NDKEvent[] = []; + + for (const pointer of addressPointers) { + // Parse the pointer (kind:pubkey:d-tag) + const [kindStr, hexPubkey, dTag] = pointer.split(':'); + const kind = parseInt(kindStr); + + // Create a filter to find this specific event + const filter: NDKFilter = { + kinds: [kind], + authors: [hexPubkey] + }; + + if (dTag) { + filter['#d'] = [dTag]; + } + + // Fetch the events + const fetchedEvents = await ndk.fetchEvents(filter); + events.push(...Array.from(fetchedEvents)); + } + + return events; +} +``` + +## Creating naddr for Sharing Packs + +If you want to generate an naddr that can be shared to allow others to import your POWR Pack: + +```typescript +function createShareableNaddr(packEvent: NDKEvent) { + // Extract the d-tag (identifier) + const dTags = packEvent.getMatchingTags('d'); + const identifier = dTags[0]?.[1] || ''; + + // Create the naddr + const naddr = nip19.naddrEncode({ + pubkey: packEvent.pubkey, + kind: packEvent.kind, + identifier + }); + + return naddr; +} +``` + +## Best Practices for Working with NIP-19 Formats + +1. **Always validate decoded values**: Check that the decoded data is of the expected type and has the necessary properties. + +2. **Handle encoding/decoding errors**: These functions can throw exceptions if the input is malformed. + +3. **Normalize hex keys**: Convert to lowercase for consistency in filters and comparisons. + +4. **Check event kinds**: Verify that the decoded event kind matches what you expect (30004 for NIP-51 lists). + +5. **Use strong typing**: TypeScript's type system can help catch errors with NIP-19 data. + +The main challenge when working with naddr and other NIP-19 formats is correctly translating between the human-readable encoded forms and the internal hex representations needed for Nostr protocol operations. NDK's nip19 module abstracts this complexity for you, allowing you to focus on the core business logic of your application. \ No newline at end of file diff --git a/docs/design/Analysis/NDKSubscriptionAnalysis.md b/docs/design/Analysis/NDKSubscriptionAnalysis.md new file mode 100644 index 0000000..17d182b --- /dev/null +++ b/docs/design/Analysis/NDKSubscriptionAnalysis.md @@ -0,0 +1,1397 @@ +Let me examine some of the key files related to subscriptions and contact lists. First, let's look at the subscription implementation: +─── text_editor | developer ────────────────────────── +path: ~/ndk/ndk/src/subscription/index.ts +command: view + + +### /Users/danielwyler/ndk/ndk/src/subscription/index.ts +```typescript +import { EventEmitter } from "tseep"; + +import type { NDKEventId, NostrEvent } from "../events/index.js"; +import { NDKEvent } from "../events/index.js"; +import type { NDK } from "../ndk/index.js"; +import type { NDKRelay } from "../relay"; +import type { NDKPool } from "../relay/pool/index.js"; +import { calculateRelaySetsFromFilters } from "../relay/sets/calculate"; +import type { NDKRelaySet } from "../relay/sets/index.js"; +import { queryFullyFilled } from "./utils.js"; +import type { NDKKind } from "../events/kinds/index.js"; +import { verifiedSignatures } from "../events/validation.js"; +import { wrapEvent } from "../events/wrap.js"; + +export type NDKSubscriptionInternalId = string; + +export type NDKSubscriptionDelayedType = "at-least" | "at-most"; + +export type NDKFilter = { + ids?: string[]; + kinds?: K[]; + authors?: string[]; + since?: number; + until?: number; + limit?: number; + search?: string; + [key: `#${string}`]: string[] | undefined; +}; + +export enum NDKSubscriptionCacheUsage { + // Only use cache, don't subscribe to relays + ONLY_CACHE = "ONLY_CACHE", + + // Use cache, if no matches, use relays + CACHE_FIRST = "CACHE_FIRST", + + // Use cache in addition to relays + PARALLEL = "PARALLEL", + + // Skip cache, don't query it + ONLY_RELAY = "ONLY_RELAY", +} + +export interface NDKSubscriptionOptions { + /** + * Whether to close the subscription when all relays have reached the end of the event stream. + * @default false + */ + closeOnEose?: boolean; + cacheUsage?: NDKSubscriptionCacheUsage; + + /** + * Whether to skip caching events coming from this subscription + **/ + dontSaveToCache?: boolean; + + /** + * Groupable subscriptions are created with a slight time + * delayed to allow similar filters to be grouped together. + */ + groupable?: boolean; + + /** + * The delay to use when grouping subscriptions, specified in milliseconds. + * @default 100 + * @example + * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100 }); + * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000 }); + * // sub1 and sub2 will be grouped together and executed 100ms after sub1 was created + */ + groupableDelay?: number; + + /** + * Specifies how this delay should be interpreted. + * "at-least" means "wait at least this long before sending the subscription" + * "at-most" means "wait at most this long before sending the subscription" + * @default "at-most" + * @example + * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100, groupableDelayType: "at-least" }); + * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000, groupableDelayType: "at-most" }); + * // sub1 and sub2 will be grouped together and executed 1000ms after sub1 was created + */ + groupableDelayType?: NDKSubscriptionDelayedType; + + /** + * The subscription ID to use for the subscription. + */ + subId?: string; + + /** + * Pool to use + */ + pool?: NDKPool; + + /** + * Skip signature verification + * @default false + */ + skipVerification?: boolean; + + /** + * Skip event validation. Event validation, checks whether received + * kinds conform to what the expected schema of that kind should look like.rtwle + * @default false + */ + skipValidation?: boolean; + + /** + * Skip emitting on events before they are received from a relay. (skip optimistic publish) + * @default false + */ + skipOptimisticPublishEvent?: boolean; + + /** + * Remove filter constraints when querying the cache. + * + * This allows setting more aggressive filters that will be removed when hitting the cache. + * + * Useful uses of this include removing `since` or `until` constraints or `limit` filters. + * + * @example + * ndk.subscribe({ kinds: [1], since: 1710000000, limit: 10 }, { cacheUnconstrainFilter: ['since', 'limit'] }); + * + * This will hit relays with the since and limit constraints, while loading from the cache without them. + */ + cacheUnconstrainFilter?: (keyof NDKFilter)[]; + + /** + * Whether to wrap events in kind-specific classes when possible. + * @default false + */ + wrap?: boolean; +} + +/** + * Default subscription options. + */ +export const defaultOpts: NDKSubscriptionOptions = { + closeOnEose: false, + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + dontSaveToCache: false, + groupable: true, + groupableDelay: 100, + groupableDelayType: "at-most", + cacheUnconstrainFilter: ['limit', 'since', 'until'] +}; + +/** + * Represents a subscription to an NDK event stream. + * + * @emits event + * Emitted when an event is received by the subscription. + * * ({NDKEvent} event - The event received by the subscription, + * * {NDKRelay} relay - The relay that received the event, + * * {NDKSubscription} subscription - The subscription that received the event.) + * + * @emits event:dup + * Emitted when a duplicate event is received by the subscription. + * * {NDKEvent} event - The duplicate event received by the subscription. + * * {NDKRelay} relay - The relay that received the event. + * * {number} timeSinceFirstSeen - The time elapsed since the first time the event was seen. + * * {NDKSubscription} subscription - The subscription that received the event. + * + * @emits cacheEose - Emitted when the cache adapter has reached the end of the events it had. + * + * @emits eose - Emitted when all relays have reached the end of the event stream. + * * {NDKSubscription} subscription - The subscription that received EOSE. + * + * @emits close - Emitted when the subscription is closed. + * * {NDKSubscription} subscription - The subscription that was closed. + * + * @example + * const sub = ndk.subscribe({ kinds: [1] }); // Get all kind:1s + * sub.on("event", (event) => console.log(event.content); // Show the content + * sub.on("eose", () => console.log("All relays have reached the end of the event stream")); + * sub.on("close", () => console.log("Subscription closed")); + * setTimeout(() => sub.stop(), 10000); // Stop the subscription after 10 seconds + * + * @description + * Subscriptions are created using {@link NDK.subscribe}. + * + * # Event validation + * By defaults, subscriptions will validate events to comply with the minimal requirement + * of each known NIP. + * This can be disabled by setting the `skipValidation` option to `true`. + * + * @example + * const sub = ndk.subscribe({ kinds: [1] }, { skipValidation: false }); + * sub.on("event", (event) => console.log(event.content); // Only valid events will be received + */ +export class NDKSubscription extends EventEmitter<{ + cacheEose: () => void; + eose: (sub: NDKSubscription) => void; + close: (sub: NDKSubscription) => void; + + /** + * Emitted when a duplicate event is received by the subscription. + * @param event - The duplicate event received by the subscription. + * @param relay - The relay that received the event. + * @param timeSinceFirstSeen - The time elapsed since the first time the event was seen. + * @param sub - The subscription that received the event. + */ + "event:dup": ( + event: NDKEvent | NostrEvent, + relay: NDKRelay | undefined, + timeSinceFirstSeen: number, + sub: NDKSubscription, + fromCache: boolean, + optimisticPublish: boolean + ) => void; + + /** + * Emitted when an event is received by the subscription. + * @param event - The event received by the subscription. + * @param relay - The relay that received the event. + * @param sub - The subscription that received the event. + * @param fromCache - Whether the event was received from the cache. + * @param optimisticPublish - Whether the event was received from an optimistic publish. + */ + event: (event: NDKEvent, relay: NDKRelay | undefined, sub: NDKSubscription, fromCache: boolean, optimisticPublish: boolean) => void; + + /** + * Emitted when a relay unilaterally closes the subscription. + * @param relay + * @param reason + * @returns + */ + closed: (relay: NDKRelay, reason: string) => void; +}> { + readonly subId?: string; + readonly filters: NDKFilter[]; + readonly opts: NDKSubscriptionOptions; + readonly pool: NDKPool; + readonly skipVerification: boolean = false; + readonly skipValidation: boolean = false; + + /** + * Tracks the filters as they are executed on each relay + */ + public relayFilters?: Map; + public relaySet?: NDKRelaySet; + public ndk: NDK; + public debug: debug.Debugger; + + /** + * Events that have been seen by the subscription, with the time they were first seen. + */ + public eventFirstSeen = new Map(); + + /** + * Relays that have sent an EOSE. + */ + public eosesSeen = new Set(); + + /** + * The time the last event was received by the subscription. + * This is used to calculate when EOSE should be emitted. + */ + private lastEventReceivedAt: number | undefined; + + public internalId: NDKSubscriptionInternalId; + + /** + * Whether the subscription should close when all relays have reached the end of the event stream. + */ + public closeOnEose: boolean; + + /** + * Pool monitor callback + */ + private poolMonitor: ((relay: NDKRelay) => void) | undefined; + + public skipOptimisticPublishEvent: boolean = false; + + /** + * Filters to remove when querying the cache. + */ + public cacheUnconstrainFilter?: Array<(keyof NDKFilter)>; + + public constructor( + ndk: NDK, + filters: NDKFilter | NDKFilter[], + opts?: NDKSubscriptionOptions, + relaySet?: NDKRelaySet, + subId?: string + ) { + super(); + this.ndk = ndk; + this.pool = opts?.pool || ndk.pool; + this.opts = { ...defaultOpts, ...(opts || {}) }; + this.filters = filters instanceof Array ? filters : [filters]; + this.subId = subId || this.opts.subId; + this.internalId = Math.random().toString(36).substring(7); + this.relaySet = relaySet; + this.debug = ndk.debug.extend(`subscription[${this.opts.subId ?? this.internalId}]`); + this.skipVerification = this.opts.skipVerification || false; + this.skipValidation = this.opts.skipValidation || false; + this.closeOnEose = this.opts.closeOnEose || false; + this.skipOptimisticPublishEvent = this.opts.skipOptimisticPublishEvent || false; + this.cacheUnconstrainFilter = this.opts.cacheUnconstrainFilter; + } + + /** + * Returns the relays that have not yet sent an EOSE. + */ + public relaysMissingEose(): WebSocket["url"][] { + if (!this.relayFilters) return []; + + const relaysMissingEose = Array.from(this.relayFilters!.keys()).filter( + (url) => !this.eosesSeen.has(this.pool.getRelay(url, false, false)) + ); + + return relaysMissingEose; + } + + /** + * Provides access to the first filter of the subscription for + * backwards compatibility. + */ + get filter(): NDKFilter { + return this.filters[0]; + } + + get groupableDelay(): number | undefined { + if (!this.isGroupable()) return undefined; + return this.opts?.groupableDelay; + } + + get groupableDelayType(): NDKSubscriptionDelayedType { + return this.opts?.groupableDelayType || "at-most"; + } + + public isGroupable(): boolean { + return this.opts?.groupable || false; + } + + private shouldQueryCache(): boolean { + // explicitly told to not query the cache + if (this.opts?.cacheUsage === NDKSubscriptionCacheUsage.ONLY_RELAY) return false; + + const hasNonEphemeralKind = this.filters.some((f) => f.kinds?.some((k) => kindIsEphemeral(k))); + if (hasNonEphemeralKind) return true; + + return true; + } + + private shouldQueryRelays(): boolean { + return this.opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_CACHE; + } + + private shouldWaitForCache(): boolean { + return ( + // Must want to close on EOSE; subscriptions + // that want to receive further updates must + // always hit the relay + this.opts.closeOnEose! && + // Cache adapter must claim to be fast + !!this.ndk.cacheAdapter?.locking && + // If explicitly told to run in parallel, then + // we should not wait for the cache + this.opts.cacheUsage !== NDKSubscriptionCacheUsage.PARALLEL + ); + } + + /** + * Start the subscription. This is the main method that should be called + * after creating a subscription. + * + * @param emitCachedEvents - Whether to emit events coming from a synchronous cache + * + * When using a synchronous cache, the events will be returned immediately + * by this function. If you will use those returned events, you should + * set emitCachedEvents to false to prevent seeing them as duplicate events. + */ + public start(emitCachedEvents: boolean = true): NDKEvent[] | null { + let cacheResult: NDKEvent[] | Promise; + + const updateStateFromCacheResults = (events: NDKEvent[]) => { + if (emitCachedEvents) { + for (const event of events) { + this.eventReceived(event, undefined, true, false); + } + } else { + cacheResult = []; + events.forEach((event) => { + event.ndk = this.ndk; + const e = this.opts.wrap ? wrapEvent(event) : event; + if (!e) return; + if (e instanceof Promise) { + // if we get a promise, we emit it + e.then((wrappedEvent) => { + this.emitEvent(false, wrappedEvent, undefined, true, false); + }); + return; + } + this.eventFirstSeen.set(e.id, Date.now()); + (cacheResult as NDKEvent[]).push(e); + }); + } + } + + const loadFromRelays = () => { + if (this.shouldQueryRelays()) { + this.startWithRelays(); + this.startPoolMonitor(); + } else { + this.emit("eose", this); + } + } + + if (this.shouldQueryCache()) { + cacheResult = this.startWithCache(); + + if (cacheResult instanceof Promise) { + // The cache is asynchronous + if (this.shouldWaitForCache()) { + // If we need to wait for it + cacheResult.then((events) => { + // load the results into the subscription state + updateStateFromCacheResults(events); + // if the cache has a hit, return early + if (queryFullyFilled(this)) { + this.emit("eose", this); + return; + } else { + loadFromRelays(); + } + }); + return null; + } else { + cacheResult.then((events) => { + updateStateFromCacheResults(events); + }); + } + + return null; + } else { + updateStateFromCacheResults(cacheResult); + + if (queryFullyFilled(this)) { + this.emit("eose", this); + } else { + loadFromRelays(); + } + + return cacheResult; + } + } else { + loadFromRelays(); + return null; + } + } + + /** + * We want to monitor for new relays that are coming online, in case + * they should be part of this subscription. + */ + private startPoolMonitor(): void { + const d = this.debug.extend("pool-monitor"); + + this.poolMonitor = (relay: NDKRelay) => { + // check if the pool monitor is already in the relayFilters + if (this.relayFilters?.has(relay.url)) return; + + const calc = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool); + + // check if the new relay is included + if (calc.get(relay.url)) { + // add it to the relayFilters + this.relayFilters?.set(relay.url, this.filters); + + // d("New relay connected -- adding to subscription", relay.url); + relay.subscribe(this, this.filters); + } + }; + + this.pool.on("relay:connect", this.poolMonitor); + } + + public onStopped?: () => void; + + public stop(): void { + this.emit("close", this); + this.poolMonitor && this.pool.off("relay:connect", this.poolMonitor); + this.removeAllListeners(); + this.onStopped?.(); + } + + /** + * @returns Whether the subscription has an authors filter. + */ + public hasAuthorsFilter(): boolean { + return this.filters.some((f) => f.authors?.length); + } + + private startWithCache(): NDKEvent[] | Promise { + if (this.ndk.cacheAdapter?.query) { + return this.ndk.cacheAdapter.query(this); + } else { + return []; + } + } + + /** + * Send REQ to relays + */ + private startWithRelays(): void { + if (!this.relaySet || this.relaySet.relays.size === 0) { + this.relayFilters = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool); + } else { + this.relayFilters = new Map(); + for (const relay of this.relaySet.relays) { + this.relayFilters.set(relay.url, this.filters); + } + } + + if (!this.relayFilters || this.relayFilters.size === 0) return; + + // iterate through the this.relayFilters + for (const [relayUrl, filters] of this.relayFilters) { + const relay = this.pool.getRelay(relayUrl, true, true, filters); + relay.subscribe(this, filters); + } + } + + // EVENT handling + + /** + * Called when an event is received from a relay or the cache + * @param event + * @param relay + * @param fromCache Whether the event was received from the cache + * @param optimisticPublish Whether this event is coming from an optimistic publish + */ + public eventReceived( + event: NDKEvent | NostrEvent, + relay: NDKRelay | undefined, + fromCache: boolean = false, + optimisticPublish: boolean = false + ) { + const eventId = event.id! as NDKEventId; + + const eventAlreadySeen = this.eventFirstSeen.has(eventId); + let ndkEvent: NDKEvent; + + if (event instanceof NDKEvent) ndkEvent = event; + + if (!eventAlreadySeen) { + // generate the ndkEvent + ndkEvent ??= new NDKEvent(this.ndk, event); + ndkEvent.ndk = this.ndk; + ndkEvent.relay = relay; + + // we don't want to validate/verify events that are either + // coming from the cache or have been published by us from within + // the client + if (!fromCache && !optimisticPublish) { + // validate it + if (!this.skipValidation) { + if (!ndkEvent.isValid) { + this.debug(`Event failed validation %s from relay %s`, eventId, relay?.url); + return; + } + } + + // verify it + if (relay) { + if (relay?.shouldValidateEvent() !== false) { + if (!this.skipVerification) { + if (!ndkEvent.verifySignature(true) && !this.ndk.asyncSigVerification) { + this.debug(`Event failed signature validation`, event); + return; + } else if (relay) { + relay.addValidatedEvent(); + } + } + } else { + relay.addNonValidatedEvent(); + } + } + + if (this.ndk.cacheAdapter && !this.opts.dontSaveToCache) { + this.ndk.cacheAdapter.setEvent(ndkEvent, this.filters, relay); + } + } + + // emit it + if (!optimisticPublish || this.skipOptimisticPublishEvent !== true) { + this.emitEvent(this.opts?.wrap, ndkEvent, relay, fromCache, optimisticPublish); + // mark the eventId as seen + this.eventFirstSeen.set(eventId, Date.now()); + } + } else { + const timeSinceFirstSeen = Date.now() - (this.eventFirstSeen.get(eventId) || 0); + this.emit("event:dup", event, relay, timeSinceFirstSeen, this, fromCache, optimisticPublish); + + if (relay) { + // Let's see if we have already verified this event id's signature + const signature = verifiedSignatures.get(eventId); + if (signature && typeof signature === "string") { + // If it matches then we can increase the relay verification count + if (event.sig === signature) { + relay.addValidatedEvent(); + } + } + } + } + + this.lastEventReceivedAt = Date.now(); + } + + /** + * Optionally wraps, sync or async, and emits the event (if one comes back from the wrapper) + */ + private emitEvent(wrap = false, evt: NDKEvent, relay: NDKRelay | undefined, fromCache: boolean, optimisticPublish: boolean) { + const wrapped = wrap ? wrapEvent(evt) : evt; + if (wrapped instanceof Promise) { + wrapped.then((e) => this.emitEvent(false, e, relay, fromCache, optimisticPublish)) + } else if (wrapped) { + this.emit("event", wrapped, relay, this, fromCache, optimisticPublish); + } + } + + public closedReceived(relay: NDKRelay, reason: string): void { + this.emit("closed", relay, reason); + } + + // EOSE handling + private eoseTimeout: ReturnType | undefined; + private eosed = false; + + public eoseReceived(relay: NDKRelay): void { + this.debug("EOSE received from %s", relay.url); + this.eosesSeen.add(relay); + + let lastEventSeen = this.lastEventReceivedAt + ? Date.now() - this.lastEventReceivedAt + : undefined; + + const hasSeenAllEoses = this.eosesSeen.size === this.relayFilters?.size; + const queryFilled = queryFullyFilled(this); + + const performEose = (reason: string) => { + this.debug("Performing EOSE: %s %d", reason, this.eosed); + if (this.eosed) return; + if (this.eoseTimeout) clearTimeout(this.eoseTimeout); + this.emit("eose", this); + this.eosed = true; + + // if (this.opts?.closeOnEose) this.stop(); + }; + + if (queryFilled || hasSeenAllEoses) { + performEose("query filled or seen all"); + } else if (this.relayFilters) { + let timeToWaitForNextEose = 1000; + + const connectedRelays = new Set(this.pool.connectedRelays().map((r) => r.url)); + + const connectedRelaysWithFilters = Array.from(this.relayFilters.keys()).filter((url) => + connectedRelays.has(url) + ); + + // if we have no connected relays, wait for all relays to connect + if (connectedRelaysWithFilters.length === 0) { + return; + } + + // Reduce the number of ms to wait based on the percentage of relays + // that have already sent an EOSE, the more + // relays that have sent an EOSE, the less time we should wait + // for the next one + const percentageOfRelaysThatHaveSentEose = + this.eosesSeen.size / connectedRelaysWithFilters.length; + + this.debug("Percentage of relays that have sent EOSE", { subId: this.subId, percentageOfRelaysThatHaveSentEose, seen: this.eosesSeen.size, total: connectedRelaysWithFilters.length }); + + // If less than 2 and 50% of relays have EOSEd don't add a timeout yet + if (this.eosesSeen.size >= 2 && percentageOfRelaysThatHaveSentEose >= 0.5) { + timeToWaitForNextEose = + timeToWaitForNextEose * (1 - percentageOfRelaysThatHaveSentEose); + + if (timeToWaitForNextEose === 0) { + performEose("time to wait was 0"); + return; + } + + if (this.eoseTimeout) clearTimeout(this.eoseTimeout); + + const sendEoseTimeout = () => { + lastEventSeen = this.lastEventReceivedAt + ? Date.now() - this.lastEventReceivedAt + : undefined; + + // If we have seen an event in the past 20ms don't emit an EOSE due to a timeout, events + // are still being received + if (lastEventSeen !== undefined && lastEventSeen < 20) { + this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose); + } else { + performEose("send eose timeout: " + timeToWaitForNextEose); + } + }; + + this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose); + } + } + } +} + +const kindIsEphemeral = (kind: NDKKind) => kind >= 20000 && kind < 30000; +``` + +Let me also check the implementation for contact list handling: +─── text_editor | developer ────────────────────────── +path: ~/ndk/ndk/src/user/index.ts +command: view + + +### /Users/danielwyler/ndk/ndk/src/user/index.ts +```typescript +import { nip19 } from "nostr-tools"; + +import { NDKEvent, type NDKTag, type NostrEvent } from "../events/index.js"; +import { NDKKind } from "../events/kinds/index.js"; +import type { NDK } from "../ndk/index.js"; +import { NDKSubscriptionCacheUsage, type NDKSubscriptionOptions } from "../subscription/index.js"; +import { follows } from "./follows.js"; +import { type NDKUserProfile, profileFromEvent, serializeProfile } from "./profile.js"; +import { getNip05For } from "./nip05.js"; +import type { + NDKFilter, + NDKRelay, + NDKZapMethod, + NDKZapMethodInfo, +} from "../index.js"; +import { NDKCashuMintList } from "../events/kinds/nutzap/mint-list.js"; + +export type Hexpubkey = string; + +export type Npub = string; + +// @ignore +export type ProfilePointer = { + pubkey: string; + relays?: string[]; + nip46?: string[]; +}; + +// @ignore +export type EventPointer = { + id: string; + relays?: string[]; + author?: string; + kind?: number; +}; + +export interface NDKUserParams { + npub?: Npub; + hexpubkey?: Hexpubkey; + pubkey?: Hexpubkey; + nip05?: string; + relayUrls?: string[]; + nip46Urls?: string[]; +} + +/** + * Represents a pubkey. + */ +export class NDKUser { + public ndk: NDK | undefined; + public profile?: NDKUserProfile; + public profileEvent?: NDKEvent; + private _npub?: Npub; + private _pubkey?: Hexpubkey; + readonly relayUrls: string[] = []; + readonly nip46Urls: string[] = []; + + public constructor(opts: NDKUserParams) { + if (opts.npub) this._npub = opts.npub; + + if (opts.hexpubkey) this._pubkey = opts.hexpubkey; + if (opts.pubkey) this._pubkey = opts.pubkey; + + if (opts.relayUrls) this.relayUrls = opts.relayUrls; + if (opts.nip46Urls) this.nip46Urls = opts.nip46Urls; + } + + get npub(): string { + if (!this._npub) { + if (!this._pubkey) throw new Error("pubkey not set"); + this._npub = nip19.npubEncode(this.pubkey); + } + + return this._npub; + } + + get nprofile(): string { + const relays = this.profileEvent?.onRelays?.map((r) => r.url); + return nip19.nprofileEncode({ + pubkey: this.pubkey, + relays + }); + } + + set npub(npub: Npub) { + this._npub = npub; + } + + /** + * Get the user's pubkey + * @returns {string} The user's pubkey + */ + get pubkey(): string { + if (!this._pubkey) { + if (!this._npub) throw new Error("npub not set"); + this._pubkey = nip19.decode(this.npub).data as Hexpubkey; + } + + return this._pubkey; + } + + /** + * Set the user's pubkey + * @param pubkey {string} The user's pubkey + */ + set pubkey(pubkey: string) { + this._pubkey = pubkey; + } + + /** + * Equivalent to NDKEvent.filters(). + * @returns {NDKFilter} + */ + public filter(): NDKFilter { + return {"#p": [this.pubkey]} + } + + /** + * Gets NIP-57 and NIP-61 information that this user has signaled + * + * @param getAll {boolean} Whether to get all zap info or just the first one + */ + async getZapInfo(timeoutMs?: number): Promise> { + if (!this.ndk) throw new Error("No NDK instance found"); + + const promiseWithTimeout = async (promise: Promise): Promise => { + if (!timeoutMs) return promise; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(), timeoutMs)) + ]); + } catch { + return undefined; + } + }; + + const [ userProfile, mintListEvent ] = await Promise.all([ + promiseWithTimeout(this.fetchProfile()), + promiseWithTimeout(this.ndk.fetchEvent({ kinds: [NDKKind.CashuMintList], authors: [this.pubkey] })) + ]); + + const res: Map = new Map(); + + if (mintListEvent) { + const mintList = NDKCashuMintList.from(mintListEvent); + + if (mintList.mints.length > 0) { + res.set("nip61", { + mints: mintList.mints, + relays: mintList.relays, + p2pk: mintList.p2pk, + }); + } + } + + if (userProfile) { + const { lud06, lud16 } = userProfile; + res.set("nip57", { lud06, lud16 }); + } + + return res; + } + + /** + * Instantiate an NDKUser from a NIP-05 string + * @param nip05Id {string} The user's NIP-05 + * @param ndk {NDK} An NDK instance + * @param skipCache {boolean} Whether to skip the cache or not + * @returns {NDKUser | undefined} An NDKUser if one is found for the given NIP-05, undefined otherwise. + */ + static async fromNip05( + nip05Id: string, + ndk: NDK, + skipCache = false + ): Promise { + if (!ndk) throw new Error("No NDK instance found"); + + const opts: RequestInit = {}; + + if (skipCache) opts.cache = "no-cache"; + const profile = await getNip05For(ndk, nip05Id, ndk?.httpFetch, opts); + + if (profile) { + const user = new NDKUser({ + pubkey: profile.pubkey, + relayUrls: profile.relays, + nip46Urls: profile.nip46, + }); + user.ndk = ndk; + return user; + } + } + + /** + * Fetch a user's profile + * @param opts {NDKSubscriptionOptions} A set of NDKSubscriptionOptions + * @param storeProfileEvent {boolean} Whether to store the profile event or not + * @returns User Profile + */ + public async fetchProfile( + opts?: NDKSubscriptionOptions, + storeProfileEvent: boolean = false + ): Promise { + if (!this.ndk) throw new Error("NDK not set"); + + if (!this.profile) this.profile = {}; + + let setMetadataEvent: NDKEvent | null = null; + + if ( + this.ndk.cacheAdapter && + this.ndk.cacheAdapter.fetchProfile && + opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_RELAY + ) { + const profile = await this.ndk.cacheAdapter.fetchProfile(this.pubkey); + + if (profile) { + this.profile = profile; + return profile; + } + } + + // if no options have been set and we have a cache, try to load from cache with no grouping + // This is done in favour of simply using NDKSubscriptionCacheUsage.CACHE_FIRST since + // we want to avoid depending on the grouping, arguably, all queries should go through this + // type of behavior when we have a locking cache + if ( + !opts && // if no options have been set + this.ndk.cacheAdapter && // and we have a cache + this.ndk.cacheAdapter.locking // and the cache identifies itself as fast 😂 + ) { + setMetadataEvent = await this.ndk.fetchEvent( + { + kinds: [0], + authors: [this.pubkey], + }, + { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE, + closeOnEose: true, + groupable: false, + } + ); + + opts = { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + closeOnEose: true, + groupable: true, + groupableDelay: 250, + }; + } + + if (!setMetadataEvent) { + setMetadataEvent = await this.ndk.fetchEvent( + { + kinds: [0], + authors: [this.pubkey], + }, + opts + ); + } + + if (!setMetadataEvent) return null; + + // return the most recent profile + this.profile = profileFromEvent(setMetadataEvent); + + if (storeProfileEvent) { + // Store the event as a stringified JSON + this.profile.profileEvent = JSON.stringify(setMetadataEvent); + } + + if (this.profile && this.ndk.cacheAdapter && this.ndk.cacheAdapter.saveProfile) { + this.ndk.cacheAdapter.saveProfile(this.pubkey, this.profile); + } + + return this.profile; + } + + /** + * Returns a set of users that this user follows. + * + * @deprecated Use followSet instead + */ + public follows = follows.bind(this); + + /** + * Returns a set of pubkeys that this user follows. + * + * @param opts - NDKSubscriptionOptions + * @param outbox - boolean + * @param kind - number + */ + public async followSet( + opts?: NDKSubscriptionOptions, + outbox?: boolean, + kind: number = NDKKind.Contacts + ): Promise> { + const follows = await this.follows(opts, outbox, kind); + return new Set(Array.from(follows).map((f) => f.pubkey)); + } + + /** @deprecated Use referenceTags instead. */ + /** + * Get the tag that can be used to reference this user in an event + * @returns {NDKTag} an NDKTag + */ + public tagReference(): NDKTag { + return ["p", this.pubkey]; + } + + /** + * Get the tags that can be used to reference this user in an event + * @returns {NDKTag[]} an array of NDKTag + */ + public referenceTags(marker?: string): NDKTag[] { + const tag = [["p", this.pubkey]]; + if (!marker) return tag; + + // TODO: Locate this pubkey's relay + tag[0].push("", marker); + return tag; + } + + /** + * Publishes the current profile. + */ + public async publish() { + if (!this.ndk) throw new Error("No NDK instance found"); + if (!this.profile) throw new Error("No profile available"); + + this.ndk.assertSigner(); + + const event = new NDKEvent(this.ndk, { + kind: 0, + content: serializeProfile(this.profile), + } as NostrEvent); + await event.publish(); + } + + /** + * Add a follow to this user's contact list + * + * @param newFollow {NDKUser} The user to follow + * @param currentFollowList {Set} The current follow list + * @param kind {NDKKind} The kind to use for this contact list (defaults to `3`) + * @returns {Promise} True if the follow was added, false if the follow already exists + */ + public async follow( + newFollow: NDKUser, + currentFollowList?: Set, + kind = NDKKind.Contacts + ): Promise { + if (!this.ndk) throw new Error("No NDK instance found"); + + this.ndk.assertSigner(); + + if (!currentFollowList) { + currentFollowList = await this.follows(undefined, undefined, kind); + } + + if (currentFollowList.has(newFollow)) { + return false; + } + + currentFollowList.add(newFollow); + + const event = new NDKEvent(this.ndk, { kind } as NostrEvent); + + // This is a horrible hack and I need to fix it + for (const follow of currentFollowList) { + event.tag(follow); + } + + await event.publish(); + return true; + } + + /** + * Remove a follow from this user's contact list + * + * @param user {NDKUser} The user to unfollow + * @param currentFollowList {Set} The current follow list + * @param kind {NDKKind} The kind to use for this contact list (defaults to `3`) + * @returns The relays were the follow list was published or false if the user wasn't found + */ + public async unfollow( + user: NDKUser, + currentFollowList?: Set, + kind = NDKKind.Contacts + ): Promise | boolean> { + if (!this.ndk) throw new Error("No NDK instance found"); + + this.ndk.assertSigner(); + + if (!currentFollowList) { + currentFollowList = await this.follows(undefined, undefined, kind); + } + + // find the user that has the same pubkey + const newUserFollowList = new Set(); + let foundUser = false; + for (const follow of currentFollowList) { + if (follow.pubkey !== user.pubkey) { + newUserFollowList.add(follow); + } else { + foundUser = true; + } + } + + if (!foundUser) return false; + + const event = new NDKEvent(this.ndk, { kind } as NostrEvent); + + // Tag users from the new follow list + for (const follow of newUserFollowList) { + event.tag(follow); + } + + return await event.publish(); + } + + /** + * Validate a user's NIP-05 identifier (usually fetched from their kind:0 profile data) + * + * @param nip05Id The NIP-05 string to validate + * @returns {Promise} True if the NIP-05 is found and matches this user's pubkey, + * False if the NIP-05 is found but doesn't match this user's pubkey, + * null if the NIP-05 isn't found on the domain or we're unable to verify (because of network issues, etc.) + */ + public async validateNip05(nip05Id: string): Promise { + if (!this.ndk) throw new Error("No NDK instance found"); + + const profilePointer: ProfilePointer | null = await getNip05For(this.ndk, nip05Id); + + if (profilePointer === null) return null; + return profilePointer.pubkey === this.pubkey; + } +} + +``` + +Based on the code analysis, I can provide some best practices and recommendations for handling subscriptions and contact lists in NDK: + +### Best Practices for Subscriptions + +1. **Use Subscription Options Effectively** +```typescript +const subscriptionOptions = { + closeOnEose: true, // Close when all relays send EOSE + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, // Try cache before hitting relays + groupable: true, // Allow grouping similar subscriptions + groupableDelay: 100, // Wait 100ms to group subscriptions + skipVerification: false, // Verify signatures + skipValidation: false // Validate event structure +}; +``` + +2. **Cache Management** +- NDK supports different cache strategies through `cacheUsage`: + - `CACHE_FIRST`: Check cache before hitting relays + - `PARALLEL`: Query both cache and relays simultaneously + - `ONLY_CACHE`: Only use cached data + - `ONLY_RELAY`: Skip cache entirely + +3. **Efficient Subscription Handling** +```typescript +// Create a subscription with filters +const sub = ndk.subscribe( + { kinds: [3], // Contact lists + authors: [userPubkey] }, + subscriptionOptions +); + +// Handle events and cleanup +sub.on('event', (event) => { + // Process contact list updates +}); +sub.on('eose', () => { + // Handle end of stored events +}); + +// Start the subscription +sub.start(); + +// Clean up when done +sub.stop(); +``` + +### Best Practices for Contact Lists (Kind 3) + +1. **Fetching Contact Lists** +```typescript +// Using NDKUser class +const user = new NDKUser({ pubkey: userPubkey }); +user.ndk = ndk; + +// Get follows as NDKUser objects +const follows = await user.follows(); + +// Get just the pubkeys +const followSet = await user.followSet(); +``` + +2. **Managing Follows** +```typescript +// Add a new follow +const newFollow = new NDKUser({ pubkey: followPubkey }); +await user.follow(newFollow); + +// Remove a follow +await user.unfollow(userToUnfollow); +``` + +3. **Efficient Contact List Updates** +```typescript +// Get current follow list first +const currentFollows = await user.follows(); + +// Batch multiple follow operations +await user.follow(newFollow, currentFollows); +await user.follow(anotherFollow, currentFollows); +``` + +### Improved Reliability Patterns + +1. **Subscription Error Handling** +```typescript +const sub = ndk.subscribe({ kinds: [3] }); + +sub.on('error', (error, relay) => { + console.error(`Subscription error from ${relay.url}:`, error); +}); + +sub.on('closed', (relay, reason) => { + console.log(`Relay ${relay.url} closed connection: ${reason}`); +}); +``` + +2. **Relay Management** +```typescript +// Specify reliable relays for important queries +const relaySet = new NDKRelaySet(ndk, [ + 'wss://relay1.example.com', + 'wss://relay2.example.com' +]); + +const sub = ndk.subscribe( + { kinds: [3] }, + { closeOnEose: true }, + relaySet +); +``` + +3. **Cache Implementation** +```typescript +// Implement a cache adapter for better reliability +const cacheAdapter = { + async query(subscription) { + // Return cached events matching subscription filters + }, + async setEvent(event, filters, relay) { + // Store event in cache + } +}; + +ndk.cacheAdapter = cacheAdapter; +``` + +4. **Subscription Grouping** +```typescript +// Group similar subscriptions to reduce relay connections +const sub1 = ndk.subscribe( + { kinds: [3], authors: [pubkey1] }, + { groupable: true, groupableDelay: 100 } +); + +const sub2 = ndk.subscribe( + { kinds: [3], authors: [pubkey2] }, + { groupable: true, groupableDelay: 100 } +); + +// These will be grouped into a single subscription to the relay +``` + +### Recommendations for Your Use Case + +1. **Implement Progressive Loading** +```typescript +// First load from cache +const sub = ndk.subscribe( + { kinds: [3], authors: [userPubkey] }, + { cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST } +); + +// Handle immediate cache results +let contactList = new Set(); +sub.on('event', (event, relay, subscription, fromCache) => { + if (fromCache) { + // Handle cached contacts immediately + } else { + // Update with fresh data from relays + } +}); +``` + +2. **Keep Contact Lists Updated** +```typescript +// Subscribe to contact list updates +const followsSubscription = ndk.subscribe({ + kinds: [3], + authors: [userPubkey], +}); + +followsSubscription.on('event', (event) => { + // Update local contact list state + updateContactList(event); +}); +``` + +3. **Implement Retry Logic** +```typescript +async function fetchContactList(attempts = 3) { + for (let i = 0; i < attempts; i++) { + try { + const user = new NDKUser({ pubkey: userPubkey }); + user.ndk = ndk; + return await user.followSet(); + } catch (e) { + if (i === attempts - 1) throw e; + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); + } + } +} +``` + +4. **Maintain Local State** +```typescript +class ContactListManager { + private contactList = new Set(); + private subscription; + + constructor(ndk, userPubkey) { + this.subscription = ndk.subscribe({ + kinds: [3], + authors: [userPubkey] + }); + + this.subscription.on('event', this.handleContactListUpdate.bind(this)); + } + + private handleContactListUpdate(event) { + // Update local state with new contact list + this.contactList = new Set( + event.tags + .filter(tag => tag[0] === 'p') + .map(tag => tag[1]) + ); + } + + getContacts() { + return this.contactList; + } +} +``` + +These practices should provide a more reliable way to handle contact lists and subscriptions in your NDK implementation. The key is to use the built-in caching mechanisms, implement proper error handling, and maintain local state while keeping it in sync with relay updates. + +Would you like me to expand on any of these aspects or provide more specific examples for your use case? \ No newline at end of file diff --git a/docs/design/Analysis/NDKandNip19.md b/docs/design/Analysis/NDKandNip19.md new file mode 100644 index 0000000..27fc1c4 --- /dev/null +++ b/docs/design/Analysis/NDKandNip19.md @@ -0,0 +1,261 @@ +# NDK Functions for Hex Keys and NIP-19 Encoding/Decoding + +When working with Nostr addresses (like naddr) and converting between hex and other formats, NDK provides several key functions. Here's a comprehensive overview of the main functions you'll need for handling hex keys and NIP-19 encoding/decoding in your POWR Pack implementation: + +## Core NIP-19 Functions + +NDK implements NIP-19 functionality in the `events/nip19.ts` file. The key functions you'll need are: + +### 1. Decoding NIP-19 Entities + +```typescript +import { nip19 } from '@nostr-dev-kit/ndk'; + +// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.) +function decodeNaddr(naddrString: string) { + try { + const decoded = nip19.decode(naddrString); + + // For naddr specifically, you'll get: + if (decoded.type === 'naddr') { + const { pubkey, kind, identifier } = decoded.data; + + // pubkey is the hex public key of the author + // kind is the event kind (30004 for lists) + // identifier is the 'd' tag value + + console.log('Hex pubkey:', pubkey); + console.log('Event kind:', kind); + console.log('Identifier:', identifier); + + return decoded.data; + } + + return null; + } catch (error) { + console.error('Invalid NIP-19 format:', error); + return null; + } +} +``` + +### 2. Encoding to NIP-19 Formats + +```typescript +// Create an naddr from components +function createNaddr(pubkey: string, kind: number, identifier: string) { + return nip19.naddrEncode({ + pubkey, // Hex pubkey + kind, // Event kind (number) + identifier // The 'd' tag value + }); +} + +// Create an npub from a hex public key +function hexToNpub(hexPubkey: string) { + return nip19.npubEncode(hexPubkey); +} + +// Create a note (event reference) from event ID +function eventIdToNote(eventId: string) { + return nip19.noteEncode(eventId); +} +``` + +### 3. Utility Functions for Hex Keys + +```typescript +// Convert npub to hex pubkey +function npubToHex(npub: string) { + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + return decoded.data as string; // This is the hex pubkey + } + return null; + } catch (error) { + console.error('Invalid npub format:', error); + return null; + } +} + +// Check if a string is a valid hex key (pubkey or event id) +function isValidHexKey(hexString: string) { + return /^[0-9a-f]{64}$/i.test(hexString); +} +``` + +## Using NIP-19 Functions with NDK Filters + +Here's how you would use these functions with NDK filters to fetch a POWR Pack from an naddr: + +```typescript +async function fetchPackFromNaddr(naddr: string) { + try { + // Decode the naddr to get event coordinates + const decoded = nip19.decode(naddr); + + if (decoded.type !== 'naddr') { + throw new Error('Not an naddr'); + } + + const { pubkey, kind, identifier } = decoded.data; + + // Ensure it's a list (kind 30004) + if (kind !== 30004) { + throw new Error('Not a NIP-51 list'); + } + + // Create a filter to fetch the specific list event + const filter = { + kinds: [kind], + authors: [pubkey], // Using the hex pubkey from the naddr + '#d': identifier ? [identifier] : undefined, // Using the d-tag if available + }; + + // Fetch the event + const events = await ndk.fetchEvents(filter); + + if (events.size === 0) { + throw new Error('Pack not found'); + } + + // Get the first matching event + return Array.from(events)[0]; + } catch (error) { + console.error('Error fetching pack:', error); + throw error; + } +} +``` + +## Implementing the Complete naddr Workflow for POWR Packs + +Here's a complete example for fetching and processing a POWR Pack from an naddr: + +```typescript +import NDK, { NDKEvent, NDKFilter, nip19 } from '@nostr-dev-kit/ndk'; + +async function fetchAndProcessPOWRPack(naddr: string) { + // 1. Initialize NDK + const ndk = new NDK({ + explicitRelayUrls: [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band' + ] + }); + await ndk.connect(); + + // 2. Decode the naddr + const decoded = nip19.decode(naddr); + if (decoded.type !== 'naddr') { + throw new Error('Invalid naddr format'); + } + + const { pubkey, kind, identifier } = decoded.data; + + // 3. Create filter to fetch the pack event + const packFilter: NDKFilter = { + kinds: [kind], + authors: [pubkey], + '#d': identifier ? [identifier] : undefined + }; + + // 4. Fetch the pack event + const packEvents = await ndk.fetchEvents(packFilter); + if (packEvents.size === 0) { + throw new Error('Pack not found'); + } + + const packEvent = Array.from(packEvents)[0]; + + // 5. Extract template and exercise references + const templateRefs: string[] = []; + const exerciseRefs: string[] = []; + + for (const tag of packEvent.tags) { + if (tag[0] === 'a') { + const addressPointer = tag[1]; + // Format is kind:pubkey:d-tag + if (addressPointer.startsWith('33402:')) { // Workout template + templateRefs.push(addressPointer); + } else if (addressPointer.startsWith('33401:')) { // Exercise + exerciseRefs.push(addressPointer); + } + } + } + + // 6. Fetch templates and exercises + const templates = await fetchReferencedEvents(ndk, templateRefs); + const exercises = await fetchReferencedEvents(ndk, exerciseRefs); + + // 7. Return the complete pack data + return { + pack: packEvent, + templates, + exercises + }; +} + +// Helper function to fetch events from address pointers +async function fetchReferencedEvents(ndk: NDK, addressPointers: string[]) { + const events: NDKEvent[] = []; + + for (const pointer of addressPointers) { + // Parse the pointer (kind:pubkey:d-tag) + const [kindStr, hexPubkey, dTag] = pointer.split(':'); + const kind = parseInt(kindStr); + + // Create a filter to find this specific event + const filter: NDKFilter = { + kinds: [kind], + authors: [hexPubkey] + }; + + if (dTag) { + filter['#d'] = [dTag]; + } + + // Fetch the events + const fetchedEvents = await ndk.fetchEvents(filter); + events.push(...Array.from(fetchedEvents)); + } + + return events; +} +``` + +## Creating naddr for Sharing Packs + +If you want to generate an naddr that can be shared to allow others to import your POWR Pack: + +```typescript +function createShareableNaddr(packEvent: NDKEvent) { + // Extract the d-tag (identifier) + const dTags = packEvent.getMatchingTags('d'); + const identifier = dTags[0]?.[1] || ''; + + // Create the naddr + const naddr = nip19.naddrEncode({ + pubkey: packEvent.pubkey, + kind: packEvent.kind, + identifier + }); + + return naddr; +} +``` + +## Best Practices for Working with NIP-19 Formats + +1. **Always validate decoded values**: Check that the decoded data is of the expected type and has the necessary properties. + +2. **Handle encoding/decoding errors**: These functions can throw exceptions if the input is malformed. + +3. **Normalize hex keys**: Convert to lowercase for consistency in filters and comparisons. + +4. **Check event kinds**: Verify that the decoded event kind matches what you expect (30004 for NIP-51 lists). + +5. **Use strong typing**: TypeScript's type system can help catch errors with NIP-19 data. + +The main challenge when working with naddr and other NIP-19 formats is correctly translating between the human-readable encoded forms and the internal hex representations needed for Nostr protocol operations. NDK's nip19 module abstracts this complexity for you, allowing you to focus on the core business logic of your application. \ No newline at end of file diff --git a/docs/design/Analysis/OlasSocialFeed.md b/docs/design/Analysis/OlasSocialFeed.md new file mode 100644 index 0000000..09790ce --- /dev/null +++ b/docs/design/Analysis/OlasSocialFeed.md @@ -0,0 +1,193 @@ +After analyzing Olas's social feed implementation, I can provide you with valuable insights for building your workout-focused social feed using Nostr. Here's a breakdown of their implementation and how you can adapt it for POWR: + +### Key Components of Olas's Feed Implementation + +1. **Feed Architecture** +- They use a main `Feed` component with two key parts: + - `useFeedEvents` hook for managing feed data + - `useFeedMonitor` hook for monitoring and updating feed entries + +2. **NDK Integration** +```typescript +import { + NDKEvent, + NDKFilter, + NDKKind, + NDKSubscription, + useNDK +} from "@nostr-dev-kit/ndk-mobile"; +``` + +3. **Event Filtering** +- They filter events based on specific kinds: +```typescript +switch (event.kind) { + case NDKKind.VerticalVideo: + case NDKKind.HorizontalVideo: + case 30018: + case 30402: + case NDKKind.Text: + case NDKKind.Media: + case NDKKind.Image: + return handleContentEvent(eventId, event); +``` + +### Implementing POWR's Workout Feed + +For your workout app, here's how you can adapt their implementation: + +1. **Event Kind Definition** +```typescript +// Define workout event kind +const WORKOUT_EVENT_KIND = 30311; // Choose an appropriate kind number for workouts +``` + +2. **Feed Filter Setup** +```typescript +const workoutFeedFilters: NDKFilter[] = [{ + kinds: [WORKOUT_EVENT_KIND], + // Add any additional filters like tags +}]; +``` + +3. **Feed Component** +```typescript +import { NDKEvent, NDKFilter, useNDK } from "@nostr-dev-kit/ndk-mobile"; + +export function WorkoutFeed() { + const { entries, newEntries, updateEntries } = useFeedEvents( + workoutFeedFilters, + { + subId: 'workout-feed', + filterFn: (entry) => { + // Add custom filtering for workout events + return true; + } + } + ); + + return ( + ( + + )} + estimatedItemSize={400} + /> + ); +} +``` + +4. **Useful NDK Tools to Leverage** +- **Subscription Management** +```typescript +const subscription = ndk.subscribe( + workoutFeedFilters, + { + groupable: false, + skipVerification: true, + subId: 'workout-feed' + } +); + +subscription.on("event", handleWorkoutEvent); +subscription.once('eose', handleEose); +``` + +- **Event Processing** +```typescript +const handleWorkoutEvent = (event: NDKEvent) => { + // Process workout specific data + const workout = { + id: event.id, + type: event.tagValue('workout-type'), + duration: event.tagValue('duration'), + // other workout specific fields + }; + + // Update feed + updateEntry(event.id, (entry) => ({ + ...entry, + event, + workout, + timestamp: event.created_at + })); +}; +``` + +5. **Feed Entry Type** +```typescript +type WorkoutFeedEntry = { + id: string; + event?: NDKEvent; + workout?: { + type: string; + duration: string; + // other workout metadata + }; + timestamp: number; +}; +``` + +### Key NDK Tools to Use + +1. **Event Subscription** +- `NDKSubscription` for real-time workout updates +- `NDKFilter` for filtering workout-specific events + +2. **Event Processing** +- `NDKEvent` for handling workout event data +- Event tags for workout metadata + +3. **Feed Management** +- `useFeedEvents` hook pattern for managing workout feed state +- Entry caching and update mechanisms + +### Best Practices from Olas's Implementation + +1. **Performance Optimization** +- Use of `FlashList` for efficient list rendering +- Implement entry caching +- Handle new entries efficiently + +2. **State Management** +- Track active entries +- Manage subscription lifecycle +- Handle feed updates appropriately + +3. **User Experience** +- Implement pull-to-refresh +- Show new entries notification +- Handle scrolling and viewing positions + +### Additional Recommendations for POWR + +1. **Workout-Specific Filters** +- Add filters for workout types +- Filter by duration, intensity, etc. +- Use workout-specific tags + +2. **Data Structure** +```typescript +// Workout event structure +const workoutEvent = { + kind: WORKOUT_EVENT_KIND, + tags: [ + ['workout-type', 'strength'], + ['duration', '45'], + ['exercises', JSON.stringify(exercises)], + // Additional metadata + ], + content: workoutDescription +}; +``` + +3. **Real-time Updates** +- Implement real-time workout progress updates +- Show active workouts in progress +- Enable social interactions during workouts + +This implementation will give you a solid foundation for building a workout-focused social feed using Nostr. The key is adapting Olas's efficient feed management system while customizing it for workout-specific content and interactions. \ No newline at end of file diff --git a/docs/design/Social/UpdatedPlan.md b/docs/design/Social/UpdatedPlan.md new file mode 100644 index 0000000..5e15eea --- /dev/null +++ b/docs/design/Social/UpdatedPlan.md @@ -0,0 +1,282 @@ +### 1. Implement a Centralized Feed Management System + +Based on both Olas and NDK's approaches: + +- **Create a `FeedEntry` type** with clear support for different event kinds and content types +- **Implement state management using references** following NDK's pattern of using `Map` and `Set` references for better performance +- **Add proper event deduplication** using NDK's seen events tracking mechanism + +### 2. Improve Event Subscription and Filtering + +From NDK's implementation: + +- **Utilize NDK's subscription options more effectively**: + ```typescript + const subscription = ndk.subscribe(filters, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + groupable: true, + skipVerification: false + }); + ``` + +- **Implement caching strategies** using NDK's caching mechanisms: + ```typescript + // Try cache first, then relays + const cacheStrategy = NDKSubscriptionCacheUsage.CACHE_FIRST; + ``` + +- **Use subscription grouping** to reduce relay connections for similar queries: + ```typescript + // Group similar subscriptions with a delay + const groupableDelay = 100; // ms + ``` + +### 3. Enhance Contact List Fetching + +NDK offers more sophisticated contact list handling: + +- **Use NDK's contact list event fetching** with explicit validation: + ```typescript + // Direct method to get follows for a user + async followSet(opts?: NDKSubscriptionOptions): Promise> { + const follows = await this.follows(opts); + return new Set(Array.from(follows).map((f) => f.pubkey)); + } + ``` + +- **Implement loading states and retry logic** using NDK's pattern: + ```typescript + // Track loading state with cleanup on unmount + let isMounted = true; + // ... + return () => { isMounted = false; } + ``` + +### 4. Enhanced Subscription Management + +From NDK's subscription implementation: + +- **Proper lifecycle management** for subscriptions: + ```typescript + // Keep track of subscriptions for cleanup + const subscriptionRef = useRef(null); + + // Clean up subscription on component unmount + useEffect(() => { + return () => { + if (subscriptionRef.current) { + subscriptionRef.current.stop(); + } + }; + }, []); + ``` + +- **Handle relay connection state** more effectively: + ```typescript + // Monitor relay connections + ndk.pool.on('relay:connect', (relay: NDKRelay) => { + console.log(`Relay connected: ${relay.url}`); + }); + ``` + +### 5. Optimize Event Processing Pipeline + +Based on NDK's efficient event handling: + +- **Implement event processing with proper validation**: + ```typescript + // Only process events that pass validation + if (!this.skipValidation) { + if (!ndkEvent.isValid) { + return; + } + } + ``` + +- **Use NDK's event queuing and batch processing**: + ```typescript + // Batch event processing for better performance + const updateEntries = (reason: string) => { + const newSlice = entriesFromIds(newEntriesRef.current); + // ... process in batch rather than individually + } + ``` + +- **Implement EOSE (End of Stored Events) handling** more effectively: + ```typescript + // Handle EOSE with improved timing + subscription.on('eose', () => { + if (isMounted) { + setLoading(false); + setEose(true); + // Process any accumulated events after EOSE + updateEntries('eose'); + } + }); + ``` + +### 6. Implement Progressive Loading + +From NDK's subscription approach: + +- **Use cache-first loading with fallback to relays**: + ```typescript + // Try cache first for immediate feedback + if (ndk.cacheAdapter) { + const cachedEvents = await ndk.cacheAdapter.query(subscription); + if (cachedEvents.length > 0) { + // Process cached events immediately + processCachedEvents(cachedEvents); + } + } + ``` + +- **Implement pagination with proper thresholds**: + ```typescript + // Only fetch more when needed + if (hasMore && !loading && !refreshing) { + loadMore(oldestTimestamp - 1); + } + ``` + +## Key Architectural Improvements + +1. **More Robust Feed State Management**: + - Use `useRef` for maintaining state references that don't trigger re-renders + - Implement batch updates for feed state changes + - Use proper cleanup and event deduplication + +2. **Enhanced Subscription Management**: + - Use `NDKSubscription` with appropriate options + - Implement proper grouping and caching strategies + - Handle subscription lifecycle with proper cleanup + +3. **Contact List Reliability**: + - Use multiple approaches to fetch and validate contacts + - Implement caching and retry logic for reliable contact detection + - Handle edge cases (like self-follows) appropriately + +4. **Event Processing Optimization**: + - Implement more efficient event filtering and processing + - Use batch processing for events after EOSE + - Add proper validation and verification + +5. **Performance Enhancements**: + - Use NDK's optimization patterns like subscription grouping + - Implement proper memory management for large event sets + - Use efficient data structures (Map/Set) for event tracking + +## Implementation Strategy + +1. **Start with rewriting the subscription layer** using NDK's patterns +2. **Then enhance the contact list fetching** with multiple reliable approaches +3. **Next, implement improved feed state management** with efficient data structures +4. **Finally, optimize the UI components** for better rendering performance + +This updated plan combines the best practices from both Olas's specialized feed implementation and NDK's efficient subscription management, providing a solid foundation for building a fast, reliable, and focused social feed for workout-related content in the POWR app. + +Let me review your current subscription and feed code, and identify which files we'll need to delete, update, or create to implement the improved architecture. + +## Current Code Structure + +From what I can see in the shared code, you have: + +1. **useFeedEvents.ts** - Core hook for handling feed events +2. **useFeedHooks.ts** - Specialized feed hooks (Following, POWR, Global) +3. **useFeedMonitor.ts** - For monitoring feed state and auto-refresh +4. **powr.tsx** - The POWR tab screen component +5. **following.tsx** - The Following tab screen component +6. **global.tsx** - The Global tab screen component +7. **EnhancedSocialPost.tsx** - Component for rendering feed posts +8. **socialFeedService.ts** - Service for interacting with Nostr + +## Plan for Implementation + +### Files to Update (Modify): + +1. **useFeedEvents.ts** - Update with NDK's subscription patterns and better event processing + - Improve subscription lifecycle management + - Implement better event deduplication + - Add batch processing and performance optimizations + +2. **useFeedHooks.ts** - Enhance contact list fetching and filter creation + - Implement multiple approaches for contact list fetching + - Improve filter creation for better relevance + - Remove hardcoded fallbacks in favor of more reliable mechanisms + +3. **useFeedMonitor.ts** - Minor updates for integration with new feed structure + - Enhance refresh mechanisms + - Improve state tracking + +4. **following.tsx** - Update to use the new feed architecture + - Remove debug code once implementation is stable + - Improve UI for feed state feedback + +5. **powr.tsx** - Update to fix infinite loop issues + - Improve component lifecycle management + - Enhance performance + +6. **global.tsx** - Update for consistency with other feed implementations + - Ensure consistent behavior across all feed tabs + +7. **socialFeedService.ts** - Enhance with better relay interaction + - Improve subscription creation + - Add better event filtering + +### Files to Create: + +1. **useFeedState.ts** - New hook for centralized feed state management + ```typescript + // Manage feed entries state with efficient updates + export function useFeedState() { + // Implementation here + } + ``` + +2. **useFeedSubscription.ts** - New hook for subscription management + ```typescript + // Handle NDK subscriptions with proper lifecycle + export function useFeedSubscription(filters: NDKFilter[]) { + // Implementation here + } + ``` + +3. **types/feed.ts** - Enhanced type definitions for feed entries + ```typescript + // More comprehensive feed entry types + export interface FeedEntry { + // Enhanced type definition + } + ``` + +4. **utils/feedUtils.ts** - Utility functions for feed operations + ```typescript + // Feed-related utility functions + export function deduplicateEvents() { + // Implementation + } + ``` + +5. **components/feed/FeedList.tsx** - Shared component for feed rendering + ```typescript + // Reusable feed list component with virtualization + export function FeedList({ entries, onItemPress }) { + // Implementation + } + ``` + +### Files to Delete: + +None of the files need to be deleted entirely. Instead, we'll refactor and enhance the existing codebase to implement the new architecture. + +## Implementation Order + +1. First, create the new type definitions in **types/feed.ts** +2. Then, implement the new hooks in **useFeedSubscription.ts** and **useFeedState.ts** +3. Update **useFeedEvents.ts** and **useFeedHooks.ts** with improved implementations +4. Create utility functions in **utils/feedUtils.ts** +5. Implement the shared component in **components/feed/FeedList.tsx** +6. Finally, update the screen components to use the new architecture + +This approach allows us to gradually refactor the codebase while maintaining functionality throughout the process. Each step builds on the previous one, ultimately resulting in a more robust and efficient feed implementation. \ No newline at end of file