Merge pull request #2388 from PR0M3TH3AN/ai/doc-nostr-client-20240523-7534812537897157259

chore(ai): document js/nostr/client.js complex logic
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-12 10:42:32 -05:00
commit 1dc292e4ae
3 files changed
+122 -28

No files matched your search

+23
View File
@@ -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.
+66 -28
View File
@@ -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<boolean>} 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<Array<{url: string, success: boolean}>>} 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<object[]>} 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<void>}
*/
async populateNip71MetadataForVideos(videos = []) {
if (!Array.isArray(videos) || !videos.length) {
return;
@@ -3804,17 +3850,6 @@ export class NostrClient {
* @returns {Promise<object[]>} 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;
+33
View File
@@ -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);