diff --git a/docs/nostr-client-overview.md b/docs/nostr-client-overview.md index 7628d1fd..8e7f829a 100644 --- a/docs/nostr-client-overview.md +++ b/docs/nostr-client-overview.md @@ -119,3 +119,26 @@ The client abstracts the signing mechanism, allowing the user to switch between 3. **Local (NIP-01)**: Uses a local private key (nsec) stored in `sessionStorage` (encrypted). The `activeSigner` object is normalized to expose a standard interface (`signEvent`, `nip04Encrypt`, `nip44Encrypt`), regardless of the underlying implementation. + +## Implicit Invariants + +Maintaining these invariants is critical for the stability of the application: + +1. **Active Map Consistency**: `activeMap` must *only* contain the single latest version of a video series. + * *Violation*: If two versions of the same video exist in `activeMap`, the UI will duplicate the card or flicker. +2. **Tombstone Authority**: Any event, regardless of its source (relay or cache), MUST be discarded if its `created_at` is older than the timestamp in `tombstones.get(activeKey)`. + * *Violation*: Deleted videos will reappear ("zombie events"). +3. **History Integrity**: All versions in a history chain must share the same `videoRootId` (V3) or `d` tag (V2). + * *Violation*: The history log will be fragmented or incomplete. + +## Common Pitfalls + +1. **Direct State Mutation**: + * *Don't*: `client.activeMap.set(key, video)` manually. + * *Do*: Use `_processAndCacheEvent(evt)` or let `VideoEventBuffer` handle it. The client needs to run deduplication and tombstone checks. +2. **Stable ID Assumption**: + * *Don't*: Rely on `event.id` as a permanent reference to a video. + * *Do*: Use `videoRootId` or the `d` tag. `event.id` changes every time the user edits the title/thumbnail. +3. **Sync vs Async State**: + * *Don't*: Expect `client.allEvents` to be populated immediately after `init()`. + * *Do*: Use `restoreLocalData()` (awaited in `init`) which loads the cache, but network updates arrive asynchronously via subscriptions. diff --git a/js/nostr/client.js b/js/nostr/client.js index b1d61a6a..9f8d43d3 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -755,6 +755,9 @@ export class NostrClient { * Restores application state from the best available local cache. * Delegates to PersistenceManager. * + * This method ensures the UI can render immediately (Stale-While-Revalidate) + * by loading `activeMap`, `allEvents`, and `tombstones` from IndexedDB. + * * @returns {Promise} True if data was successfully restored. */ async restoreLocalData() { @@ -1065,6 +1068,11 @@ export class NostrClient { return this.connectionManager.ensurePool(); } + /** + * Delegates to `ConnectionManager` to connect to all configured relays. + * + * @returns {Promise>} Connection results for each relay. + */ async connectToRelays() { return this.connectionManager.connectToRelays(); } @@ -1105,17 +1113,6 @@ export class NostrClient { return this.connectionManager.getHealthyRelays(candidates); } - /** - * Logs out the current user and clears all session state. - * - * **Cleanup:** - * - Clears `this.pubkey` and notifies the global signer registry. - * - Wipes session actor (ephemeral keys). - * - Disconnects NIP-46 remote signer (if active). - * - Clears Watch History cache. - * - Resets permissions cache. - */ - getDmDecryptCacheLimit() { return DM_DECRYPT_CACHE_LIMIT; } @@ -2300,6 +2297,8 @@ export class NostrClient { const signerCapabilities = resolveSignerCapabilities(signer); const useNip17 = Boolean(resolvedOptions.useNip17); + // Heuristic: If attachments are present, we MUST use NIP-17 because + // NIP-04 does not support rich metadata or multiple recipients securely. if (hasAttachments && !useNip17) { return { ok: false, error: "attachments-unsupported" }; } @@ -2331,6 +2330,8 @@ export class NostrClient { }; if (useNip17) { + // Protocol: NIP-17 (Sealed Rumor) + // Requires NIP-44 encryption and NIP-65 relay discovery. if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") { return { ok: false, error: "nip44-unsupported" }; } @@ -2369,6 +2370,8 @@ export class NostrClient { }); } + // Protocol: NIP-04 (Legacy) + // Fallback for older clients. Simple ECDH encryption. return this._sendNip04DirectMessage({ signer, signingAdapter, @@ -2638,6 +2641,16 @@ export class NostrClient { return this.signerManager.clearStoredSessionActor(); } + /** + * Logs out the current user and clears all session state. + * + * **Cleanup:** + * - Clears `this.pubkey` and notifies the global signer registry. + * - Wipes session actor (ephemeral keys). + * - Disconnects NIP-46 remote signer (if active). + * - Clears Watch History cache. + * - Resets permissions cache. + */ logout() { return this.signerManager.logout(); } @@ -2853,6 +2866,16 @@ export class NostrClient { return { matchingEvents, inferredRoot }; } + /** + * Helper to perform a soft delete (revert) on a set of video events. + * Publishes a new version with `deleted: true` for each unique d-tag found. + * + * @param {Map} matchingEvents - The events to delete. + * @param {string} inferredRoot - The root ID of the series. + * @param {object} targetVideo - The main video object being deleted. + * @param {string} pubkey - The active user's pubkey. + * @returns {Promise<{revertSummaries: object[], revertEvents: object[]}>} + */ async _softDeleteVersions(matchingEvents, inferredRoot, targetVideo, pubkey) { const revertSummaries = []; const revertEvents = []; @@ -2935,6 +2958,17 @@ export class NostrClient { return { revertSummaries, revertEvents }; } + /** + * Helper to perform a hard delete (Kind 5) on a set of video events. + * Collects all event IDs (`e` tags) and addresses (`a` tags) and publishes a deletion request. + * + * @param {Map} matchingEvents - The original events to delete. + * @param {object[]} revertEvents - The soft-delete events created in the previous step (also deleted). + * @param {object} targetVideo - The main video object. + * @param {string} inferredRoot - The root ID. + * @param {string} pubkey - The active user's pubkey. + * @returns {Promise} Summary of deletion results. + */ async _hardDeleteVersions(matchingEvents, revertEvents, targetVideo, inferredRoot, pubkey) { const eventIdSet = new Set(); const addressPointerSet = new Set(); @@ -3246,12 +3280,6 @@ export class NostrClient { * @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop listening. */ subscribeVideos(onVideo, options = {}) { - // Explanation: - // This method handles the primary video feed. It uses a buffering strategy to - // prevent UI thrashing when thousands of events arrive at once (e.g. initial load). - // Incoming events are pushed to `eventBuffer` and processed in batches - // via `flushEventBuffer` which is debounced. - const { since, until, limit } = options; const latestCachedCreatedAt = this.getLatestCachedCreatedAt(); @@ -3318,6 +3346,12 @@ export class NostrClient { return sub; } + /** + * Delegates to `processNip71EventsHelper` to parse and cache NIP-71 metadata from events. + * + * @param {import("nostr-tools").Event[]} events - The raw events to process. + * @param {Map} [pointerMap] - Optional optimization map. + */ processNip71Events(events, pointerMap = null) { processNip71EventsHelper(events, { nip71Cache: this.nip71Cache, @@ -3325,12 +3359,24 @@ export class NostrClient { }); } + /** + * Delegates to `mergeNip71MetadataIntoVideoHelper` to apply cached NIP-71 metadata to a video. + * + * @param {object} video - The video object to mutate. + * @returns {object} The mutated video object. + */ mergeNip71MetadataIntoVideo(video) { return mergeNip71MetadataIntoVideoHelper(video, { nip71Cache: this.nip71Cache, }); } + /** + * Delegates to `populateNip71MetadataForVideosHelper` to fetch missing NIP-71 metadata. + * + * @param {object[]} videos - The list of video objects to hydrate. + * @returns {Promise} + */ async populateNip71MetadataForVideos(videos = []) { if (!Array.isArray(videos) || !videos.length) { return; @@ -3804,17 +3850,6 @@ export class NostrClient { * @returns {Promise} A promise resolving to an array of Video objects, sorted from newest to oldest. */ async hydrateVideoHistory(video) { - // Explanation: - // This method reconstructs the edit history of a video series. - // Since Nostr events are immutable, "editing" creates a new event. - // We link these events together using: - // 1. `videoRootId` (V3 canonical ID) - The primary key for the series. - // 2. `d` tag (NIP-33 addressability) - The secondary key for lookups. - // 3. Fallback: Matching ID for legacy V1/V2 posts (where ID was the root). - // - // The method first checks local cache (`allEvents`), and if data is sparse, - // queries relays for the specific `d` tag to find missing links. - if (!video || typeof video !== "object") { return []; } @@ -3827,6 +3862,7 @@ export class NostrClient { let localMatches = this.getLocalVideoHistory(video); + // Ensure we have the "Root" event (genesis block) even if we didn't start with it. const ensureRootPresence = async () => { const normalizedRoot = targetRoot && typeof targetRoot === "string" ? targetRoot @@ -3872,6 +3908,8 @@ export class NostrClient { await ensureRootPresence(); + // Heuristic: If we only have 1 version (the current one) and it's addressable (d-tag), + // we likely missed history. Query relays for all events with the same d-tag to fill gaps. const shouldFetchFromRelays = localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag; diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index 1941b4a3..f118255d 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -10,6 +10,10 @@ import { devLogger, userLogger } from "../utils/logger.js"; import { convertEventToVideo } from "./nip71.js"; export class VideoEventBuffer { + /** + * @param {import("./client.js").NostrClient} client - The parent client instance (for state access). + * @param {function(object[]): void} onVideo - The UI callback to fire with batched updates. + */ constructor(client, onVideo) { this.client = client; this.onVideo = onVideo; @@ -19,11 +23,21 @@ export class VideoEventBuffer { this.FLUSH_DEBOUNCE_MS = 75; } + /** + * Pushes a raw event into the buffer and schedules a debounced flush. + * @param {import("nostr-tools").Event} event - The incoming Nostr event. + */ push(event) { this.buffer.push(event); this.scheduleFlush(false); } + /** + * Schedules processing of the buffer. + * Uses a debounce timer to wait for the stream to pause (pressure valve). + * + * @param {boolean} [immediate=false] - If true, flushes immediately (e.g. on EOSE). + */ scheduleFlush(immediate = false) { if (this.flushTimerId) { if (!immediate) { @@ -44,6 +58,17 @@ export class VideoEventBuffer { }, this.FLUSH_DEBOUNCE_MS); } + /** + * Processes all buffered events in a single batch. + * + * **Steps:** + * 1. Validates and converts raw events to Video objects. + * 2. Checks for tombstones (deleted videos). + * 3. Updates the client's `activeMap` (Latest Wins). + * 4. Triggers the UI callback *once*. + * 5. Triggers background hydration of NIP-71 metadata. + * 6. Persists state to IndexedDB. + */ flush() { if (!this.buffer.length) { return; @@ -137,6 +162,10 @@ export class VideoEventBuffer { this.client.saveLocalData("subscribeVideos:flush"); } + /** + * Called when the subscription reaches End-of-Stored-Events (EOSE). + * Triggers an immediate flush to ensure the UI is fully up-to-date. + */ handleEose() { if (isDevMode && this.invalidEvents.length > 0) { userLogger.warn( @@ -150,6 +179,10 @@ export class VideoEventBuffer { this.scheduleFlush(true); } + /** + * Cleans up timers and flushes any remaining events. + * Call when unsubscribing. + */ cleanup() { if (this.flushTimerId) { clearTimeout(this.flushTimerId);