mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
chore(ai): doc js/nostr/client.js (agent)
Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
11df8f79a3
commit
8df741f525
2 files changed
+259
-146
No files matched your search
@@ -57,6 +57,51 @@ graph TD
|
||||
ActiveMap --> IDB[(IndexedDB Cache)]
|
||||
```
|
||||
|
||||
## Publishing Lifecycle
|
||||
|
||||
Creating and editing content involves a coordinated sequence of events to satisfy multiple NIPs:
|
||||
|
||||
1. **Preparation**: The client normalizes the input (Title, Magnet, URL) and generates a unique `d` tag (NIP-33) and `videoRootId` (V3 Schema).
|
||||
2. **Signing**: The payload is signed by the active signer (Extension, NIP-46, or Local).
|
||||
3. **Primary Publish**: The main Kind 30078 event is broadcast to relays.
|
||||
4. **Mirroring (NIP-94)**: If a hosted URL is present, a Kind 1063 "File Header" event is *also* published. This ensures compatibility with generic file-sharing clients.
|
||||
5. **Tagging (NIP-71)**: If categories/tags are used, a Kind 22 event is published referencing the video. This keeps the main event payload lightweight.
|
||||
|
||||
## Deletion & Versioning
|
||||
|
||||
The client implements a robust deletion strategy to handle the immutable nature of Nostr events:
|
||||
|
||||
### Versioning
|
||||
Edits are new events that share the same `d` tag and `videoRootId`. The client's `hydrateVideoHistory` method reconstructs the timeline by fetching all events with the matching `d` tag and sorting by `created_at`.
|
||||
|
||||
### Soft Delete (Revert)
|
||||
The `revertVideo` method publishes a new version of the video where the content is replaced with a tombstone marker (`deleted: true`). This hides the video from the feed while preserving the history chain.
|
||||
|
||||
### Hard Delete (Nuclear Option)
|
||||
The `deleteAllVersions` method attempts to physically remove data from relays (NIP-09):
|
||||
1. **Hydration**: Fetches the full history to identify every Event ID (`e` tags) and Address (`a` tags).
|
||||
2. **Tombstone**: Publishes a "Soft Delete" event to ensure immediate hiding.
|
||||
3. **Pruning**: Publishes a Kind 5 (Deletion) event listing *all* historical IDs. Relays respecting NIP-09 will delete the data.
|
||||
4. **Local Guard**: A local tombstone is recorded to prevent the client from re-displaying cached versions.
|
||||
|
||||
## Direct Messages
|
||||
|
||||
The client supports a dual-stack DM system to ensure compatibility and privacy:
|
||||
|
||||
* **NIP-04 (Legacy)**:
|
||||
* Uses simple ECDH encryption (Kind 4).
|
||||
* **Pros**: Supported by almost all clients.
|
||||
* **Cons**: Leaks metadata (who is talking to whom).
|
||||
* **Usage**: Default fallback for text messages.
|
||||
|
||||
* **NIP-17 (Private / Gift Wrap)**:
|
||||
* Uses a "Russian Doll" onion encryption scheme (Kind 1059 -> Kind 13 -> Kind 14).
|
||||
* **Pros**: Hides sender, receiver, and content from relays.
|
||||
* **Cons**: Higher complexity and bandwidth.
|
||||
* **Usage**: Required for attachments (images/videos) and strictly private conversations.
|
||||
|
||||
The `sendDirectMessage` method automatically selects the appropriate protocol based on the content (e.g., attachments force NIP-17).
|
||||
|
||||
## Caching Strategy (`EventsCacheStore`)
|
||||
|
||||
To provide an "app-like" experience, the client persists its state to IndexedDB (`bitvid-events-cache`).
|
||||
|
||||
+214
-146
@@ -1380,6 +1380,26 @@ export class NostrClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and decrypts Direct Messages (DMs) for the active user.
|
||||
* Handles both NIP-04 (Legacy) and NIP-17 (Gift Wrap) events transparently.
|
||||
*
|
||||
* **Process:**
|
||||
* 1. **Filter Construction**: Builds filters for Kind 4 and Kind 1059 events.
|
||||
* 2. **Relay Query**: Fetches events from read relays (or specified candidates).
|
||||
* 3. **Decryption**: Attempts to decrypt each event using available strategies:
|
||||
* - NIP-04: Uses signer's `nip04.decrypt`.
|
||||
* - NIP-44/17: Uses signer's `nip44.decrypt` (or private key if local).
|
||||
* 4. **Caching**: Decrypted results are cached in memory (`dmDecryptCache`) to avoid repeated decryption overhead.
|
||||
*
|
||||
* @param {string|null} [actorPubkeyInput] - The public key of the user viewing the DMs (defaults to logged-in user).
|
||||
* @param {object} [options] - Filter options.
|
||||
* @param {number} [options.limit] - Max events to fetch.
|
||||
* @param {number} [options.since] - Start timestamp.
|
||||
* @param {string[]} [options.relays] - Custom relay list.
|
||||
* @param {number} [options.decryptLimit] - Max number of messages to attempt decrypting (optimization).
|
||||
* @returns {Promise<object[]>} A list of decrypted message objects (text, timestamp, sender, etc.).
|
||||
*/
|
||||
async listDirectMessages(actorPubkeyInput = null, options = {}) {
|
||||
if (!this.pool) {
|
||||
await this.ensurePool();
|
||||
@@ -1622,20 +1642,32 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Direct Message (DM) to a target user.
|
||||
* Sends a private Direct Message (DM) to a target user.
|
||||
* Supports both legacy NIP-04 (simple encryption) and modern NIP-17 (sealed gift wraps).
|
||||
*
|
||||
* Supports:
|
||||
* - **Legacy (NIP-04)**: Simple base64 ciphertext (deprecated but widely supported).
|
||||
* - **Wrapped (NIP-17)**: Sealed rumors inside gift wraps (private, metadata-leaking free).
|
||||
* - **Attachments**: Handling file attachments via NIP-17 or fallback text.
|
||||
* **Protocol Selection:**
|
||||
* - Defaults to NIP-04 for maximum compatibility unless `options.useNip17` is explicitly true.
|
||||
* - NIP-17 is required for sending rich attachments (images/videos) privately.
|
||||
*
|
||||
* @param {string} targetNpub - The recipient's npub.
|
||||
* @param {string} message - The text content to send.
|
||||
* @param {string|null} [actorPubkeyOverride] - Optional sender pubkey override.
|
||||
* **NIP-17 Flow (Sealed Rumor):**
|
||||
* 1. **Rumor**: Create an unsigned Kind 14 (Chat Message) or Kind 1063 (Attachment).
|
||||
* 2. **Seal**: Sign the rumor, encrypt it to the recipient, and wrap it in a Kind 13 (Seal).
|
||||
* 3. **Wrap**: Generate an ephemeral keypair, encrypt the seal to the recipient, and wrap in a Kind 1059 (Gift Wrap).
|
||||
* This creates an "onion" where the outer layer leaks no metadata about the sender or content.
|
||||
* 4. **Publish**: Send to the recipient's inbox relays (discovered via NIP-65).
|
||||
*
|
||||
* **NIP-04 Flow (Legacy):**
|
||||
* 1. **Encrypt**: Encrypt the message content using the shared secret (sender priv + recipient pub).
|
||||
* 2. **Publish**: Send a Kind 4 event with the ciphertext.
|
||||
* *Privacy Warning*: NIP-04 leaks metadata (sender, receiver, timestamp) to relays.
|
||||
*
|
||||
* @param {string} targetNpub - The recipient's public key (npub or hex).
|
||||
* @param {string} message - The plaintext message to send.
|
||||
* @param {string|null} [actorPubkeyOverride] - Optional sender pubkey (defaults to logged-in user).
|
||||
* @param {object} [options] - Configuration options.
|
||||
* @param {boolean} [options.useNip17=false] - Whether to use the new NIP-17 protocol.
|
||||
* @param {Array} [options.attachments] - File attachments (NIP-17 only).
|
||||
* @returns {Promise<{ok: boolean, error?: string, details?: any}>} Result of the operation.
|
||||
* @param {boolean} [options.useNip17=false] - If true, uses the NIP-17 sealing protocol.
|
||||
* @param {Array<{url: string, type: string}>} [options.attachments] - File attachments (requires NIP-17).
|
||||
* @returns {Promise<{ok: boolean, error?: string, details?: any}>} Result object. `ok: true` on success.
|
||||
*/
|
||||
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) {
|
||||
let resolvedOptions = options;
|
||||
@@ -2354,22 +2386,26 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new video event (Kind 30078) to the network.
|
||||
* Publishes a fresh Video Series (Kind 30078) to the network.
|
||||
* This is the entry point for creating new content.
|
||||
*
|
||||
* **Payload Construction:**
|
||||
* - Creates a V3 video note with `magnet`, `url` (WebSeed), and core metadata.
|
||||
* - Generates a unique `d` tag and `videoRootId` for this new series unless
|
||||
* an explicit identifier is provided in the upload payload.
|
||||
* **Protocol (V3):**
|
||||
* - The main event is a Kind 30078 (Application-Specific Data) with `t=video`.
|
||||
* - It includes both a WebTorrent `magnet` and a direct `url` (if hosted).
|
||||
* - A unique `d` tag is generated to make the series addressable (NIP-33).
|
||||
* - A `videoRootId` is assigned to track future edits.
|
||||
*
|
||||
* **Side Effects (in order):**
|
||||
* 1. **Primary Event**: Signs and publishes the Kind 30078 Video Note.
|
||||
* 2. **NIP-94 Mirror**: If a hosted `url` is provided, publishes a Kind 1063 File Header event (for clients that only support NIP-94).
|
||||
* 3. **NIP-71 Metadata**: If categories/tags are present, publishes a Kind 22 Video Wrapper event linked to the primary event.
|
||||
* **Side Effects (Mirroring):**
|
||||
* 1. **Primary Event**: Signs and broadcasts the Kind 30078 event.
|
||||
* 2. **NIP-94 Mirror**: If a `url` exists, we *also* publish a Kind 1063 (File Header).
|
||||
* This allows clients that only support NIP-94 (like file browsers) to see the video.
|
||||
* 3. **NIP-71 Metadata**: If the user added categories/tags, we publish a Kind 22 event
|
||||
* referencing the main video. This keeps the main event lightweight.
|
||||
*
|
||||
* @param {object} videoPayload - The video metadata, form data, and NIP-71 attributes.
|
||||
* @param {string} pubkey - The public key of the publisher.
|
||||
* @returns {Promise<import("nostr-tools").Event>} The signed and published Kind 30078 event.
|
||||
* @throws {Error} If not logged in or if the primary publish fails.
|
||||
* @param {object} videoPayload - The normalized form data (title, magnet, thumbnail, etc.).
|
||||
* @param {string} pubkey - The active user's public key.
|
||||
* @returns {Promise<import("nostr-tools").Event>} The signed Kind 30078 event.
|
||||
* @throws {Error} If signing fails or the primary event is rejected by all relays.
|
||||
*/
|
||||
async publishVideo(videoPayload, pubkey) {
|
||||
const context = await prepareVideoPublishPayload(videoPayload, pubkey);
|
||||
@@ -2400,19 +2436,88 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing video by publishing a new version (Kind 30078).
|
||||
* Publishes a *new version* of an existing video series.
|
||||
* Nostr events are immutable, so "editing" means creating a newer event that supersedes the old one.
|
||||
*
|
||||
* **Mechanism:**
|
||||
* - Reuses the original `d` tag to ensure NIP-33 addressability (clients see the edit as the "latest" version).
|
||||
* - Preserves the `videoRootId` to maintain the history chain.
|
||||
* - Enforces ownership (pubkey match).
|
||||
* **Mechanism (NIP-33 Replacement):**
|
||||
* 1. **Address Retention**: We reuse the original `d` tag. Relays will now serve *this* event
|
||||
* instead of the old one when queried by address.
|
||||
* 2. **Root Preservation**: We copy the `videoRootId` from the original event. This links the
|
||||
* new event to the series history.
|
||||
* 3. **Content Update**: The new payload (title, magnet, etc.) replaces the old content.
|
||||
*
|
||||
* @param {object} originalEventStub - The original video event (must have `id`).
|
||||
* @param {object} updatedData - The new metadata to apply (title, magnet, NIP-71, etc.).
|
||||
* @param {string} userPubkey - The public key of the editor (must match owner).
|
||||
* @returns {Promise<import("nostr-tools").Event>} The signed and published edit event.
|
||||
* @throws {Error} If permission denied, ownership mismatch, or publish failure.
|
||||
* **Constraints:**
|
||||
* - The `userPubkey` must match the original event's author (only the creator can edit).
|
||||
* - The original event must be V2 or newer (legacy V1 migration is not supported here).
|
||||
*
|
||||
* @param {object} originalEventStub - The video event being edited (requires `id` and `d` tag).
|
||||
* @param {object} updatedData - The modified fields (title, thumbnail, etc.).
|
||||
* @param {string} userPubkey - The active user's public key.
|
||||
* @returns {Promise<import("nostr-tools").Event>} The newly published event.
|
||||
* @throws {Error} If the user is not the owner or if the original event is too old.
|
||||
*/
|
||||
async editVideo(originalEventStub, updatedData, userPubkey) {
|
||||
if (!userPubkey) {
|
||||
throw new Error("Not logged in to edit.");
|
||||
}
|
||||
|
||||
// NOTE: Keep the Upload, Edit, and Revert flows synchronized when
|
||||
// adjusting validation or persisted fields.
|
||||
// Convert the provided pubkey to lowercase
|
||||
const userPubkeyLower = userPubkey.toLowerCase();
|
||||
|
||||
// Use getEventById to fetch the full original event details
|
||||
const baseEvent = await this.getEventById(originalEventStub.id);
|
||||
if (!baseEvent) {
|
||||
throw new Error("Could not retrieve the original event to edit.");
|
||||
}
|
||||
|
||||
// Check that the original event is version 2 or higher
|
||||
if (baseEvent.version < 2) {
|
||||
throw new Error(
|
||||
"This video is not in the supported version for editing."
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check (compare lowercase hex public keys)
|
||||
if (
|
||||
!baseEvent.pubkey ||
|
||||
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
|
||||
) {
|
||||
throw new Error("You do not own this video (pubkey mismatch).");
|
||||
}
|
||||
|
||||
const context = prepareVideoEditPayload({
|
||||
baseEvent,
|
||||
originalEventStub,
|
||||
updatedData,
|
||||
userPubkey,
|
||||
resolveEventDTag: (evt, stub) => this.resolveEventDTag(evt, stub),
|
||||
});
|
||||
|
||||
devLogger.log("Creating edited event with root ID:", context.videoRootId);
|
||||
devLogger.log("Event content:", context.event.content);
|
||||
|
||||
await this.ensureActiveSignerForPubkey(userPubkeyLower);
|
||||
|
||||
try {
|
||||
const { signedEvent } = await this.signAndPublishEvent(context.event, {
|
||||
context: "edited video note",
|
||||
logName: "Edited video note",
|
||||
devLogLabel: "edited video note",
|
||||
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
|
||||
});
|
||||
|
||||
await handlePublishNip94(this, signedEvent, context);
|
||||
await handlePublishNip71(this, signedEvent, context);
|
||||
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
userLogger.error("Edit failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureActiveSignerForPubkey(pubkey) {
|
||||
return this.signerManager.ensureActiveSignerForPubkey(pubkey);
|
||||
}
|
||||
@@ -2494,76 +2599,19 @@ export class NostrClient {
|
||||
return this.signerManager.logout();
|
||||
}
|
||||
|
||||
async editVideo(originalEventStub, updatedData, userPubkey) {
|
||||
if (!userPubkey) {
|
||||
throw new Error("Not logged in to edit.");
|
||||
}
|
||||
|
||||
// NOTE: Keep the Upload, Edit, and Revert flows synchronized when
|
||||
// adjusting validation or persisted fields.
|
||||
// Convert the provided pubkey to lowercase
|
||||
const userPubkeyLower = userPubkey.toLowerCase();
|
||||
|
||||
// Use getEventById to fetch the full original event details
|
||||
const baseEvent = await this.getEventById(originalEventStub.id);
|
||||
if (!baseEvent) {
|
||||
throw new Error("Could not retrieve the original event to edit.");
|
||||
}
|
||||
|
||||
// Check that the original event is version 2 or higher
|
||||
if (baseEvent.version < 2) {
|
||||
throw new Error(
|
||||
"This video is not in the supported version for editing."
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check (compare lowercase hex public keys)
|
||||
if (
|
||||
!baseEvent.pubkey ||
|
||||
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
|
||||
) {
|
||||
throw new Error("You do not own this video (pubkey mismatch).");
|
||||
}
|
||||
|
||||
const context = prepareVideoEditPayload({
|
||||
baseEvent,
|
||||
originalEventStub,
|
||||
updatedData,
|
||||
userPubkey,
|
||||
resolveEventDTag: (evt, stub) => this.resolveEventDTag(evt, stub),
|
||||
});
|
||||
|
||||
devLogger.log("Creating edited event with root ID:", context.videoRootId);
|
||||
devLogger.log("Event content:", context.event.content);
|
||||
|
||||
await this.ensureActiveSignerForPubkey(userPubkeyLower);
|
||||
|
||||
try {
|
||||
const { signedEvent } = await this.signAndPublishEvent(context.event, {
|
||||
context: "edited video note",
|
||||
logName: "Edited video note",
|
||||
devLogLabel: "edited video note",
|
||||
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
|
||||
});
|
||||
|
||||
await handlePublishNip94(this, signedEvent, context);
|
||||
await handlePublishNip71(this, signedEvent, context);
|
||||
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
userLogger.error("Edit failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts a video (soft delete).
|
||||
* Publishes a new version with the same `d` tag but `deleted: true`.
|
||||
* The content is replaced with a placeholder.
|
||||
* Performs a "Soft Delete" by publishing a new version marked as deleted.
|
||||
*
|
||||
* @param {object} originalEvent - The video event to revert.
|
||||
* @param {string} pubkey - The public key of the owner.
|
||||
* @returns {Promise<{event: object, publishResults: object[], summary: object}>} Result of the operation.
|
||||
* **Mechanism:**
|
||||
* - Creates a new Kind 30078 event with the same `d` tag as the original.
|
||||
* - The content is scrubbed and replaced with `{ deleted: true, ...placeholder }`.
|
||||
* - Relays will overwrite the old event with this tombstone when resolving the address.
|
||||
* - This is reversible (by publishing a new valid version later) and preserves the history chain.
|
||||
*
|
||||
* @param {object} originalEvent - The video event object to revert.
|
||||
* @param {string} pubkey - The active user's public key (must match owner).
|
||||
* @returns {Promise<{event: object, publishResults: object[], summary: object}>} The published tombstone event and relay results.
|
||||
* @throws {Error} If the user is not the owner or signing fails.
|
||||
*/
|
||||
async revertVideo(originalEvent, pubkey) {
|
||||
if (!pubkey) {
|
||||
@@ -2994,20 +3042,32 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all versions of a video.
|
||||
* Permanently removes a video series from the network.
|
||||
* This is a "Nuclear Option" that attempts to wipe all traces of the video.
|
||||
*
|
||||
* **Process:**
|
||||
* 1. **Hydration**: Ensures the full edit history is loaded so we know all `d` tags and Event IDs.
|
||||
* 2. **Soft Delete (Revert)**: Publishes a new update with `deleted: true` for every unique `d` tag/root found.
|
||||
* This clears the content for clients that just resolve the "latest" version.
|
||||
* 3. **Hard Delete (NIP-09)**: Publishes a Kind 5 event referencing ALL known Event IDs (`e` tags) and
|
||||
* NIP-33 addresses (`a` tags). Relays compliant with NIP-09 will physically remove the events.
|
||||
* 4. **Tombstoning**: Updates local state to ensure the deleted video doesn't reappear from cache.
|
||||
* **The Problem:**
|
||||
* Because a video series might have 10 "edit" versions, deleting just the "latest" one isn't enough.
|
||||
* Relays would just serve the *second-latest* version, resurrecting the video.
|
||||
*
|
||||
* @param {string} videoRootId - The root ID of the video series.
|
||||
* @param {string} pubkey - The owner's public key.
|
||||
* @param {{confirm?: boolean, video?: object}} [options] - Options (confirm dialog, target video hint).
|
||||
* @returns {Promise<{reverts: object[], deletes: object[]}|null>} Summary of actions taken, or null if cancelled.
|
||||
* **The Protocol (Soft + Hard Delete):**
|
||||
* 1. **History Hydration**: We first fetch *every* historical version of this video (by `rootId` and `d` tag).
|
||||
* 2. **Soft Delete (Tombstoning)**: For every unique `d` tag found in the history, we publish a *new* version
|
||||
* with `content: { deleted: true }`. This ensures that even if NIP-09 fails, clients resolving "latest"
|
||||
* will see a tombstone.
|
||||
* 3. **Hard Delete (NIP-09)**: We construct a Kind 5 event referencing:
|
||||
* - Every single Event ID (`e` tag) in the history.
|
||||
* - Every NIP-33 address (`a` tag) found.
|
||||
* - The video root ID.
|
||||
* Relays that respect Kind 5 will physically delete these rows.
|
||||
* 4. **Local Tombstone**: We update `this.tombstones` and `this.activeMap` immediately to hide the video
|
||||
* from the UI without waiting for relay confirmation.
|
||||
*
|
||||
* @param {string} videoRootId - The unique root identifier of the video series.
|
||||
* @param {string} pubkey - The owner's public key (must match event author).
|
||||
* @param {object} [options] - Configuration.
|
||||
* @param {boolean} [options.confirm=true] - Whether to show a browser confirmation dialog.
|
||||
* @param {object} [options.video] - The current video object (optimization to skip initial fetch).
|
||||
* @returns {Promise<{reverts: object[], deletes: object[]}|null>} A summary of the soft (revert) and hard (delete) events published. Returns null if user cancelled.
|
||||
*/
|
||||
async deleteAllVersions(videoRootId, pubkey, options = {}) {
|
||||
if (!pubkey) {
|
||||
@@ -3112,29 +3172,35 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Kind 30078 video events from relays using a buffered stream.
|
||||
* Subscribes to Kind 30078 video events (Series/Clips) from relays using a buffered stream.
|
||||
* This is the primary feed ingestion engine for the application.
|
||||
*
|
||||
* **Why Buffer?**
|
||||
* Relays often send bursts of events (e.g., historical dumps or new floods).
|
||||
* Processing every single event immediately would cause layout thrashing and UI lag.
|
||||
* Relays typically send events in high-speed bursts (historical dumps on connect).
|
||||
* Processing every single event immediately (parsing -> hydrating -> React setState)
|
||||
* causes massive layout thrashing and freezes the main thread.
|
||||
*
|
||||
* **The Algorithm:**
|
||||
* 1. **Buffer**: Push all incoming events into `eventBuffer`.
|
||||
* 2. **Debounce**: Schedule `flushEventBuffer` to run after 75ms of silence or inactivity.
|
||||
* 3. **Batch Process**:
|
||||
* - Convert events to internal Video objects.
|
||||
* - Filter out invalids or duplicates.
|
||||
* - Apply "Last-Write-Wins" logic against `activeMap`.
|
||||
* - Notify the UI (`onVideo`) once per batch.
|
||||
* 4. **Persist**: Save the batch to IndexedDB to warm up the cache for next reload.
|
||||
* **The Algorithm (Buffering Strategy):**
|
||||
* 1. **Ingest**: Push every incoming raw event into a temporary `eventBuffer`.
|
||||
* 2. **Debounce**: A 75ms timer ensures we only process the buffer when the stream pauses briefly.
|
||||
* 3. **Batch Process (Flush)**:
|
||||
* - Validate & Parse: Convert raw JSON to internal `Video` objects.
|
||||
* - Tombstone Check: Discard any video that is older than a known deletion timestamp.
|
||||
* - **Latest-Wins Resolution**: Compare `created_at` against `activeMap`. Only update if newer.
|
||||
* - **Hydration**: Bulk-fetch NIP-71 category tags for the new items.
|
||||
* - **UI Update**: Fire `onVideo(batch)` *once* for the whole chunk.
|
||||
* 4. **Persist**: Asynchronously save the new state to IndexedDB (Stale-While-Revalidate).
|
||||
*
|
||||
* **Invariants:**
|
||||
* - `activeMap` always holds the latest valid version of a video.
|
||||
* - Deleted events (Tombstones) are never surfaced to `onVideo`.
|
||||
* - `activeMap` will *only* ever contain the newest valid version of a video series.
|
||||
* - Deleted videos (Tombstones) are filtered out before reaching the UI callback.
|
||||
*
|
||||
* @param {function(object): void} onVideo - Callback fired when new valid videos are processed. Receives the `Video` object.
|
||||
* @param {{ since?: number, until?: number, limit?: number }} [options] - Filter options.
|
||||
* @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop.
|
||||
* @param {function(object[]): void} onVideo - Callback fired with a BATCH of new/updated `Video` objects.
|
||||
* @param {object} [options] - Filter options.
|
||||
* @param {number} [options.since] - Unix timestamp to fetch events after (usually `latestCachedCreatedAt`).
|
||||
* @param {number} [options.until] - Unix timestamp to fetch events before.
|
||||
* @param {number} [options.limit] - Max number of events to request from relays.
|
||||
* @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop listening.
|
||||
*/
|
||||
subscribeVideos(onVideo, options = {}) {
|
||||
// Explanation:
|
||||
@@ -3668,31 +3734,33 @@ export class NostrClient {
|
||||
* Fetches and reconstructs the full edit history of a video.
|
||||
*
|
||||
* **The Problem:**
|
||||
* Nostr events are immutable. "Editing" a video creates a NEW event.
|
||||
* We need to find all previous versions to show a history log or allow reverting.
|
||||
* Nostr events are immutable. "Editing" a video creates a NEW event with a new ID.
|
||||
* We need to find all previous versions to show a history log or allow reverting to an older state.
|
||||
*
|
||||
* **The Linking Logic:**
|
||||
* 1. **Modern**: Versions share a `videoRootId` field in their content.
|
||||
* 2. **NIP-33**: Versions share the same `d` tag.
|
||||
* 3. **Legacy**: Older versions might only be linked by the `d` tag or lack a root pointer entirely.
|
||||
* **The Linking Logic (Version Stitching):**
|
||||
* 1. **Modern (V3+)**: Versions share a `videoRootId` field in their content, pointing to the *first* event in the series.
|
||||
* 2. **NIP-33 Addressability**: Versions share the same `d` tag, allowing clients to find them by `kind:pubkey:dTag`.
|
||||
* 3. **Legacy Fallback**: Older versions might only be linked by the `d` tag or lack a root pointer entirely.
|
||||
*
|
||||
* **Algorithm:**
|
||||
* 1. **Local Scan**: Search `allEvents` for any event matching the target's `videoRootId` OR `d` tag.
|
||||
* 2. **Root Recovery**: If the `videoRootId` refers to an event we don't have, fetch it specifically (to establish the timeline start).
|
||||
* 3. **Relay Query**: If local history is sparse (<= 1 version), query relays for all events with the same `d` tag.
|
||||
* 4. **Merge & Sort**: Combine all findings, filter out unrelated events, and sort by `created_at` descending.
|
||||
* 1. **Local Scan**: Linearly search `this.allEvents` (memory cache) for any event matching the target's `videoRootId` OR `d` tag.
|
||||
* 2. **Root Recovery**: If the `videoRootId` refers to an event ID we don't have in memory, fetch it specifically from relays.
|
||||
* This ensures we have the "genesis" block of the video history.
|
||||
* 3. **Sparse History Check**: If we found <= 1 version locally, we assume our cache is incomplete.
|
||||
* 4. **Relay Query**: If sparse, query relays for *all* events (Kind 30078) with the same `d` tag and author.
|
||||
* 5. **Merge & Sort**: Combine local and network findings, deduplicate by ID, filter out unrelated events, and sort by `created_at` (Newest -> Oldest).
|
||||
*
|
||||
* @param {object} video - The target video to find history for.
|
||||
* @returns {Promise<object[]>} A promise resolving to an array of Video objects (Newest -> Oldest).
|
||||
* @param {object} video - The target video object to find history for.
|
||||
* @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)
|
||||
// 2. `d` tag (NIP-33 addressability)
|
||||
// 3. Fallback: Matching ID for legacy V1/V2 posts
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user