From 93ffe552a549ac8f82419532cabc4597c3e58821 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:39:01 -0500 Subject: [PATCH] update --- README.md | 3 +- config/instance-config.js | 4 +- debug_comments.js | 58 ++ docs/moderation/README.md | 3 +- docs/nostr-event-schemas.md | 44 +- js/app.js | 13 +- js/bootstrap.js | 119 +++- js/index.js | 22 +- js/nostr/client.js | 623 +++++++++++++--- js/nostr/commentEvents.js | 7 + js/nostr/watchHistory.js | 63 +- js/nostrEventSchemas.js | 4 +- js/services/authService.js | 10 + js/services/commentThreadService.js | 323 +++++++-- js/services/hashtagPreferencesService.js | 101 ++- js/services/nostrService.js | 22 +- js/ui/components/VideoModal.js | 1 + .../video-modal/commentsController.js | 168 ++++- js/ui/videoModalCommentController.js | 181 +++-- js/utils/hex.js | 11 + js/utils/videoPointer.js | 3 +- js/watchHistoryService.js | 20 +- reproduce_issue.js | 53 ++ tests/comment-thread-service.test.mjs | 587 ++++++++++++++- tests/hashtag-preferences.test.mjs | 86 +++ tests/moderation/trust-seeds.test.mjs | 39 +- tests/nostr/comment-events.test.mjs | 101 +++ tests/video-modal-comments.test.mjs | 672 +++++++++++++++++- 28 files changed, 3011 insertions(+), 330 deletions(-) create mode 100644 debug_comments.js create mode 100644 js/utils/hex.js create mode 100644 reproduce_issue.js diff --git a/README.md b/README.md index 780acec2..04f1974b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ Operators can tune thresholds and lists from the Profile modal: ### Hashtag interests & disinterests - The Profile modal’s **Hashtags** tab lets you maintain interests (tags you want surfaced) and disinterests (tags to downrank) with dedicated input fields and per-tag remove actions. Preferences are cached locally so list edits render instantly. -- bitvid persists the lists by publishing a replaceable `kind 30005` event with the identifier `bitvid:tag-preferences`. The `HashtagPreferencesService` normalizes tags, keeps interests and disinterests mutually exclusive, and emits change events so UI stays in sync.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】【F:docs/nostr-event-schemas.md†L150-L168】 +- bitvid persists the lists by publishing a replaceable `kind 30015` event (legacy `30005` remains readable) with the identifier `bitvid:tag-preferences`. The `HashtagPreferencesService` normalizes tags, keeps interests and disinterests mutually exclusive, and emits change events so UI stays in sync.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】【F:docs/nostr-event-schemas.md†L154-L201】 +- Legacy `30005` preference events are still fetched and decrypted; saving updates reissues them as `30015`, so convergence happens automatically as viewers adjust their lists. Operators who need a faster rollout can ask affected accounts to re-save (e.g., toggle a tag) to trigger the new publish cycle.【F:js/services/hashtagPreferencesService.js†L360-L470】【F:docs/nostr-event-schemas.md†L196-L201】 - Preference payloads are encrypted before leaving the browser. The service attempts NIP-44 v2 first, falls back to NIP-44 or NIP-04 based on the signer/browser capabilities, records the active scheme in the `['encrypted', ]` tag, and decrypts in the same priority order when loading from relays.【F:js/services/hashtagPreferencesService.js†L520-L657】【F:js/services/hashtagPreferencesService.js†L356-L511】 - Published ciphertext is stored on every configured write relay and accepted ones are tracked so operators can diagnose relay failures without exposing the plaintext list.【F:js/services/hashtagPreferencesService.js†L658-L711】 diff --git a/config/instance-config.js b/config/instance-config.js index 93fe02f6..461d6135 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -21,7 +21,7 @@ * `false` before deploying to production so that end users receive the stable * experience. Downstream modules read this flag via `js/config.js`. */ -export const IS_DEV_MODE = true; +export const IS_DEV_MODE = false; /** * Whether development builds should surface extra verbose diagnostics. @@ -301,7 +301,7 @@ export const THEME_ACCENT_OVERRIDES = Object.freeze({ * example, a NIP-51 collection) can flip this number so long as their relays * accept the chosen kind. */ -export const WATCH_HISTORY_KIND = 30079; +export const WATCH_HISTORY_KIND = 30078; /** * Identifier applied to the watch-history list when storing it on relays. diff --git a/debug_comments.js b/debug_comments.js new file mode 100644 index 00000000..f8d52224 --- /dev/null +++ b/debug_comments.js @@ -0,0 +1,58 @@ + +import { __testExports } from "./js/nostr/commentEvents.js"; +const { isVideoCommentEvent, normalizeCommentTarget } = __testExports; +import { devLogger, userLogger } from "./js/utils/logger.js"; + +// Logger mocking removed + +const videoEventId = "0000000000000000000000000000000000000000000000000000000000000001"; +const rootKind = "30078"; +const rootAuthor = "0000000000000000000000000000000000000000000000000000000000000002"; + +const targetInput = { + videoEventId, + videoKind: rootKind, + videoAuthorPubkey: rootAuthor, +}; + +const descriptor = normalizeCommentTarget(targetInput); + +console.log("Descriptor:", JSON.stringify(descriptor, null, 2)); + +const validEvent = { + kind: 1111, + tags: [ + ["e", videoEventId, "wss://relay.example.com", "root"], + ["K", rootKind], + ["P", rootAuthor], + ], +}; + +const missingKTagEvent = { + kind: 1111, + tags: [ + ["e", videoEventId, "wss://relay.example.com", "root"], + ["P", rootAuthor], + ], +}; + +const mismatchKTagEvent = { + kind: 1111, + tags: [ + ["e", videoEventId, "wss://relay.example.com", "root"], + ["K", "12345"], // Wrong kind + ["P", rootAuthor], + ], +}; + +const legacyEvent = { + kind: 1, + tags: [ + ["e", videoEventId, "wss://relay.example.com", "root"], + ], +}; + +console.log("Testing Valid Event:", isVideoCommentEvent(validEvent, descriptor)); +console.log("Testing Missing K Tag Event:", isVideoCommentEvent(missingKTagEvent, descriptor)); +console.log("Testing Mismatch K Tag Event:", isVideoCommentEvent(mismatchKTagEvent, descriptor)); +console.log("Testing Legacy Event:", isVideoCommentEvent(legacyEvent, descriptor)); diff --git a/docs/moderation/README.md b/docs/moderation/README.md index 0703386b..43bfd2b8 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -81,7 +81,8 @@ The profile modal now exposes blur, autoplay, and hide thresholds so operators c - The Profile modal’s Hashtags tab is wired to [`HashtagPreferencesService`](../../js/services/hashtagPreferencesService.js), which maintains a local snapshot of the viewer’s interests and disinterests, normalizes every tag, and guarantees that the two sets stay mutually exclusive before broadcasting UI change events.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】 - `bitvidApp` resolves the active pubkey’s preferences at login, listens for `CHANGE` events, and forwards the normalized snapshot to controllers so moderation and discovery logic can react without querying relays again.【F:js/app.js†L3811-L3906】【F:js/app.js†L3940-L4050】 -- Publishing writes a replaceable `kind 30005` list with `d=bitvid:tag-preferences`, encrypting the `{ version, interests, disinterests }` payload via the best available NIP-44/NIP-04 scheme before hitting every configured write relay.【F:docs/nostr-event-schemas.md†L150-L168】【F:js/services/hashtagPreferencesService.js†L520-L711】 +- Publishing writes a replaceable `kind 30015` list (legacy `30005` remains readable) with `d=bitvid:tag-preferences`, encrypting the `{ version, interests, disinterests }` payload via the best available NIP-44/NIP-04 scheme before hitting every configured write relay.【F:docs/nostr-event-schemas.md†L150-L201】【F:js/services/hashtagPreferencesService.js†L520-L711】 +- Legacy `30005` events are still fetched and decrypted; saving or toggling a tag republishes them as `30015`, so operators only need to prompt a re-save if they want faster convergence than organic edits provide.【F:js/services/hashtagPreferencesService.js†L360-L470】【F:docs/nostr-event-schemas.md†L196-L201】 ### Runtime flags diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 77f4319e..a2afb37c 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -151,7 +151,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` (clients also read legacy `30002`) | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | -| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30005` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | +| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` (reads legacy `30005`) | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | @@ -183,14 +183,50 @@ builders inherit the same debugging knobs. ### Hashtag preference lists -Hashtag preference events (`NOTE_TYPES.HASHTAG_PREFERENCES`) live on a -replaceable `30005` list with a stable identifier tag of +Hashtag preference events (`NOTE_TYPES.HASHTAG_PREFERENCES`) now publish as a +replaceable `30015` list with a stable identifier tag of `['d','bitvid:tag-preferences']`. The builder appends `['encrypted','nip44_v2']` so downstream clients can detect that the payload is encrypted. The `content` field must be a NIP-44 ciphertext representing the JSON shape `{ version, interests: string[], disinterests: string[] }`, allowing clients to share their preferred and muted hashtags without exposing the raw preferences on -relays. +relays. Readers continue to accept legacy `30005` payloads so previously +published preferences remain visible. + +### Migration approach: auto-republish on next user change + +We are **not** shipping a one-off migration helper. Instead, the existing +`HashtagPreferencesService.publish()` flow reissues preferences as kind `30015` +the next time a user saves their interests from the profile modal. That publish +path already: + +* rehydrates the signer via `getActiveSigner()` and aborts when no signer is + available, so background jobs cannot emit preferences without user consent; +* encrypts the normalized payload once per save using the strongest available + NIP-44/NIP-04 scheme before calling `buildHashtagPreferenceEvent()`; and +* writes a single replaceable event per account thanks to the stable + `['d','bitvid:tag-preferences']` tag, avoiding duplicate relay load because + later publishes overwrite the previous value.【F:js/services/hashtagPreferencesService.js†L604-L788】 + +To prevent relay spikes, we only invoke `publish()` when the local interests or +disinterests change—UI handlers short-circuit if a user toggles a tag back to +its prior state. The method fans out to the configured write relays via +`publishEventToRelays`, and `assertAnyRelayAccepted` ensures at least one relay +acknowledges the update before treating the migration as complete.【F:js/services/hashtagPreferencesService.js†L761-L784】 + +### Operator checklist + +Operators should monitor the rollout as follows: + +1. After deploying this change, review relay dashboards or logs for accounts + saving preferences to confirm new `30015` writes are accepted (look for the + `bitvid:tag-preferences` `d` tag). +2. Spot-check a few migrated accounts by fetching both `30015` and legacy + `30005` events; the canonical kind should present the newest `created_at` + timestamp once a user saves. +3. If relays reject events, correlate the warnings emitted by + `HashtagPreferencesService.publish()` in operator consoles to identify relay + outages or permission errors. When decrypting, the client inspects both `['encrypted', ...]` and `['encryption', ...]` hints on the event to prioritize NIP-44 v2 payloads, diff --git a/js/app.js b/js/app.js index e4292b61..3cfd4c22 100644 --- a/js/app.js +++ b/js/app.js @@ -4022,7 +4022,18 @@ class Application { } this.applyLoggedOutUiState(); - this.commentController?.refreshAuthState?.(); + + const activeModalVideo = + typeof this.videoModal?.getCurrentVideo === "function" + ? this.videoModal.getCurrentVideo() + : this.commentController?.currentVideo || null; + + if (this.commentController && activeModalVideo) { + // Regression guard: ensure logout refreshes the modal thread so comments stay visible without reopening. + this.commentController.load(activeModalVideo); + } else { + this.commentController?.refreshAuthState?.(); + } try { await this.handleModerationSettingsChange({ diff --git a/js/bootstrap.js b/js/bootstrap.js index b535f438..7ea9bc3a 100644 --- a/js/bootstrap.js +++ b/js/bootstrap.js @@ -12,6 +12,9 @@ import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; import { loadView } from "./viewManager.js"; import hashtagPreferences from "./services/hashtagPreferencesService.js"; +import { devLogger, userLogger } from "./utils/logger.js"; + +const TRUST_SEED_READY_TIMEOUT_MS = 3500; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -44,6 +47,53 @@ function buildTrustedSeeds({ superAdmin, editors, fallbackSeeds }) { return seeds; } +function delay(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForRelaysReady({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ( + nostrService?.nostrClient?.relays && + Array.isArray(nostrService.nostrClient.relays) && + nostrService.nostrClient.relays.length > 0 + ) { + return true; + } + await delay(125); + } + return false; +} + +async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } = {}) { + if (!accessControl || typeof accessControl.ensureReady !== "function") { + return { ok: true, timedOut: false }; + } + + let timedOut = false; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + timedOut = true; + reject(new Error("accessControl ensureReady timed out")); + }, timeoutMs); + }); + + try { + await Promise.race([accessControl.ensureReady(), timeoutPromise]); + return { ok: true, timedOut: false }; + } catch (error) { + if (timedOut) { + devLogger.warn( + "[bootstrap] access control hydration timed out; falling back to default trust seeds.", + error, + ); + return { ok: true, timedOut: true }; + } + throw error; + } +} + async function bootstrapTrustedSeeds() { if ( !( @@ -67,20 +117,58 @@ async function bootstrapTrustedSeeds() { fallbackSeeds: DEFAULT_TRUST_SEED_NPUBS, }); moderationService.setTrustedSeeds(seeds); - } catch { - // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. + if ( + moderationService && + typeof moderationService.recomputeAllSummaries === "function" + ) { + moderationService.recomputeAllSummaries(); + } + } catch (error) { + userLogger.warn("[bootstrap] Failed to apply trusted seeds", error); } }; - try { - if (accessControl && typeof accessControl.ensureReady === "function") { - await accessControl.ensureReady(); + let attempts = 0; + const hydrate = async () => { + attempts += 1; + try { + const result = await waitForAccessControl(); + applySeeds(); + return result; + } catch (error) { + userLogger.warn( + "[bootstrap] Failed to hydrate admin lists for trusted seeds", + error, + ); + return { ok: false, timedOut: false, error }; } - } catch { - // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. - } + }; - applySeeds(); + const hydrateResult = await hydrate(); + + const runAsyncRetry = async () => { + if (hydrateResult?.ok || attempts >= 2) { + return; + } + + const relaysReady = await waitForRelaysReady(); + if (relaysReady) { + await hydrate(); + } else { + devLogger.warn( + "[bootstrap] Skipping trusted seed retry because relays were not ready in time.", + ); + applySeeds(); + } + }; + + runAsyncRetry().catch((error) => { + userLogger.warn("[bootstrap] Trusted seed retry failed", error); + }); + + if (!hydrateResult?.ok) { + applySeeds(); + } const applyOnChange = () => { applySeeds(); @@ -95,7 +183,7 @@ async function bootstrapTrustedSeeds() { } } -bootstrapTrustedSeeds(); +export const trustedSeedsReadyPromise = bootstrapTrustedSeeds(); function mergeServices(overrides = {}) { const merged = { nostrService, r2Service, hashtagPreferences }; @@ -115,7 +203,16 @@ function mergeServices(overrides = {}) { return merged; } -export function createApplication({ services, loadView: loadViewOverride } = {}) { +export async function createApplication({ services, loadView: loadViewOverride } = {}) { + try { + await trustedSeedsReadyPromise; + } catch (error) { + userLogger.warn( + "[bootstrap] Failed to await trusted seed hydration before creating application", + error, + ); + } + return new Application({ services: mergeServices(services), loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView, diff --git a/js/index.js b/js/index.js index e2189682..553d3e60 100644 --- a/js/index.js +++ b/js/index.js @@ -3,7 +3,6 @@ import { validateInstanceConfig } from "../config/validate-config.js"; import { ASSET_VERSION } from "../config/asset-version.js"; import "./bufferPolyfill.js"; -import Application from "./app.js"; import { setApplication, setApplicationReady } from "./applicationContext.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; @@ -37,6 +36,7 @@ import { accessControl } from "./accessControl.js"; import { nostrClient } from "./nostrClientFacade.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; +import createApplication from "./bootstrap.js"; validateInstanceConfig(); @@ -354,17 +354,17 @@ function startApplication() { return applicationReadyPromise; } - application = new Application({ - services: { - nostrService, - r2Service, - }, - loadView, - }); - - setApplication(application); - const startupPromise = (async () => { + application = await createApplication({ + services: { + nostrService, + r2Service, + }, + loadView, + }); + + setApplication(application); + await application.init(); if (typeof application.start === "function") { await application.start(); diff --git a/js/nostr/client.js b/js/nostr/client.js index 8289536d..5ba7d0be 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -301,6 +301,269 @@ function shouldRequestExtensionPermissions(signer) { const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const EVENTS_CACHE_DB_NAME = "bitvid-events-cache"; +const EVENTS_CACHE_DB_VERSION = 1; +const EVENTS_CACHE_PERSIST_DELAY_MS = 450; +const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500; +const DEFAULT_VIDEO_REQUEST_LIMIT = 150; +const MAX_VIDEO_REQUEST_LIMIT = 500; + +function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) { + if (typeof requestIdleCallback === "function") { + return requestIdleCallback(callback, { timeout }); + } + return setTimeout(callback, timeout); +} + +function wrapIdbRequest(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("IndexedDB request failed")); + }); +} + +function waitForTransaction(tx) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed")); + tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted")); + }); +} + +class EventsCacheStore { + constructor() { + this.dbPromise = null; + this.persistedEventFingerprints = new Map(); + this.persistedTombstoneFingerprints = new Map(); + this.hasLoadedFingerprints = false; + } + + isSupported() { + return typeof indexedDB !== "undefined"; + } + + async getDb() { + if (!this.isSupported()) { + return null; + } + + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(EVENTS_CACHE_DB_NAME, EVENTS_CACHE_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains("events")) { + db.createObjectStore("events", { keyPath: "id" }); + } + if (!db.objectStoreNames.contains("tombstones")) { + db.createObjectStore("tombstones", { keyPath: "key" }); + } + if (!db.objectStoreNames.contains("meta")) { + db.createObjectStore("meta", { keyPath: "key" }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("Failed to open events cache database")); + }); + + return this.dbPromise; + } + + computeEventFingerprint(video) { + try { + return JSON.stringify(video); + } catch (error) { + devLogger.warn("[nostr] Failed to fingerprint cached event", error); + return String(Date.now()); + } + } + + computeTombstoneFingerprint(timestamp) { + return `ts:${timestamp}`; + } + + async ensureFingerprintsLoaded(db) { + if (this.hasLoadedFingerprints || !db) { + return; + } + const tx = db.transaction(["events", "tombstones"], "readonly"); + const eventsStore = tx.objectStore("events"); + const tombstoneStore = tx.objectStore("tombstones"); + + const [events, tombstones] = await Promise.all([ + wrapIdbRequest(eventsStore.getAll()), + wrapIdbRequest(tombstoneStore.getAll()), + waitForTransaction(tx), + ]); + + for (const entry of Array.isArray(events) ? events : []) { + if (entry && entry.id && entry.fingerprint) { + this.persistedEventFingerprints.set(entry.id, entry.fingerprint); + } + } + + for (const entry of Array.isArray(tombstones) ? tombstones : []) { + if (entry && entry.key) { + const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp); + this.persistedTombstoneFingerprints.set(entry.key, fingerprint); + } + } + + this.hasLoadedFingerprints = true; + } + + async readMeta(db) { + const tx = db.transaction(["meta"], "readonly"); + const metaStore = tx.objectStore("meta"); + const meta = await wrapIdbRequest(metaStore.get("meta")); + await waitForTransaction(tx); + return meta; + } + + async restoreSnapshot() { + const db = await this.getDb(); + if (!db) { + return null; + } + + const meta = await this.readMeta(db); + if (!meta || meta.version !== 1 || !meta.savedAt) { + return null; + } + + const now = Date.now(); + if (now - meta.savedAt > EVENTS_CACHE_TTL_MS) { + const tx = db.transaction(["events", "tombstones", "meta"], "readwrite"); + tx.objectStore("events").clear(); + tx.objectStore("tombstones").clear(); + tx.objectStore("meta").clear(); + await waitForTransaction(tx); + return null; + } + + const tx = db.transaction(["events", "tombstones"], "readonly"); + const eventsStore = tx.objectStore("events"); + const tombstoneStore = tx.objectStore("tombstones"); + + const [events, tombstones] = await Promise.all([ + wrapIdbRequest(eventsStore.getAll()), + wrapIdbRequest(tombstoneStore.getAll()), + waitForTransaction(tx), + ]); + + const eventsMap = new Map(); + const tombstoneMap = new Map(); + + for (const entry of Array.isArray(events) ? events : []) { + if (entry && entry.id && entry.video) { + eventsMap.set(entry.id, entry.video); + if (entry.fingerprint) { + this.persistedEventFingerprints.set(entry.id, entry.fingerprint); + } + } + } + + for (const entry of Array.isArray(tombstones) ? tombstones : []) { + if (entry && entry.key && Number.isFinite(entry.timestamp)) { + tombstoneMap.set(entry.key, entry.timestamp); + const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp); + this.persistedTombstoneFingerprints.set(entry.key, fingerprint); + } + } + + this.hasLoadedFingerprints = true; + + return { + version: 1, + savedAt: meta.savedAt, + events: eventsMap, + tombstones: tombstoneMap, + }; + } + + async persistSnapshot(payload) { + const db = await this.getDb(); + if (!db) { + return { persisted: false }; + } + + await this.ensureFingerprintsLoaded(db); + + const tx = db.transaction(["events", "tombstones", "meta"], "readwrite"); + const eventsStore = tx.objectStore("events"); + const tombstoneStore = tx.objectStore("tombstones"); + const metaStore = tx.objectStore("meta"); + + const { events, tombstones, savedAt } = payload; + let eventWrites = 0; + let eventDeletes = 0; + let tombstoneWrites = 0; + let tombstoneDeletes = 0; + + for (const [id, video] of events.entries()) { + if (!id) { + continue; + } + const fingerprint = this.computeEventFingerprint(video); + const prevFingerprint = this.persistedEventFingerprints.get(id); + if (prevFingerprint === fingerprint) { + continue; + } + eventsStore.put({ id, video, fingerprint }); + this.persistedEventFingerprints.set(id, fingerprint); + eventWrites++; + } + + for (const persistedId of Array.from(this.persistedEventFingerprints.keys())) { + if (events.has(persistedId)) { + continue; + } + eventsStore.delete(persistedId); + this.persistedEventFingerprints.delete(persistedId); + eventDeletes++; + } + + for (const [key, timestamp] of tombstones.entries()) { + if (!key) { + continue; + } + const fingerprint = this.computeTombstoneFingerprint(timestamp); + const prevFingerprint = this.persistedTombstoneFingerprints.get(key); + if (prevFingerprint === fingerprint) { + continue; + } + tombstoneStore.put({ key, timestamp, fingerprint }); + this.persistedTombstoneFingerprints.set(key, fingerprint); + tombstoneWrites++; + } + + for (const persistedKey of Array.from(this.persistedTombstoneFingerprints.keys())) { + if (tombstones.has(persistedKey)) { + continue; + } + tombstoneStore.delete(persistedKey); + this.persistedTombstoneFingerprints.delete(persistedKey); + tombstoneDeletes++; + } + + metaStore.put({ key: "meta", savedAt, version: 1 }); + + await waitForTransaction(tx); + + return { + persisted: true, + eventWrites, + eventDeletes, + tombstoneWrites, + tombstoneDeletes, + }; + } +} // To limit error spam let errorLogCount = 0; @@ -866,6 +1129,11 @@ export class NostrClient { this.rootCreatedAtByRoot = new Map(); this.hasRestoredLocalData = false; + this.eventsCacheStore = new EventsCacheStore(); + this.cachePersistTimerId = null; + this.cachePersistIdleId = null; + this.cachePersistInFlight = null; + this.cachePersistReason = null; this.sessionActor = null; this.lockedSessionActor = null; @@ -2907,13 +3175,113 @@ export class NostrClient { return error; } - restoreLocalData() { + applyCachedPayload(payload, sourceLabel) { + if (!payload || payload.version !== 1) { + return false; + } + + const now = Date.now(); + if ( + typeof payload.savedAt !== "number" || + payload.savedAt <= 0 || + now - payload.savedAt > EVENTS_CACHE_TTL_MS + ) { + return false; + } + + const events = payload.events; + if (!events || (typeof events !== "object" && !(events instanceof Map))) { + return false; + } + + this.allEvents.clear(); + this.rawEvents.clear(); + this.activeMap.clear(); + this.rootCreatedAtByRoot.clear(); + this.tombstones.clear(); + + const tombstoneEntries = + events instanceof Map && payload.tombstones instanceof Map + ? payload.tombstones.entries() + : Array.isArray(payload.tombstones) + ? payload.tombstones + : []; + + for (const entry of tombstoneEntries) { + const [key, value] = Array.isArray(entry) ? entry : [entry.key, entry.timestamp]; + const normalizedKey = typeof key === "string" ? key.trim() : ""; + const timestamp = Number.isFinite(value) ? Math.floor(value) : 0; + if (normalizedKey && timestamp > 0) { + this.tombstones.set(normalizedKey, timestamp); + } + } + + const eventEntries = + events instanceof Map ? events.entries() : Object.entries(events); + + for (const [id, video] of eventEntries) { + if (!id || !video || typeof video !== "object") { + continue; + } + + this.applyRootCreatedAt(video); + const activeKey = getActiveKey(video); + + if (video.deleted) { + this.recordTombstone(activeKey, video.created_at); + } else { + this.applyTombstoneGuard(video); + } + + this.allEvents.set(id, video); + if (video.deleted) { + continue; + } + + const existing = this.activeMap.get(activeKey); + if (!existing || video.created_at > existing.created_at) { + this.activeMap.set(activeKey, video); + } + } + + if (this.allEvents.size > 0 && isDevMode) { + devLogger.log( + `[nostr] Restored ${this.allEvents.size} cached events from ${sourceLabel}`, + ); + } + + return this.allEvents.size > 0; + } + + async restoreLocalData() { if (this.hasRestoredLocalData) { return this.allEvents.size > 0; } this.hasRestoredLocalData = true; + const restoredFromIndexedDb = await this.restoreFromIndexedDb(); + if (restoredFromIndexedDb) { + return true; + } + + return this.restoreFromLocalStorage(); + } + + async restoreFromIndexedDb() { + try { + const payload = await this.eventsCacheStore.restoreSnapshot(); + if (!payload) { + return false; + } + return this.applyCachedPayload(payload, "IndexedDB"); + } catch (error) { + devLogger.warn("[nostr] Failed to restore IndexedDB cache:", error); + return false; + } + } + + restoreFromLocalStorage() { if (typeof localStorage === "undefined") { return false; } @@ -2955,74 +3323,17 @@ export class NostrClient { } } - if (!payload || payload.version !== 1) { - return false; - } + const applied = this.applyCachedPayload(payload, "localStorage"); - if ( - typeof payload.savedAt !== "number" || - payload.savedAt <= 0 || - now - payload.savedAt > EVENTS_CACHE_TTL_MS - ) { + if (!applied && payload) { try { localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY); } catch (err) { devLogger.warn("[nostr] Failed to clear expired cache:", err); } - return false; } - const events = payload.events; - if (!events || typeof events !== "object") { - return false; - } - - this.allEvents.clear(); - this.rawEvents.clear(); - this.activeMap.clear(); - this.rootCreatedAtByRoot.clear(); - this.tombstones.clear(); - - if (Array.isArray(payload.tombstones)) { - for (const entry of payload.tombstones) { - if (!Array.isArray(entry) || entry.length < 2) { - continue; - } - const [key, value] = entry; - const normalizedKey = typeof key === "string" ? key.trim() : ""; - const timestamp = Number.isFinite(value) ? Math.floor(value) : 0; - if (normalizedKey && timestamp > 0) { - this.tombstones.set(normalizedKey, timestamp); - } - } - } - - for (const [id, video] of Object.entries(events)) { - if (!id || !video || typeof video !== "object") { - continue; - } - - this.applyRootCreatedAt(video); - const activeKey = getActiveKey(video); - - if (video.deleted) { - this.recordTombstone(activeKey, video.created_at); - } else { - this.applyTombstoneGuard(video); - } - - this.allEvents.set(id, video); - if (video.deleted) { - continue; - } - - const existing = this.activeMap.get(activeKey); - if (!existing || video.created_at > existing.created_at) { - this.activeMap.set(activeKey, video); - } - } - - return this.allEvents.size > 0; + return applied; } applyRelayPreferences(preferences = {}) { @@ -3184,7 +3495,7 @@ export class NostrClient { async init() { devLogger.log("Connecting to relays..."); - this.restoreLocalData(); + await this.restoreLocalData(); try { this.scheduleStoredRemoteSignerRestore(); @@ -5113,7 +5424,7 @@ export class NostrClient { } } - this.saveLocalData(); + this.saveLocalData("delete-events", { immediate: true }); return { reverts: revertSummaries, @@ -5121,46 +5432,194 @@ export class NostrClient { }; } - /** - * Saves all known events to localStorage (or a different storage if you prefer). - */ - saveLocalData() { + clearCachePersistHandles() { + if (this.cachePersistTimerId) { + clearTimeout(this.cachePersistTimerId); + this.cachePersistTimerId = null; + } + + if (this.cachePersistIdleId) { + if (typeof cancelIdleCallback === "function") { + try { + cancelIdleCallback(this.cachePersistIdleId); + } catch (error) { + devLogger.warn("[nostr] Failed to cancel cache idle callback:", error); + } + } else { + clearTimeout(this.cachePersistIdleId); + } + this.cachePersistIdleId = null; + } + } + + buildCachePayload() { + return { + version: 1, + savedAt: Date.now(), + events: new Map(this.allEvents), + tombstones: new Map(this.tombstones), + }; + } + + saveLocalData(reason = "unspecified", options = {}) { + const { immediate = false } = options; + + if (immediate) { + this.clearCachePersistHandles(); + this.cachePersistInFlight = this.persistLocalData(reason).finally(() => { + this.cachePersistInFlight = null; + }); + return this.cachePersistInFlight; + } + + if (this.cachePersistTimerId || this.cachePersistIdleId || this.cachePersistInFlight) { + return this.cachePersistInFlight; + } + + this.cachePersistReason = reason; + devLogger.log( + `[nostr] Scheduling cached events persist (${reason}) with debounce ${EVENTS_CACHE_PERSIST_DELAY_MS}ms`, + ); + this.cachePersistTimerId = setTimeout(() => { + this.cachePersistTimerId = null; + this.cachePersistIdleId = scheduleIdleTask(() => { + this.cachePersistIdleId = null; + this.cachePersistInFlight = this.persistLocalData(reason).finally(() => { + this.cachePersistInFlight = null; + }); + }); + }, EVENTS_CACHE_PERSIST_DELAY_MS); + + return this.cachePersistInFlight; + } + + async persistLocalData(reason = "unspecified") { + const payload = this.buildCachePayload(); + const startedAt = Date.now(); + let summary = null; + let target = "localStorage"; + + try { + summary = await this.eventsCacheStore.persistSnapshot(payload); + if (summary?.persisted) { + target = "IndexedDB"; + } + } catch (error) { + devLogger.warn("[nostr] Failed to persist events cache to IndexedDB:", error); + } + + if (!summary?.persisted) { + this.persistCacheToLocalStorage(payload); + } + + const durationMs = Date.now() - startedAt; + devLogger.log( + `[nostr] Cached events persisted via ${target} (reason=${reason}, duration=${durationMs}ms, events+${summary?.eventWrites ?? 0}/-${summary?.eventDeletes ?? 0}, tombstones+${summary?.tombstoneWrites ?? 0}/-${summary?.tombstoneDeletes ?? 0})`, + ); + + return summary?.persisted; + } + + persistCacheToLocalStorage(payload) { if (typeof localStorage === "undefined") { return; } - const payload = { - version: 1, - savedAt: Date.now(), - events: {}, - tombstones: Array.from(this.tombstones.entries()), - }; - - for (const [id, vid] of this.allEvents.entries()) { - payload.events[id] = vid; + const serializedEvents = {}; + for (const [id, vid] of payload.events.entries()) { + serializedEvents[id] = vid; } + const serializedPayload = { + ...payload, + events: serializedEvents, + tombstones: Array.from(payload.tombstones.entries()), + }; + try { - localStorage.setItem(EVENTS_CACHE_STORAGE_KEY, JSON.stringify(payload)); + localStorage.setItem( + EVENTS_CACHE_STORAGE_KEY, + JSON.stringify(serializedPayload), + ); localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY); } catch (err) { devLogger.warn("[nostr] Failed to persist events cache:", err); } } + clampVideoRequestLimit(limit, fallback = DEFAULT_VIDEO_REQUEST_LIMIT) { + const normalizedFallback = + Number.isFinite(fallback) && fallback > 0 + ? Math.floor(fallback) + : DEFAULT_VIDEO_REQUEST_LIMIT; + + if (!Number.isFinite(limit) || limit <= 0) { + return normalizedFallback; + } + + const floored = Math.floor(limit); + const clamped = Math.min(MAX_VIDEO_REQUEST_LIMIT, Math.max(1, floored)); + + if (clamped !== floored && isDevMode) { + devLogger.log( + `[nostr] Clamped video request limit from ${floored} to ${clamped}`, + ); + } + + return clamped; + } + + getLatestCachedCreatedAt() { + let latest = 0; + + for (const video of this.allEvents.values()) { + const createdAt = Number.isFinite(video?.created_at) + ? Math.floor(video.created_at) + : 0; + if (createdAt > latest) { + latest = createdAt; + } + } + + return latest; + } + /** * Subscribe to *all* videos (old and new) with a single subscription, * buffering incoming events to avoid excessive DOM updates. + * + * @param {Function} onVideo + * @param {{ since?: number, until?: number, limit?: number }} [options] + * `since` defaults to the latest cached created_at to avoid replaying + * the full history on every load. `limit` is clamped to + * MAX_VIDEO_REQUEST_LIMIT. */ - subscribeVideos(onVideo) { + subscribeVideos(onVideo, options = {}) { + const { since, until, limit } = options; + const latestCachedCreatedAt = this.getLatestCachedCreatedAt(); + + const resolvedLimit = this.clampVideoRequestLimit(limit); + const resolvedSince = Number.isFinite(since) + ? Math.floor(since) + : latestCachedCreatedAt > 0 + ? latestCachedCreatedAt + : undefined; + const resolvedUntil = Number.isFinite(until) ? Math.floor(until) : undefined; + const filter = { kinds: [30078], "#t": ["video"], - // Adjust limit/time as desired - limit: 500, - since: 0, + limit: resolvedLimit, }; + if (resolvedSince !== undefined) { + filter.since = resolvedSince; + } + + if (resolvedUntil !== undefined) { + filter.until = resolvedUntil; + } + devLogger.log("[subscribeVideos] Subscribing with filter:", filter); const sub = this.pool.sub(this.relays, [filter]); @@ -5243,7 +5702,7 @@ export class NostrClient { } // Persist processed events after each flush so reloads warm quickly. - this.saveLocalData(); + this.saveLocalData("subscribeVideos:flush"); }; const scheduleFlush = (immediate = false) => { diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index b90bf7aa..1943fa1c 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -816,6 +816,13 @@ function createVideoCommentFilters(targetInput, options = {}) { if (typeof descriptor.rootIdentifier === "string" && descriptor.rootIdentifier) { uppercaseFilter["#I"] = [descriptor.rootIdentifier]; hasUppercasePointer = true; + + const lowercaseRootFilter = { + kinds: getAllowedCommentKinds(), + "#i": [descriptor.rootIdentifier], + }; + + filters.push(applyFilterOptions(lowercaseRootFilter, options)); } else if ( typeof descriptor.videoDefinitionAddress === "string" && descriptor.videoDefinitionAddress diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 8529bee1..7c6cc5d7 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -1248,8 +1248,8 @@ class WatchHistoryManager { typeof actorInput === "string" && actorInput.trim() ? actorInput.trim() : typeof this.deps.getActivePubkey === "function" - ? this.deps.getActivePubkey() - : ""; + ? this.deps.getActivePubkey() + : ""; const actorKey = normalizeActorKey(resolvedActor); if (!actorKey) { return ""; @@ -1257,11 +1257,11 @@ class WatchHistoryManager { const items = Array.isArray(itemsOverride) ? canonicalizeWatchHistoryItems(itemsOverride, WATCH_HISTORY_MAX_ITEMS) : (() => { - const cacheEntry = this.cache.get(actorKey) || this.getStorage().actors?.[actorKey]; - return Array.isArray(cacheEntry?.items) - ? canonicalizeWatchHistoryItems(cacheEntry.items, WATCH_HISTORY_MAX_ITEMS) - : []; - })(); + const cacheEntry = this.cache.get(actorKey) || this.getStorage().actors?.[actorKey]; + return Array.isArray(cacheEntry?.items) + ? canonicalizeWatchHistoryItems(cacheEntry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + })(); const fingerprint = await computeWatchHistoryFingerprintForItems(items); const previous = this.fingerprints.get(actorKey); if (previous && previous !== fingerprint) { @@ -1276,8 +1276,8 @@ class WatchHistoryManager { typeof actorInput === "string" && actorInput.trim() ? actorInput.trim() : typeof this.deps.getActivePubkey === "function" - ? this.deps.getActivePubkey() - : this.deps.getSessionActor?.()?.pubkey || ""; + ? this.deps.getActivePubkey() + : this.deps.getSessionActor?.()?.pubkey || ""; const actorKey = normalizeActorKey(resolvedActor); if (!actorKey) { return Promise.resolve({ pointerEvent: null, items: [], snapshotId: "" }); @@ -1433,9 +1433,8 @@ class WatchHistoryManager { WATCH_HISTORY_PAYLOAD_MAX_BYTES, ); if (skipped.length) { - userLogger.warn( - `[nostr] Watch history snapshot skipped ${skipped.length} oversize ${ - skipped.length === 1 ? "entry" : "entries" + devLogger.warn( + `[nostr] Watch history snapshot skipped ${skipped.length} oversize ${skipped.length === 1 ? "entry" : "entries" }.`, ); } @@ -1543,7 +1542,7 @@ class WatchHistoryManager { await ensureSessionEncryptors(); if (!encryptionCandidates.length) { - userLogger.warn( + devLogger.warn( "[nostr] No encryption strategies available to publish watch history.", { actor: actorKey, @@ -1714,7 +1713,7 @@ class WatchHistoryManager { ciphertext = encryptionResult?.ciphertext || ""; encryptionScheme = encryptionResult?.scheme || ""; } catch (error) { - userLogger.warn("[nostr] Failed to encrypt watch history chunk:", { + devLogger.warn("[nostr] Failed to encrypt watch history chunk:", { actor: actorKey, snapshotId, chunkIndex: index, @@ -1729,7 +1728,7 @@ class WatchHistoryManager { return { ok: false, error: "encryption-failed", retryable: false }; } if (!ciphertext) { - userLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", { + devLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", { actor: actorKey, snapshotId, chunkIndex: index, @@ -1754,7 +1753,7 @@ class WatchHistoryManager { try { signedEvent = await signEvent(event); } catch (error) { - userLogger.warn("[nostr] Failed to sign watch history chunk:", error); + devLogger.warn("[nostr] Failed to sign watch history chunk:", error); return { ok: false, error: "signing-failed", retryable: false }; } const publishResults = await publishEventToRelays( @@ -1766,7 +1765,7 @@ class WatchHistoryManager { const acceptedCount = relayStatus.filter((entry) => entry.success).length; if (acceptedCount === 0) { anyChunkRejected = true; - userLogger.warn( + devLogger.warn( `[nostr] Watch history chunk ${index} rejected by all relays:`, publishResults, ); @@ -1779,7 +1778,7 @@ class WatchHistoryManager { ); } else { anyChunkPartial = true; - userLogger.warn( + devLogger.warn( `[nostr] Watch history chunk ${index} ${logMessage} by ${acceptedCount}/${relays.length} relay(s).`, publishResults, ); @@ -1808,7 +1807,7 @@ class WatchHistoryManager { try { signedPointerEvent = await signEvent(pointerEvent); } catch (error) { - userLogger.warn("[nostr] Failed to sign watch history pointer event:", error); + devLogger.warn("[nostr] Failed to sign watch history pointer event:", error); return { ok: false, error: "signing-failed", retryable: false }; } devLogger.info( @@ -1832,12 +1831,12 @@ class WatchHistoryManager { `[nostr] Watch history pointer accepted by ${pointerAcceptedCount}/${relays.length} relay(s).`, ); } else if (pointerAccepted) { - userLogger.warn( + devLogger.warn( `[nostr] Watch history pointer partially accepted by ${pointerAcceptedCount}/${relays.length} relay(s).`, pointerResults, ); } else { - userLogger.warn( + devLogger.warn( "[nostr] Watch history pointer rejected by all relays:", pointerResults, ); @@ -2137,7 +2136,7 @@ class WatchHistoryManager { return { pointerEvent: null, items, snapshotId: entry.snapshotId }; }; if (!actorKeyIsHex) { - userLogger.warn( + devLogger.warn( `[nostr] Cannot normalize watch history actor key to hex. Aborting relay fetch for ${resolvedActor}.`, ); if ( @@ -2173,7 +2172,7 @@ class WatchHistoryManager { } const pool = typeof this.deps.getPool === "function" ? this.deps.getPool() : null; if (!pool) { - userLogger.warn( + devLogger.warn( "[nostr] Cannot fetch watch history because relay pool is unavailable. Returning cached values.", ); return { @@ -2218,8 +2217,8 @@ class WatchHistoryManager { const results = await pool.list(readRelays, filters); pointerEvents = Array.isArray(results) ? results - .flat() - .filter((event) => event && typeof event === "object") + .flat() + .filter((event) => event && typeof event === "object") : []; } catch (error) { devLogger.warn("[nostr] Failed to fetch watch history pointer:", error); @@ -2311,8 +2310,8 @@ class WatchHistoryManager { const chunkResults = await pool.list(readRelays, chunkFilters); chunkEvents = Array.isArray(chunkResults) ? chunkResults - .flat() - .filter((event) => event && typeof event === "object") + .flat() + .filter((event) => event && typeof event === "object") : []; } catch (error) { devLogger.warn("[nostr] Failed to fetch watch history chunks:", error); @@ -2522,7 +2521,7 @@ class WatchHistoryManager { eventId: event.id ?? null, attempts: attemptFailures, }); - userLogger.error( + devLogger.warn( "[nostr] Decrypt failed for watch history chunk. Falling back to pointer items.", { actorKey, @@ -2573,7 +2572,7 @@ class WatchHistoryManager { } } if (decryptErrors.length) { - userLogger.warn( + devLogger.warn( `[nostr] Failed to decrypt ${decryptErrors.length} watch history chunk(s) for ${actorKey}. Using fallback pointers.`, ); } @@ -2632,9 +2631,9 @@ class WatchHistoryManager { const storage = this.getStorage(); const fallbackItems = Array.isArray(storage.actors?.[actorKey]?.items) ? canonicalizeWatchHistoryItems( - storage.actors[actorKey].items, - WATCH_HISTORY_MAX_ITEMS, - ) + storage.actors[actorKey].items, + WATCH_HISTORY_MAX_ITEMS, + ) : []; const fetchResult = await this.fetch(resolvedActor, { forceRefresh: options.forceRefresh || false, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index dfd2fbaf..d4926454 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -421,7 +421,7 @@ const BASE_SCHEMAS = { [NOTE_TYPES.HASHTAG_PREFERENCES]: { type: NOTE_TYPES.HASHTAG_PREFERENCES, label: "Hashtag preferences", - kind: 30005, + kind: 30015, identifierTag: { name: "d", value: "bitvid:tag-preferences", @@ -1555,7 +1555,7 @@ export function buildHashtagPreferenceEvent({ } appendSchemaTags(tags, schema); return { - kind: schema?.kind ?? 30005, + kind: schema?.kind ?? 30015, pubkey, created_at, tags, diff --git a/js/services/authService.js b/js/services/authService.js index 58ad6202..69714205 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -20,6 +20,7 @@ import { persistProfileCacheToStorage as persistProfileCacheState, getProfileCacheEntry as getCachedProfileEntry, setProfileCacheEntry as setCachedProfileEntry, + resetModerationSettings, } from "../state/cache.js"; import getDefaultAuthProvider, { providers as defaultAuthProviders, @@ -742,6 +743,15 @@ export default class AuthService { setCurrentUserNpub(null); setActiveProfilePubkey(null, { persist: true }); + try { + resetModerationSettings({ persist: true }); + } catch (error) { + this.log( + "[AuthService] Failed to reset moderation settings on logout", + error, + ); + } + if (this.userBlocks && typeof this.userBlocks.reset === "function") { try { this.userBlocks.reset(); diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index e02f62bf..21dff452 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -1,6 +1,7 @@ // js/services/commentThreadService.js -import { devLogger } from "../utils/logger.js"; +import logger, { devLogger, userLogger } from "../utils/logger.js"; +import { normalizeHexId, normalizeHexPubkey } from "../utils/hex.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; @@ -8,6 +9,8 @@ import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; const ROOT_PARENT_KEY = "__root__"; const DEFAULT_INITIAL_LIMIT = 40; const DEFAULT_HYDRATION_DEBOUNCE_MS = 25; +const PROFILE_FETCH_MAX_ATTEMPTS = 3; +const PROFILE_FETCH_BACKOFF_MS = 50; const COMMENT_CACHE_PREFIX = "bitvid:comments:"; const COMMENT_CACHE_TTL_MS = 5 * 60 * 1000; const COMMENT_CACHE_VERSION = 2; @@ -24,11 +27,15 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } -function normalizePubkey(pubkey) { - const normalized = normalizeString(pubkey); - return normalized ? normalized.toLowerCase() : ""; +function normalizeRelay(value) { + if (typeof value === "string") { + return value.trim(); + } + return ""; } +const normalizePubkey = normalizeHexPubkey; + function normalizeKind(value) { if (Number.isFinite(value)) { return String(Math.floor(value)); @@ -47,6 +54,12 @@ function fromParentKey(parentKey) { return parentKey === ROOT_PARENT_KEY ? null : parentKey; } +function logDev(loggerCandidate, ...args) { + if (loggerCandidate?.info) { + loggerCandidate.info(...args); + } +} + function safeCall(handler, payload) { if (typeof handler !== "function") { return; @@ -58,6 +71,34 @@ function safeCall(handler, payload) { } } +function normalizeLogger(loggerCandidate) { + const normalized = + loggerCandidate && typeof loggerCandidate === "object" + ? loggerCandidate + : null; + + const devChannel = + normalized?.dev && typeof normalized.dev.warn === "function" + ? normalized.dev + : devLogger; + + const userChannel = + normalized?.user && typeof normalized.user.warn === "function" + ? normalized.user + : userLogger; + + const warn = + typeof normalized?.warn === "function" + ? (...args) => normalized.warn(...args) + : (...args) => devChannel.warn(...args); + + return { + warn, + dev: devChannel, + user: userChannel, + }; +} + export default class CommentThreadService { constructor({ nostrClient = null, @@ -68,7 +109,7 @@ export default class CommentThreadService { batchFetchProfiles = null, limit = DEFAULT_INITIAL_LIMIT, hydrationDebounceMs = DEFAULT_HYDRATION_DEBOUNCE_MS, - logger = devLogger, + logger: loggerCandidate = logger, } = {}) { this.nostrClient = nostrClient; this.fetchVideoComments = fetchVideoComments; @@ -105,8 +146,7 @@ export default class CommentThreadService { 0, toPositiveInteger(hydrationDebounceMs, DEFAULT_HYDRATION_DEBOUNCE_MS) ); - this.logger = - logger && typeof logger.warn === "function" ? logger : devLogger; + this.logger = normalizeLogger(loggerCandidate); this.callbacks = { onThreadReady: null, @@ -132,10 +172,13 @@ export default class CommentThreadService { this.videoKind = ""; this.videoAuthorPubkeyRaw = ""; this.videoAuthorPubkey = ""; + this.videoRootIdentifier = ""; + this.videoRootRelay = ""; this.parentCommentId = ""; this.parentCommentKind = ""; this.parentCommentPubkey = ""; this.activeRelays = null; + this.commentCacheDiagnostics = { storageUnavailable: false }; } setCallbacks({ @@ -158,7 +201,7 @@ export default class CommentThreadService { } = {}) { this.teardown(); - // Always ensure the pool is ready before proceeding + // Always ensure the pool is ready before proceeding when supported. if (typeof this.nostrClient?.ensurePool === "function") { try { await this.nostrClient.ensurePool(); @@ -168,21 +211,24 @@ export default class CommentThreadService { ); return { success: false, error }; } - } else { - this.emitError( - new Error("Nostr client missing ensurePool implementation."), - ); - return { success: false }; } const rawVideoAuthorPubkey = normalizeString(video?.pubkey); - this.videoEventId = normalizeString(video?.id); + this.videoEventId = normalizeHexId(video?.id); this.videoAddressPointer = buildVideoAddressPointer(video); this.videoKind = normalizeKind(video?.kind); this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey; this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey); - this.parentCommentId = normalizeString(parentCommentId); + const pointerRootIdentifier = normalizeHexId( + video?.pointerIdentifiers?.videoRootId, + ); + this.videoRootIdentifier = + normalizeHexId(video?.videoRootId) || pointerRootIdentifier; + this.videoRootRelay = normalizeRelay( + video?.videoRootRelay || video?.rootIdentifierRelay, + ); + this.parentCommentId = normalizeHexId(parentCommentId); this.parentCommentKind = this.parentCommentId ? String(COMMENT_EVENT_KIND) : ""; this.parentCommentPubkey = ""; this.activeRelays = Array.isArray(relays) ? [...relays] : null; @@ -213,6 +259,12 @@ export default class CommentThreadService { if (this.videoKind) { target.videoKind = this.videoKind; } + if (this.videoRootIdentifier) { + target.rootIdentifier = this.videoRootIdentifier; + } + if (this.videoRootRelay) { + target.rootIdentifierRelay = this.videoRootRelay; + } const targetVideoAuthorPubkey = this.getVideoAuthorPubkeyForOutput(); if (targetVideoAuthorPubkey) { target.videoAuthorPubkey = targetVideoAuthorPubkey; @@ -267,16 +319,24 @@ export default class CommentThreadService { }; if (!FEATURE_IMPROVED_COMMENT_FETCHING) { + logDev( + this.logger?.dev, + "[commentThread] Improved fetching fallback: feature disabled.", + ); return fallbackFetch(options); } const targetCandidate = target && typeof target === "object" ? target : {}; - const videoEventId = normalizeString( + const videoEventId = normalizeHexId( targetCandidate.videoEventId || targetCandidate.eventId, ); if (!videoEventId) { + logDev( + this.logger?.dev, + "[commentThread] Improved fetching fallback: missing video event id.", + ); return fallbackFetch(options); } @@ -285,6 +345,11 @@ export default class CommentThreadService { return cached; } + logDev( + this.logger?.dev, + `[commentThread] Comment cache miss for ${videoEventId}, fetching fresh thread.`, + ); + const fetchOptions = { ...options, since: 0, @@ -305,8 +370,34 @@ export default class CommentThreadService { } getCommentCacheKey(videoEventId) { - const normalized = normalizeString(videoEventId); - return normalized ? `${COMMENT_CACHE_PREFIX}${normalized}` : ""; + const normalized = normalizeHexId(videoEventId); + if (!normalized) { + return ""; + } + + return `${COMMENT_CACHE_PREFIX}${normalized.toLowerCase()}`; + } + + handleCommentCacheError(context, videoEventId, error) { + this.commentCacheDiagnostics = { + ...this.commentCacheDiagnostics, + storageUnavailable: true, + }; + + const message = + typeof videoEventId === "string" && videoEventId.trim() + ? `[commentThread] Failed to ${context} comment cache for ${videoEventId}.` + : `[commentThread] Failed to ${context} comment cache.`; + + if (this.logger?.user?.warn) { + this.logger.user.warn(message, error); + } else if (this.logger?.warn) { + this.logger.warn(message, error); + } + + if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) { + this.logger.dev.warn(message, error); + } } getCachedComments(videoEventId) { @@ -319,6 +410,10 @@ export default class CommentThreadService { const cacheKey = this.getCommentCacheKey(videoEventId); if (!cacheKey) { + logDev( + this.logger?.dev, + "[commentThread] Comment cache skipped: invalid video id.", + ); return null; } @@ -326,22 +421,25 @@ export default class CommentThreadService { try { raw = localStorage.getItem(cacheKey); } catch (error) { - if (this.logger?.warn) { - this.logger.warn( - `[commentThread] Failed to read cached comments for ${videoEventId}:`, - error, - ); - } + this.handleCommentCacheError("read", videoEventId, error); return null; } if (raw === null) { + logDev( + this.logger?.dev, + `[commentThread] Comment cache miss for ${videoEventId}: no entry present.`, + ); return null; } try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { + logDev( + this.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: malformed payload.`, + ); this.removeCommentCache(cacheKey); return null; } @@ -351,6 +449,10 @@ export default class CommentThreadService { : null; if (cacheVersion !== COMMENT_CACHE_VERSION) { + logDev( + this.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: version ${cacheVersion} != ${COMMENT_CACHE_VERSION}.`, + ); this.removeCommentCache(cacheKey); return null; } @@ -365,14 +467,17 @@ export default class CommentThreadService { Number.isFinite(timestamp) && Date.now() - timestamp <= COMMENT_CACHE_TTL_MS ) { - if (devLogger?.info) { - devLogger.info( - `[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`, - ); - } + logDev( + this.logger?.dev, + `[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`, + ); return comments; } + logDev( + this.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: entry expired.`, + ); this.removeCommentCache(cacheKey); } catch (error) { if (this.logger?.warn) { @@ -381,6 +486,10 @@ export default class CommentThreadService { error, ); } + logDev( + this.logger?.dev, + `[commentThread] Comment cache rejected for ${videoEventId}: parse error.`, + ); this.removeCommentCache(cacheKey); } @@ -410,18 +519,12 @@ export default class CommentThreadService { timestamp: Date.now(), }), ); - if (devLogger?.info) { - devLogger.info( - `[commentThread] Cached ${comments.length} comments for ${videoEventId}.`, - ); - } + logDev( + this.logger?.dev, + `[commentThread] Cached ${comments.length} comments for ${videoEventId}.`, + ); } catch (error) { - if (this.logger?.warn) { - this.logger.warn( - `[commentThread] Failed to cache comments for ${videoEventId}:`, - error, - ); - } + this.handleCommentCacheError("write", videoEventId, error); } } @@ -485,8 +588,8 @@ export default class CommentThreadService { return aTime - bTime; } - const aId = normalizeString(a?.id); - const bId = normalizeString(b?.id); + const aId = normalizeHexId(a?.id); + const bId = normalizeHexId(b?.id); if (aId && bId) { return aId.localeCompare(bId); } @@ -554,7 +657,7 @@ export default class CommentThreadService { return null; } - const eventId = normalizeString(event.id); + const eventId = normalizeHexId(event.id); if (!eventId) { return null; } @@ -563,6 +666,14 @@ export default class CommentThreadService { const parentKey = toParentKey(parentId); const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; const pubkey = normalizePubkey(event.pubkey); + const { identifier: rootIdentifier, relay: rootRelay } = + this.extractRootIdentifier(event); + if (rootIdentifier) { + this.videoRootIdentifier = rootIdentifier; + if (rootRelay) { + this.videoRootRelay = rootRelay; + } + } if (this.parentCommentId && eventId === this.parentCommentId) { const kindValue = normalizeKind(event.kind); if (kindValue) { @@ -610,7 +721,7 @@ export default class CommentThreadService { if (name !== "e") { continue; } - const normalizedValue = normalizeString(value); + const normalizedValue = normalizeHexId(value); if (!normalizedValue) { continue; } @@ -627,6 +738,30 @@ export default class CommentThreadService { return ""; } + extractRootIdentifier(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (let index = tags.length - 1; index >= 0; index -= 1) { + const tag = tags[index]; + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const name = + typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (name !== "i") { + continue; + } + const value = normalizeString(tag[1]); + if (!value) { + continue; + } + return { + identifier: value, + relay: normalizeRelay(tag[2]), + }; + } + return { identifier: "", relay: "" }; + } + insertIntoParentList(parentKey, eventId, createdAt) { const list = this.getParentList(parentKey); if (list.includes(eventId)) { @@ -741,31 +876,66 @@ export default class CommentThreadService { return; } - const hydrationPromise = Promise.resolve( - this.batchFetchProfiles(pubkeys), - ) - .catch((error) => { - if (this.logger?.warn) { - this.logger.warn( - "[commentThread] Profile hydration failed:", - error, - ); - } - this.emitError(error); - }) - .then(() => { - pubkeys.forEach((pubkey) => { - const profile = this.getProfileFromCache(pubkey); - if (profile) { - this.profileCache.set(pubkey, profile); + const pubkeyListLog = pubkeys.join(", ") || "(empty profile batch)"; + const hydrationPromise = (async () => { + try { + let attempt = 0; + let lastError = null; + + while (attempt < PROFILE_FETCH_MAX_ATTEMPTS) { + try { + await Promise.resolve(this.batchFetchProfiles(pubkeys)); + break; + } catch (error) { + lastError = error; + const attemptLabel = `${attempt + 1}/${PROFILE_FETCH_MAX_ATTEMPTS}`; + if (this.logger?.dev?.warn) { + this.logger.dev.warn( + `[commentThread] Profile hydration attempt ${attemptLabel} failed for pubkeys: ${pubkeyListLog}.`, + error, + ); + } + + attempt += 1; + if (attempt >= PROFILE_FETCH_MAX_ATTEMPTS) { + throw lastError; + } + + const backoffMs = PROFILE_FETCH_BACKOFF_MS * attempt; + logDev( + this.logger?.dev, + `[commentThread] Retrying profile hydration in ${backoffMs}ms for pubkeys: ${pubkeyListLog}.`, + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } - }); - }) - .finally(() => { - if (this.profileHydrationPromise === hydrationPromise) { - this.profileHydrationPromise = null; + } + } catch (error) { + const errorMessage = `[commentThread] Profile hydration failed for pubkeys: ${pubkeyListLog}.`; + if (this.logger?.user?.warn) { + this.logger.user.warn(errorMessage, error); + } else if (this.logger?.warn) { + this.logger.warn(errorMessage, error); + } + + if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) { + this.logger.dev.warn(errorMessage, error); + } + + this.emitError(error); + return; + } + + pubkeys.forEach((pubkey) => { + const profile = this.getProfileFromCache(pubkey); + if (profile) { + this.profileCache.set(pubkey, profile); } }); + })().finally(() => { + if (this.profileHydrationPromise === hydrationPromise) { + this.profileHydrationPromise = null; + } + }); this.profileHydrationPromise = hydrationPromise; await hydrationPromise; @@ -782,12 +952,15 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, topLevelIds: this.getCommentIdsForParent(null), commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), + commentCacheDiagnostics: { ...this.commentCacheDiagnostics }, }; safeCall(this.callbacks.onThreadReady, payload); this.persistCommentCache(); @@ -800,12 +973,15 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentIds: [eventId], commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), + commentCacheDiagnostics: { ...this.commentCacheDiagnostics }, }; safeCall(this.callbacks.onCommentsAppended, payload); this.persistCommentCache(); @@ -825,11 +1001,14 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), + commentCacheDiagnostics: { ...this.commentCacheDiagnostics }, }; } @@ -869,13 +1048,13 @@ export default class CommentThreadService { } getCommentIdsForParent(parentId) { - const key = toParentKey(normalizeString(parentId)); + const key = toParentKey(normalizeHexId(parentId)); const list = this.childrenByParent.get(key); return Array.isArray(list) ? [...list] : []; } getCommentEvent(commentId) { - const normalized = normalizeString(commentId); + const normalized = normalizeHexId(commentId); if (!normalized) { return null; } @@ -887,6 +1066,14 @@ export default class CommentThreadService { } teardown() { + try { + this.persistCommentCache(); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn("[commentThread] Failed to persist comment cache:", error); + } + } + if (this.subscriptionCleanup) { try { this.subscriptionCleanup(); diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 6cf488d3..597dde3b 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -4,7 +4,11 @@ import { requestDefaultExtensionPermissions, } from "../nostrClientFacade.js"; import { getActiveSigner } from "../nostr/index.js"; -import { buildHashtagPreferenceEvent } from "../nostrEventSchemas.js"; +import { + buildHashtagPreferenceEvent, + getNostrEventSchema, + NOTE_TYPES, +} from "../nostrEventSchemas.js"; import { publishEventToRelays, assertAnyRelayAccepted, @@ -112,6 +116,44 @@ function normalizeEncryptionToken(value) { .replace(/[^a-z0-9]/g, ""); } +function normalizePreferencesPayload(payload) { + const versionCandidate = Number(payload?.version); + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? versionCandidate + : DEFAULT_VERSION; + + const rawInterests = Array.isArray(payload?.interests) + ? payload.interests + : []; + const rawDisinterests = Array.isArray(payload?.disinterests) + ? payload.disinterests + : []; + + const interests = new Set(); + for (const tag of rawInterests) { + const normalizedTag = normalizeTag(tag); + if (normalizedTag) { + interests.add(normalizedTag); + } + } + + const disinterests = new Set(); + for (const tag of rawDisinterests) { + const normalizedTag = normalizeTag(tag); + if (!normalizedTag) { + continue; + } + disinterests.add(normalizedTag); + interests.delete(normalizedTag); + } + + return { + version, + interests, + disinterests, + }; +} + function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; @@ -201,6 +243,7 @@ class HashtagPreferencesService { this.eventId = null; this.eventCreatedAt = null; this.loaded = false; + this.preferencesVersion = DEFAULT_VERSION; } on(eventName, handler) { @@ -213,6 +256,7 @@ class HashtagPreferencesService { this.eventId = null; this.eventCreatedAt = null; this.loaded = false; + this.preferencesVersion = DEFAULT_VERSION; this.emitChange("reset"); } @@ -290,6 +334,7 @@ class HashtagPreferencesService { disinterests: this.getDisinterests(), eventId: this.eventId, createdAt: this.eventCreatedAt, + version: this.preferencesVersion, ...detail, }); } catch (error) { @@ -331,8 +376,14 @@ class HashtagPreferencesService { return; } + const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES); + const canonicalKind = schema?.kind ?? 30015; + const legacyKind = 30005; + const filterKinds = canonicalKind === legacyKind + ? [canonicalKind] + : [canonicalKind, legacyKind]; const filter = { - kinds: [30005], + kinds: filterKinds, authors: [normalized], "#d": [HASHTAG_IDENTIFIER], limit: 50, @@ -358,6 +409,15 @@ class HashtagPreferencesService { return; } + // Prefer the newest event, breaking timestamp ties by prioritizing the + // canonical kind (30015) before falling back to the legacy 30005 payload. + const preferredKinds = filterKinds; + const getKindPriority = (event) => { + const kindValue = Number(event?.kind); + const index = preferredKinds.indexOf(kindValue); + return index === -1 ? preferredKinds.length : index; + }; + const latest = events.reduce((current, candidate) => { if (!candidate) { return current; @@ -368,6 +428,11 @@ class HashtagPreferencesService { const candidateTs = Number(candidate.created_at) || 0; const currentTs = Number(current.created_at) || 0; if (candidateTs === currentTs) { + const candidatePriority = getKindPriority(candidate); + const currentPriority = getKindPriority(current); + if (candidatePriority !== currentPriority) { + return candidatePriority < currentPriority ? candidate : current; + } return candidate.id > current.id ? candidate : current; } return candidateTs > currentTs ? candidate : current; @@ -392,33 +457,13 @@ class HashtagPreferencesService { try { const payload = JSON.parse(decryptResult.plaintext); - const rawInterests = Array.isArray(payload?.interests) - ? payload.interests - : []; - const rawDisinterests = Array.isArray(payload?.disinterests) - ? payload.disinterests - : []; + // Normalize whichever source kind we decrypted into the canonical + // preferences payload so downstream consumers receive a single shape. + const normalizedPayload = normalizePreferencesPayload(payload); - const nextInterests = new Set(); - for (const tag of rawInterests) { - const normalizedTag = normalizeTag(tag); - if (normalizedTag) { - nextInterests.add(normalizedTag); - } - } - - const nextDisinterests = new Set(); - for (const tag of rawDisinterests) { - const normalizedTag = normalizeTag(tag); - if (!normalizedTag) { - continue; - } - nextDisinterests.add(normalizedTag); - nextInterests.delete(normalizedTag); - } - - this.interests = nextInterests; - this.disinterests = nextDisinterests; + this.preferencesVersion = normalizedPayload.version; + this.interests = normalizedPayload.interests; + this.disinterests = normalizedPayload.disinterests; this.eventId = latest.id || null; this.eventCreatedAt = Number.isFinite(latest?.created_at) ? latest.created_at diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 341f7251..64a71a55 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1122,10 +1122,19 @@ export class NostrService { this.resolveInitialLoad({ videos: initial, reason: "cache" }); if (!getStoredVideoSubscription()) { - const subscription = this.nostrClient.subscribeVideos(() => { - const updated = this.nostrClient.getActiveVideos(); - applyAndNotify(updated, "subscription"); - }); + const subscriptionOptions = { + // Start from the freshest cached timestamp to avoid replaying the full history; + // use loadOlderVideos when the user scrolls for backfill. + since: this.nostrClient.getLatestCachedCreatedAt() || undefined, + limit: this.nostrClient.clampVideoRequestLimit(200), + }; + const subscription = this.nostrClient.subscribeVideos( + () => { + const updated = this.nostrClient.getActiveVideos(); + applyAndNotify(updated, "subscription"); + }, + subscriptionOptions, + ); this.setVideoSubscription(subscription); this.emit("subscription:started", { subscription }); } @@ -1160,11 +1169,14 @@ export class NostrService { return []; } + const clampedLimit = + this.nostrClient?.clampVideoRequestLimit(limit) ?? limit ?? 0; + const filter = { kinds: [VIDEO_KIND], "#t": ["video"], until, - limit, + limit: clampedLimit, }; const collected = new Map(); diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index f9675296..4e6445b7 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -969,6 +969,7 @@ export class VideoModal { if (normalizedReason === "login-required") { this.setCommentHintText("Log in to add a comment."); this.setCommentStatus(""); + this.setCommentsVisibility(true); } else if (normalizedReason === "submitting") { this.setCommentHintText(this.commentComposerDefaultHint); this.setCommentStatus("Posting comment…"); diff --git a/js/ui/components/video-modal/commentsController.js b/js/ui/components/video-modal/commentsController.js index 5112e2e6..a89644f0 100644 --- a/js/ui/components/video-modal/commentsController.js +++ b/js/ui/components/video-modal/commentsController.js @@ -1,7 +1,10 @@ +import logger from "../../../utils/logger.js"; + export class CommentsController { constructor({ modal } = {}) { this.modal = modal; this.root = null; + this.playerModal = null; } initialize({ playerModal } = {}) { @@ -9,10 +12,38 @@ export class CommentsController { return; } + this.playerModal = playerModal || null; const root = playerModal?.querySelector("[data-comments-root]") || null; + const commentsDisabledPlaceholder = + playerModal?.querySelector("[data-comments-disabled-placeholder]") || + root?.querySelector("[data-comments-disabled]") || + this.ensureCommentsDisabledPlaceholder({ root }) || + null; + this.root = root; this.modal.commentsRoot = root; this.modal.commentsContainer = root; + this.modal.commentsDisabledPlaceholder = commentsDisabledPlaceholder; + + if (commentsDisabledPlaceholder) { + const disabledText = commentsDisabledPlaceholder.textContent || ""; + if (disabledText) { + this.modal.commentsDisabledPlaceholderDefaultText = disabledText; + } + } + + if (!root) { + logger.user.warn( + "[VideoModal:comments] Missing [data-comments-root]; disabling comments UI" + ); + this.disableComments({ + reason: "missing-root", + message: this.modal.commentsDisabledPlaceholderDefaultText || + "Comments are unavailable right now.", + }); + return; + } + this.modal.commentsCountLabel = root?.querySelector("[data-comments-count]") || null; this.modal.commentsEmptyState = @@ -21,10 +52,6 @@ export class CommentsController { root?.querySelector("[data-comments-list]") || null; this.modal.commentsLoadMoreButton = root?.querySelector("[data-comments-load-more]") || null; - this.modal.commentsDisabledPlaceholder = - playerModal?.querySelector("[data-comments-disabled-placeholder]") || - root?.querySelector("[data-comments-disabled]") || - null; this.modal.commentsComposer = root?.querySelector("[data-comments-composer]") || null; this.modal.commentsInput = @@ -44,6 +71,41 @@ export class CommentsController { this.modal.commentComposerHint || null; + const missingSelectors = [ + { selector: "[data-comments-list]", element: this.modal.commentsList }, + { + selector: "[data-comments-composer]", + element: this.modal.commentsComposer, + }, + { + selector: "[data-comments-input]", + element: this.modal.commentsInput, + }, + { + selector: "[data-comments-submit]", + element: this.modal.commentsSubmitButton, + }, + ] + .filter(({ element }) => !element) + .map(({ selector }) => selector); + + if (missingSelectors.length > 0) { + logger.user.warn( + `[VideoModal:comments] Missing required selectors: ${missingSelectors.join(", ")}; disabling comments UI` + ); + this.disableComments({ + reason: "missing-selectors", + message: this.modal.commentsDisabledPlaceholderDefaultText || + "Comments are unavailable right now.", + }); + return; + } + + if (this.playerModal) { + this.playerModal.removeAttribute("data-comments-disabled"); + this.playerModal.classList?.remove("comments-disabled"); + } + if (this.modal.commentComposerHint) { const hintText = this.modal.commentComposerHint.textContent || ""; if (hintText) { @@ -73,11 +135,6 @@ export class CommentsController { } if (this.modal.commentsDisabledPlaceholder) { - const disabledText = - this.modal.commentsDisabledPlaceholder.textContent || ""; - if (disabledText) { - this.modal.commentsDisabledPlaceholderDefaultText = disabledText; - } this.modal.commentsDisabledPlaceholder.setAttribute("hidden", ""); } @@ -90,6 +147,46 @@ export class CommentsController { this.modal.attachCommentEventHandlers(); } + disableComments({ message = "", reason = "" } = {}) { + const placeholder = + this.modal.commentsDisabledPlaceholder || + this.ensureCommentsDisabledPlaceholder({ root: this.root }) || + null; + if (!this.modal.commentsDisabledPlaceholder && placeholder) { + this.modal.commentsDisabledPlaceholder = placeholder; + if (placeholder.textContent) { + this.modal.commentsDisabledPlaceholderDefaultText = placeholder.textContent; + } + } + + if (this.playerModal) { + this.playerModal.setAttribute("data-comments-disabled", "true"); + this.playerModal.classList?.add("comments-disabled"); + } + + if (this.modal.commentsList) { + this.modal.commentsList.setAttribute("hidden", ""); + this.modal.commentsList.classList?.add("hidden"); + } + + if (this.modal.commentsComposer) { + this.modal.commentsComposer.setAttribute("hidden", ""); + this.modal.commentsComposer.classList?.add("hidden"); + } + + const nextMessage = + typeof message === "string" && message.trim() + ? message.trim() + : this.modal.commentsDisabledPlaceholderDefaultText || + "Comments are unavailable right now."; + + this.modal.showCommentsDisabledMessage(nextMessage); + logger.dev?.info?.( + "[VideoModal:comments] Disabled comments handling", + { reason: reason || "unknown" } + ); + } + update(action = {}) { if (!this.modal) { return; @@ -135,7 +232,60 @@ export class CommentsController { this.modal.commentsStatusMessage = null; this.modal.commentsCharCount = null; this.modal.commentComposerHint = null; + if (this.playerModal) { + this.playerModal.removeAttribute("data-comments-disabled"); + this.playerModal.classList?.remove("comments-disabled"); + } this.root = null; } + + getDocument() { + if (this.modal?.document) { + return this.modal.document; + } + if (this.playerModal?.ownerDocument) { + return this.playerModal.ownerDocument; + } + if (typeof document !== "undefined") { + return document; + } + return null; + } + + ensureCommentsDisabledPlaceholder({ root } = {}) { + const existing = + this.modal?.commentsDisabledPlaceholder || + root?.querySelector?.("[data-comments-disabled-placeholder]") || + this.playerModal?.querySelector?.("[data-comments-disabled-placeholder]"); + if (existing) { + return existing; + } + + const doc = this.getDocument(); + const container = root?.parentElement || this.playerModal || null; + if (!doc || !container) { + return null; + } + + const placeholder = doc.createElement("div"); + placeholder.setAttribute("data-comments-disabled-placeholder", ""); + placeholder.setAttribute("role", "status"); + placeholder.setAttribute("aria-live", "polite"); + placeholder.setAttribute("hidden", ""); + placeholder.classList.add("comment-thread__disabled"); + + const defaultText = + this.modal?.commentsDisabledPlaceholderDefaultText || + "Comments are unavailable right now."; + placeholder.textContent = defaultText; + + if (root?.parentElement) { + root.parentElement.insertBefore(placeholder, root); + } else { + container.insertBefore(placeholder, container.firstChild); + } + + return placeholder; + } } diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index 5d7f11d3..7cacca13 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -1,6 +1,7 @@ import { devLogger } from "../utils/logger.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; +import { normalizeHexString, normalizeHexPubkey } from "../utils/hex.js"; const DEFAULT_LIMIT = 40; @@ -8,6 +9,15 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +const normalizeHexId = normalizeHexString; + +function normalizeRelay(value) { + if (typeof value === "string") { + return value.trim(); + } + return ""; +} + function normalizeKind(value) { if (Number.isFinite(value)) { return String(Math.floor(value)); @@ -77,7 +87,7 @@ export default class VideoModalCommentController { normalizeHexPubkey: typeof utils.normalizeHexPubkey === "function" ? (value) => utils.normalizeHexPubkey(value) - : () => null, + : (value) => normalizeHexPubkey(value) || null, }; this.currentVideo = null; @@ -86,6 +96,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: null, videoKind: null, videoAuthorPubkey: null, + videoRootId: null, + videoRootRelay: null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -143,15 +155,23 @@ export default class VideoModalCommentController { return; } - const videoEventId = normalizeString(video.id); + const videoEventId = normalizeHexId(video.id); let videoDefinitionAddress = buildVideoAddressPointer(video); if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) { videoDefinitionAddress = normalizeString( this.modalCommentState.videoDefinitionAddress, ); } + if (videoDefinitionAddress) { + videoDefinitionAddress = videoDefinitionAddress.trim().toLowerCase(); + } const videoKind = normalizeKind(video.kind); - const videoAuthorPubkey = normalizeString(video.pubkey); + const videoAuthorPubkey = normalizeHexPubkey(video.pubkey); + const pointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId); + const videoRootId = normalizeHexId(video.videoRootId) || pointerRootId; + const videoRootRelay = normalizeRelay( + video.videoRootRelay || video.rootIdentifierRelay, + ); if (!videoEventId) { this.resetModalCommentState({ hide: false }); @@ -166,6 +186,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: videoDefinitionAddress || null, videoKind: videoKind || null, videoAuthorPubkey: videoAuthorPubkey || null, + videoRootId: videoRootId || null, + videoRootRelay: videoRootRelay || null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -179,29 +201,40 @@ export default class VideoModalCommentController { this.videoModal.clearComments?.(); this.videoModal.resetCommentComposer?.(); this.videoModal.setCommentStatus?.("Loading comments…"); - this.applyCommentComposerAuthState(); - const loadPromise = this.commentThreadService.loadThread({ + const loadResult = this.commentThreadService.loadThread({ video, parentCommentId: null, limit: this.modalCommentLimit, }); - if (!loadPromise || typeof loadPromise.then !== "function") { + if (!loadResult) { this.applyCommentComposerAuthState(); return; } - this.modalCommentLoadPromise = loadPromise; - loadPromise - .then(() => { - if (this.modalCommentLoadPromise === loadPromise) { + if (typeof loadResult.then !== "function") { + const didRenderSnapshot = this.handleCommentThreadReady(loadResult); + if (didRenderSnapshot === false) { + this.applyCommentComposerAuthState(); + } + return; + } + + this.modalCommentLoadPromise = loadResult; + loadResult + .then((snapshot) => { + if (this.modalCommentLoadPromise === loadResult) { this.modalCommentLoadPromise = null; } - this.applyCommentComposerAuthState(); + + const didRenderSnapshot = this.handleCommentThreadReady(snapshot); + if (didRenderSnapshot === false) { + this.applyCommentComposerAuthState(); + } }) .catch((error) => { - if (this.modalCommentLoadPromise === loadPromise) { + if (this.modalCommentLoadPromise === loadResult) { this.modalCommentLoadPromise = null; } this.handleCommentThreadError(error); @@ -248,6 +281,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: null, videoKind: null, videoAuthorPubkey: null, + videoRootId: null, + videoRootRelay: null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -317,6 +352,8 @@ export default class VideoModalCommentController { disabled: true, reason: "login-required", }); + this.videoModal.setCommentsVisibility?.(true); + this.videoModal.setCommentStatus?.(""); return; } @@ -329,17 +366,21 @@ export default class VideoModalCommentController { handleCommentThreadReady(snapshot) { if (!snapshot || !this.videoModal) { - return; + return false; } - if (snapshot.videoEventId !== this.modalCommentState.videoEventId) { - return; + const snapshotVideoEventId = normalizeHexId(snapshot.videoEventId); + const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId); + + if (!snapshotVideoEventId || snapshotVideoEventId !== modalVideoEventId) { + return false; } - this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles); - this.modalCommentState.parentCommentId = normalizeString( - snapshot.parentCommentId, - ) || null; + this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles, { + normalizeKey: this.utils.normalizeHexPubkey, + }); + this.modalCommentState.parentCommentId = + normalizeHexId(snapshot.parentCommentId) || null; if (typeof snapshot.videoDefinitionAddress === "string") { const pointer = normalizeString(snapshot.videoDefinitionAddress); if (pointer) { @@ -350,23 +391,33 @@ export default class VideoModalCommentController { if (snapshotVideoKind) { this.modalCommentState.videoKind = snapshotVideoKind; } - const snapshotVideoAuthor = normalizeString(snapshot.videoAuthorPubkey); + const snapshotVideoAuthor = normalizeHexPubkey(snapshot.videoAuthorPubkey); if (snapshotVideoAuthor) { this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor; } + const snapshotRootIdentifier = normalizeHexId(snapshot.rootIdentifier); + if (snapshotRootIdentifier) { + this.modalCommentState.videoRootId = snapshotRootIdentifier; + } + const snapshotRootRelay = normalizeRelay(snapshot.rootIdentifierRelay); + if (snapshotRootRelay) { + this.modalCommentState.videoRootRelay = snapshotRootRelay; + } const snapshotParentKind = normalizeKind(snapshot.parentCommentKind); if (snapshotParentKind) { this.modalCommentState.parentCommentKind = snapshotParentKind; } - const snapshotParentPubkey = normalizeString(snapshot.parentCommentPubkey); + const snapshotParentPubkey = normalizeHexPubkey(snapshot.parentCommentPubkey); if (snapshotParentPubkey) { this.modalCommentState.parentCommentPubkey = snapshotParentPubkey; } const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot); + this.videoModal.setCommentsVisibility?.(true); this.videoModal.renderComments?.(sanitizedSnapshot); this.videoModal.setCommentStatus?.(""); this.applyCommentComposerAuthState(); + return true; } handleCommentThreadAppend(payload) { @@ -374,16 +425,30 @@ export default class VideoModalCommentController { return; } - if (payload.videoEventId !== this.modalCommentState.videoEventId) { + const payloadVideoEventId = normalizeHexId(payload.videoEventId); + const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId); + + if (!payloadVideoEventId || payloadVideoEventId !== modalVideoEventId) { return; } const comments = this.createMapFromInput(payload.commentsById); - const profiles = this.createMapFromInput(payload.profiles); + const profiles = this.createMapFromInput(payload.profiles, { + normalizeKey: this.utils.normalizeHexPubkey, + }); profiles.forEach((profile, pubkey) => { this.modalCommentProfiles.set(pubkey, profile); }); + const payloadRootIdentifier = normalizeHexId(payload.rootIdentifier); + if (payloadRootIdentifier) { + this.modalCommentState.videoRootId = payloadRootIdentifier; + } + const payloadRootRelay = normalizeRelay(payload.rootIdentifierRelay); + if (payloadRootRelay) { + this.modalCommentState.videoRootRelay = payloadRootRelay; + } + const commentIds = Array.isArray(payload.commentIds) ? payload.commentIds : []; @@ -405,7 +470,7 @@ export default class VideoModalCommentController { if (eventKind) { this.modalCommentState.parentCommentKind = eventKind; } - const eventPubkey = normalizeString(event.pubkey); + const eventPubkey = normalizeHexPubkey(event.pubkey); if (eventPubkey) { this.modalCommentState.parentCommentPubkey = eventPubkey; } @@ -432,22 +497,41 @@ export default class VideoModalCommentController { } } - createMapFromInput(input) { - if (input instanceof Map) { - return new Map(input); - } + createMapFromInput(input, { normalizeKey = null } = {}) { const map = new Map(); + const applyKey = (key) => { + if (!normalizeKey) { + return key; + } + const normalized = normalizeKey(key); + return normalized === null || normalized === undefined ? null : normalized; + }; + + const setEntry = (key, value) => { + const normalizedKey = applyKey(key); + if (normalizedKey === null || normalizedKey === undefined) { + return; + } + map.set(normalizedKey, value); + }; + + if (input instanceof Map) { + input.forEach((value, key) => { + setEntry(key, value); + }); + return map; + } if (Array.isArray(input)) { input.forEach((entry) => { if (Array.isArray(entry) && entry.length >= 2) { - map.set(entry[0], entry[1]); + setEntry(entry[0], entry[1]); } }); return map; } if (input && typeof input === "object") { Object.entries(input).forEach(([key, value]) => { - map.set(key, value); + setEntry(key, value); }); } return map; @@ -471,7 +555,7 @@ export default class VideoModalCommentController { ) { normalizedKey = null; } else { - normalizedKey = trimmed; + normalizedKey = normalizeHexId(trimmed) || trimmed; } } const list = Array.isArray(value) ? value.filter(Boolean) : []; @@ -518,8 +602,10 @@ export default class VideoModalCommentController { }); return { - videoEventId: snapshot.videoEventId, - parentCommentId: snapshot.parentCommentId || null, + videoEventId: normalizeHexId(snapshot.videoEventId), + parentCommentId: normalizeHexId(snapshot.parentCommentId) || null, + rootIdentifier: normalizeHexId(snapshot.rootIdentifier) || null, + rootIdentifierRelay: snapshot.rootIdentifierRelay || null, commentsById: sanitizedComments, childrenByParent: sanitizedChildren, profiles: this.modalCommentProfiles, @@ -564,7 +650,7 @@ export default class VideoModalCommentController { return; } - const videoEventId = normalizeString(video.id); + const videoEventId = normalizeHexId(video.id); let videoDefinitionAddress = buildVideoAddressPointer(video); if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) { videoDefinitionAddress = normalizeString( @@ -572,21 +658,30 @@ export default class VideoModalCommentController { ); } const videoKind = - this.modalCommentState.videoKind || normalizeKind(video.kind); + normalizeKind(this.modalCommentState.videoKind) || normalizeKind(video.kind); const videoAuthorPubkey = - this.modalCommentState.videoAuthorPubkey || normalizeString(video.pubkey); + normalizeHexPubkey(this.modalCommentState.videoAuthorPubkey) || + normalizeHexPubkey(video.pubkey); + const fallbackPointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId); + const videoRootId = + normalizeHexId(this.modalCommentState.videoRootId) || + normalizeHexId(video.videoRootId) || + fallbackPointerRootId; + const videoRootRelay = + normalizeRelay(this.modalCommentState.videoRootRelay) || + normalizeRelay(video.videoRootRelay || video.rootIdentifierRelay); if (!videoEventId) { this.callbacks.showError("Comments are unavailable for this video."); return; } - const parentCommentId = normalizeString(detail.parentId) || null; + const parentCommentId = normalizeHexId(detail.parentId) || null; let parentCommentKind = this.modalCommentState.parentCommentKind ? normalizeKind(this.modalCommentState.parentCommentKind) : ""; let parentCommentPubkey = this.modalCommentState.parentCommentPubkey - ? normalizeString(this.modalCommentState.parentCommentPubkey) + ? normalizeHexPubkey(this.modalCommentState.parentCommentPubkey) : ""; if (parentCommentId && typeof this.commentThreadService?.getCommentEvent === "function") { @@ -599,7 +694,7 @@ export default class VideoModalCommentController { parentCommentKind = eventKind; this.modalCommentState.parentCommentKind = eventKind; } - const eventPubkey = normalizeString(parentEvent.pubkey); + const eventPubkey = normalizeHexPubkey(parentEvent.pubkey); if (eventPubkey) { parentCommentPubkey = eventPubkey; this.modalCommentState.parentCommentPubkey = eventPubkey; @@ -632,6 +727,12 @@ export default class VideoModalCommentController { publishPayload.videoAuthorPubkey = videoAuthorPubkey; publishPayload.rootAuthorPubkey = videoAuthorPubkey; } + if (videoRootId) { + publishPayload.rootIdentifier = videoRootId; + } + if (videoRootRelay) { + publishPayload.rootIdentifierRelay = videoRootRelay; + } if (parentCommentKind) { publishPayload.parentCommentKind = parentCommentKind; } @@ -782,7 +883,7 @@ export default class VideoModalCommentController { } async handleVideoModalCommentMute(detail = {}) { - const pubkey = normalizeString(detail?.pubkey); + const pubkey = normalizeHexPubkey(detail?.pubkey); if (!pubkey) { return; } diff --git a/js/utils/hex.js b/js/utils/hex.js new file mode 100644 index 00000000..367f78bd --- /dev/null +++ b/js/utils/hex.js @@ -0,0 +1,11 @@ +export function normalizeHexString(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + +export const normalizeHexId = normalizeHexString; +export const normalizeHexPubkey = normalizeHexString; diff --git a/js/utils/videoPointer.js b/js/utils/videoPointer.js index 292ef31d..10358362 100644 --- a/js/utils/videoPointer.js +++ b/js/utils/videoPointer.js @@ -2,6 +2,7 @@ import { pointerArrayToKey } from "./pointer.js"; import { devLogger } from "./logger.js"; +import { normalizeHexPubkey } from "./hex.js"; const DEFAULT_VIDEO_KIND = 30078; @@ -84,7 +85,7 @@ export function buildVideoAddressPointer( return ""; } - const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + const pubkey = normalizeHexPubkey(video.pubkey); if (!pubkey) { return ""; } diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 197eb378..dbf89c48 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1211,7 +1211,7 @@ async function publishView(pointerInput, createdAt, metadata = {}) { queue.throttle.set(key, now); persistQueueState(); notifyQueueChange(actorKey); - devLogger.info( + devLogger.debug( "[watchHistoryService] Updated existing watch list entry.", { actor: actorKey, @@ -1224,7 +1224,7 @@ async function publishView(pointerInput, createdAt, metadata = {}) { if (throttled) { queue.throttle.set(key, now); - devLogger.info( + devLogger.debug( `[watchHistoryService] Throttled pointer ${key} for actor ${actorKey}.` ); return viewResult; @@ -1249,7 +1249,7 @@ async function publishView(pointerInput, createdAt, metadata = {}) { ); if (!remoteEnabled) { - devLogger.info( + devLogger.debug( "[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.", { actor: actorKey, @@ -1270,7 +1270,7 @@ async function snapshot(items, options = {}) { } if (!isFeatureEnabled(actorKey)) { - devLogger.info( + devLogger.debug( "[watchHistoryService] Snapshot skipped because watch history sync is unavailable for this actor.", { actor: actorKey, @@ -1338,7 +1338,7 @@ async function snapshot(items, options = {}) { } ); - devLogger.info( + devLogger.debug( "[watchHistoryService] Watch list snapshot publish completed.", { actor: actorKey, @@ -1375,7 +1375,7 @@ async function snapshot(items, options = {}) { await updateFingerprintCache(actorKey, publishResult.items || payloadItems); emit("snapshot-complete", { actor: actorKey, reason, result: publishResult }); - devLogger.info( + devLogger.debug( "[watchHistoryService] Watch list snapshot processed and fingerprint cache updated.", { actor: actorKey, @@ -1462,7 +1462,7 @@ async function loadLatest(actorInput, options = {}) { restoreQueueState(); } const items = collectQueueItems(actorKey); - devLogger.info( + devLogger.debug( "[watchHistoryService] loadLatest returning local-only watch history queue.", { actor: actorKey, @@ -1489,7 +1489,7 @@ async function loadLatest(actorInput, options = {}) { cacheEntry.expiresAt > now; if (cacheIsFresh) { - devLogger.info( + devLogger.debug( "[watchHistoryService] Returning cached watch list items.", { actor: actorKey, @@ -1500,14 +1500,14 @@ async function loadLatest(actorInput, options = {}) { } if (cacheEntry?.promise) { - devLogger.info( + devLogger.debug( "[watchHistoryService] Awaiting in-flight watch list refresh.", { actor: actorKey, } ); if (allowStale && hasCachedItems) { - devLogger.info( + devLogger.debug( "[watchHistoryService] Returning stale watch list items while refresh completes.", { actor: actorKey, diff --git a/reproduce_issue.js b/reproduce_issue.js new file mode 100644 index 00000000..f50f17f5 --- /dev/null +++ b/reproduce_issue.js @@ -0,0 +1,53 @@ + +import { __testExports } from "./js/nostr/commentEvents.js"; +const { isVideoCommentEvent, normalizeCommentTarget } = __testExports; + +const originalVideoId = "0000000000000000000000000000000000000000000000000000000000000001"; +const originalAuthor = "0000000000000000000000000000000000000000000000000000000000000002"; +const originalKind = "30078"; + +const reposterPubkey = "0000000000000000000000000000000000000000000000000000000000000003"; +const repostKind = "6"; + +// Scenario: UI passes the Repost event as the "video" +const targetInput = { + videoEventId: originalVideoId, // The ID is usually the original ID even in reposts? No, repost has its own ID. + // But CommentThreadService uses video.id. + // If video is Repost, video.id is Repost ID. + // But comments are on Original ID. + // So if video.id is Repost ID, then we are fetching comments for the Repost ID! + // And the comment event tags the Repost ID? No, it tags the Original ID. + // So we wouldn't even FETCH the comments if we used Repost ID. + + // Wait, if loadThread is called with Repost object, videoEventId = Repost ID. + // fetchThread queries for #e = Repost ID. + // But comments are on Original ID. + // So we get NO comments. + + // So the issue is even earlier: we are querying for the wrong ID. + + // BUT, assuming we somehow got the comment (maybe via legacy #e tag pointing to Repost?), + // let's see if it passes filter. + + // Let's assume the UI unwraps the ID but passes the Repost Kind/Pubkey? + // If video.id is Original ID, but video.kind is 6 (Repost Kind) and video.pubkey is Reposter. + + videoEventId: originalVideoId, + videoKind: repostKind, + videoAuthorPubkey: reposterPubkey, +}; + +const descriptor = normalizeCommentTarget(targetInput); + +console.log("Descriptor:", JSON.stringify(descriptor, null, 2)); + +const validCommentOnOriginal = { + kind: 1111, + tags: [ + ["e", originalVideoId, "wss://relay.example.com", "root"], + ["K", originalKind], + ["P", originalAuthor], + ], +}; + +console.log("Testing Valid Comment on Original (against Repost metadata):", isVideoCommentEvent(validCommentOnOriginal, descriptor)); diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 6efa2caf..d2481111 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -2,7 +2,14 @@ import test from "node:test"; import assert from "node:assert/strict"; import CommentThreadService from "../js/services/commentThreadService.js"; -import { listVideoComments } from "../js/nostr/commentEvents.js"; +import { + COMMENT_EVENT_KIND, + listVideoComments, +} from "../js/nostr/commentEvents.js"; +import { + FEATURE_IMPROVED_COMMENT_FETCHING, + setImprovedCommentFetchingEnabled, +} from "../js/constants.js"; import { buildCommentEvent } from "../js/nostrEventSchemas.js"; function createBaseVideo() { @@ -11,6 +18,7 @@ function createBaseVideo() { pubkey: "authorpk", kind: 30078, tags: [["d", "video-root"], ["alt", "ignored"]], + videoRootId: "video-root", }; } @@ -87,6 +95,490 @@ test.after(() => { } }); +test("CommentThreadService caches mixed-case video ids consistently", () => { + const videoEventId = "VideoMixed123"; + const service = new CommentThreadService(); + + const comments = [ + { + id: "comment-1", + pubkey: "authorpk", + content: "Cached comment", + created_at: 1700000000, + tags: [], + }, + ]; + + service.cacheComments(videoEventId, comments); + + const cachedKeys = []; + for (let index = 0; index < localStorage.length; index += 1) { + cachedKeys.push(localStorage.key(index)); + } + + assert.deepEqual( + cachedKeys, + ["bitvid:comments:videomixed123"], + "cache key should normalize mixed-case ids", + ); + + const cached = service.getCachedComments(videoEventId.toLowerCase()); + assert.deepEqual( + cached, + comments, + "mixed-case ids should retrieve cached entries", + ); +}); + +test("CommentThreadService surfaces cache read failures", () => { + const warnings = []; + const service = new CommentThreadService({ + logger: { + warn: (...args) => warnings.push({ channel: "root", args }), + dev: { warn: (...args) => warnings.push({ channel: "dev", args }) }, + user: { warn: (...args) => warnings.push({ channel: "user", args }) }, + }, + }); + + globalThis.localStorage.getItem = () => { + throw new Error("quota exceeded"); + }; + + const cached = service.getCachedComments("video123"); + + assert.equal(cached, null, "cache read errors should fall back to null"); + assert.equal( + service.getSnapshot().commentCacheDiagnostics.storageUnavailable, + true, + "comment cache diagnostics should flag storage errors", + ); + assert.equal( + warnings.some((entry) => entry.channel === "user"), + true, + "user warnings should be emitted when cache reads fail", + ); +}); + +test("CommentThreadService surfaces cache write failures", () => { + const warnings = []; + const service = new CommentThreadService({ + logger: { + warn: (...args) => warnings.push({ channel: "root", args }), + dev: { warn: (...args) => warnings.push({ channel: "dev", args }) }, + user: { warn: (...args) => warnings.push({ channel: "user", args }) }, + }, + }); + + globalThis.localStorage.setItem = () => { + throw new Error("private mode"); + }; + + service.cacheComments("video123", []); + + assert.equal( + service.getSnapshot().commentCacheDiagnostics.storageUnavailable, + true, + "comment cache diagnostics should flag write failures", + ); + assert.equal( + warnings.some((entry) => entry.channel === "user"), + true, + "user warnings should be emitted when cache writes fail", + ); +}); + +test("CommentThreadService persists caches safely during teardown failures", async () => { + const warnings = []; + const service = new CommentThreadService({ + fetchVideoComments: async () => [ + createComment({ id: "persist-me", pubkey: "pk-cache", createdAt: 1 }), + ], + subscribeVideoComments: () => () => {}, + logger: { + warn: (...args) => warnings.push(args), + dev: { warn: (...args) => warnings.push(args) }, + user: { warn: (...args) => warnings.push(args) }, + }, + }); + + await service.loadThread({ video: createBaseVideo() }); + + globalThis.localStorage.setItem = () => { + throw new Error("storage blocked"); + }; + + assert.doesNotThrow(() => service.teardown()); + assert.equal( + warnings.some((args) => + args.some?.((value) => + typeof value === "string" && + value.includes("Failed to write comment cache for video123"), + ), + ), + true, + "teardown should warn when persistence fails but still reset state", + ); +}); + +test("CommentThreadService logs cache usage and fallback decisions", async () => { + const initialFlag = FEATURE_IMPROVED_COMMENT_FETCHING; + const devLogs = []; + const fetchCalls = []; + const logger = { + warn: () => {}, + dev: { + log: () => {}, + info: (...args) => devLogs.push(args.join(" ")), + debug: () => {}, + warn: (...args) => devLogs.push(args.join(" ")), + error: () => {}, + }, + user: { warn: () => {} }, + }; + + const fetchVideoComments = async (...args) => { + fetchCalls.push(args); + return [ + { + id: "remote-1", + pubkey: "remote", + content: "Fresh comment", + created_at: 2, + tags: [], + }, + ]; + }; + + const service = new CommentThreadService({ + fetchVideoComments, + logger, + }); + + try { + setImprovedCommentFetchingEnabled(true); + const cachedComments = [ + { + id: "cached-1", + pubkey: "cached", + content: "Cached comment", + created_at: 1, + tags: [], + }, + ]; + + service.cacheComments("video123", cachedComments); + devLogs.length = 0; + fetchCalls.length = 0; + + const cachedResult = await service.fetchThread({ videoEventId: "video123" }); + + assert.deepEqual( + cachedResult, + cachedComments, + "feature flag on should return cached comments", + ); + assert.equal( + fetchCalls.length, + 0, + "cache hits should avoid fallback fetch", + ); + assert.equal( + devLogs.some((entry) => + entry.includes("Loaded 1 cached comments for video123"), + ), + true, + "dev logs should note cache usage when enabled", + ); + + setImprovedCommentFetchingEnabled(false); + devLogs.length = 0; + fetchCalls.length = 0; + + const fallbackResult = await service.fetchThread({ + videoEventId: "video123", + }); + + assert.equal( + fetchCalls.length, + 1, + "feature flag off should invoke fallback fetch", + ); + assert.equal( + devLogs.some((entry) => + entry.includes("Improved fetching fallback: feature disabled"), + ), + true, + "dev logs should note fallback when feature is disabled", + ); + assert.deepEqual( + fallbackResult.map((comment) => comment.id), + ["remote-1"], + "fallback path should return freshly fetched comments", + ); + } finally { + setImprovedCommentFetchingEnabled(initialFlag); + } +}); + +test("CommentThreadService normalizes mixed-case event ids in thread state", async () => { + const video = { ...createBaseVideo(), id: "VideoMixed123" }; + const topLevelId = "CoMmEnT-Root"; + const replyId = "RePlY-Child"; + + const topLevelEvent = { + id: topLevelId, + kind: 1, + pubkey: "commenterpk", + created_at: 1700000000, + content: "Top level", + tags: [ + ["e", video.id], + ["a", "30078:authorpk:video-root"], + ], + }; + + const replyEvent = { + id: replyId, + kind: 1, + pubkey: "replypk", + created_at: 1700000100, + content: "Reply", + tags: [ + ["e", video.id.toUpperCase()], + ["a", "30078:authorpk:video-root"], + ["e", topLevelId.toUpperCase()], + ], + }; + + const fetchVideoComments = async () => [topLevelEvent]; + let triggerAppend = null; + const subscribeVideoComments = (target, options) => { + triggerAppend = options?.onEvent; + return () => {}; + }; + + const service = new CommentThreadService({ + fetchVideoComments, + subscribeVideoComments, + }); + + let threadReadyPayload = null; + let appendPayload = null; + service.setCallbacks({ + onThreadReady: (payload) => { + threadReadyPayload = payload; + }, + onCommentsAppended: (payload) => { + appendPayload = payload; + }, + }); + + await service.loadThread({ video }); + + assert.equal( + threadReadyPayload.videoEventId, + video.id.toLowerCase(), + "video ids should normalize to lowercase in the thread payload", + ); + assert.deepEqual( + threadReadyPayload.topLevelIds, + [topLevelId.toLowerCase()], + "top-level ids should be normalized", + ); + assert.equal( + threadReadyPayload.childrenByParent.get(null)[0], + topLevelId.toLowerCase(), + "root children should reference normalized ids", + ); + assert.equal( + threadReadyPayload.commentsById.has(topLevelId.toLowerCase()), + true, + "comment map should key by normalized ids", + ); + assert.ok( + service.getCommentEvent(topLevelId.toUpperCase()), + "comment lookups should be case-insensitive", + ); + + assert.equal(typeof triggerAppend, "function", "subscription should be active"); + triggerAppend(replyEvent); + + assert.deepEqual( + appendPayload.commentIds, + [replyId.toLowerCase()], + "append payload should emit normalized comment ids", + ); + assert.equal( + appendPayload.parentCommentId, + topLevelId.toLowerCase(), + "append payload should carry normalized parent ids", + ); + assert.deepEqual( + appendPayload.childrenByParent.get(topLevelId.toLowerCase()), + [replyId.toLowerCase()], + "children map should store normalized ids for replies", + ); +}); + +test("CommentThreadService normalizes mixed-case pubkeys during hydration", async () => { + const video = createBaseVideo(); + const mixedPubkey = "PuBkEy-Upper"; + + const fetchVideoComments = async () => [ + createComment({ id: "c-hydrate", pubkey: mixedPubkey, createdAt: 1700000200 }), + ]; + + const hydrationRequests = []; + const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, + fetchVideoComments, + subscribeVideoComments: () => () => {}, + batchFetchProfiles: async (pubkeys) => { + hydrationRequests.push([...pubkeys]); + return []; + }, + hydrationDebounceMs: 0, + }); + + await service.loadThread({ video }); + + assert.deepEqual( + hydrationRequests, + [[mixedPubkey.toLowerCase()]], + "hydration should request normalized pubkeys", + ); +}); + +test("CommentThreadService deduplicates mixed-case event ids and pubkeys", async () => { + const hydrationRequests = []; + const service = new CommentThreadService({ + fetchVideoComments: async () => [], + subscribeVideoComments: () => () => {}, + batchFetchProfiles: async (pubkeys) => { + hydrationRequests.push([...pubkeys]); + }, + hydrationDebounceMs: 0, + }); + + await service.loadThread({ video: createBaseVideo() }); + + const mixedCaseEvent = createComment({ + id: "CoMmEnT-123", // intentionally mixed case + pubkey: "PuBkEy-123", + createdAt: 10, + }); + const lowerCaseEvent = createComment({ + id: "comment-123", + pubkey: "pubkey-123", + createdAt: 20, + }); + + service.processIncomingEvent(mixedCaseEvent); + service.processIncomingEvent(lowerCaseEvent); + await service.flushProfileQueue(); + + assert.deepEqual( + service.getCommentIdsForParent(null), + ["comment-123"], + "mixed-case duplicates should collapse to a single normalized id", + ); + assert.equal( + service.getCommentEvent("COMMENT-123")?.created_at, + 20, + "newer updates should replace existing mixed-case entries", + ); + assert.deepEqual( + hydrationRequests, + [["pubkey-123"]], + "hydration should only request the normalized pubkey once", + ); +}); + +test("CommentThreadService retries profile hydration before succeeding", async () => { + const profiles = new Map(); + const hydrationCalls = []; + let attempts = 0; + + const service = new CommentThreadService({ + getProfileCacheEntry: (pubkey) => profiles.get(pubkey), + batchFetchProfiles: async (pubkeys) => { + hydrationCalls.push([...pubkeys]); + attempts += 1; + if (attempts === 1) { + throw new Error("temporary hydration failure"); + } + pubkeys.forEach((pubkey) => + profiles.set(pubkey, { name: `Profile ${pubkey}` }), + ); + }, + hydrationDebounceMs: 0, + }); + + service.queueProfileForHydration("pk-retry-1"); + service.queueProfileForHydration("pk-retry-2"); + + for (let attempt = 0; attempt < 5 && !service.profileHydrationPromise; attempt += 1) { + await tick(10); + } + await service.waitForProfileHydration(); + + assert.equal(attempts, 2, "hydration should retry failed batches once"); + assert.deepEqual( + hydrationCalls, + [ + ["pk-retry-1", "pk-retry-2"], + ["pk-retry-1", "pk-retry-2"], + ], + "hydration should reuse the same batch across retries", + ); + assert.ok(service.getProfile("pk-retry-1")); + assert.ok(service.getProfile("pk-retry-2")); +}); + +test( + "CommentThreadService surfaces profile hydration failures after retries", + async () => { + const errors = []; + const userWarnings = []; + const devWarnings = []; + let attempts = 0; + + const service = new CommentThreadService({ + batchFetchProfiles: async (pubkeys) => { + attempts += 1; + throw new Error(`hydration failed attempt ${attempts}`); + }, + logger: { + warn: (...args) => userWarnings.push(args), + dev: { warn: (...args) => devWarnings.push(args) }, + user: { warn: (...args) => userWarnings.push(args) }, + }, + hydrationDebounceMs: 0, + }); + + service.setCallbacks({ onError: (error) => errors.push(error) }); + service.queueProfileForHydration("pk-retry-fail"); + + await tick(200); + await service.waitForProfileHydration(); + + assert.equal(attempts, 3, "hydration should respect the retry limit"); + assert.equal(errors.length, 1, "final failures should emit an error"); + assert.ok( + userWarnings.some(([message]) => + typeof message === "string" && message.includes("pk-retry-fail"), + ), + "user-visible warnings should include the failed pubkeys", + ); + assert.ok( + devWarnings.some(([message]) => + typeof message === "string" && message.includes("attempt 1/3") + ), + "dev warnings should log the retry attempts", + ); + }, +); + test( "listVideoComments accepts builder events without parent ids and filters replies", async () => { @@ -256,6 +748,7 @@ test( const hydrationRequests = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments, subscribeVideoComments, getProfileCacheEntry: (pubkey) => @@ -290,6 +783,7 @@ test( videoDefinitionAddress: "30078:authorpk:video-root", videoKind: "30078", videoAuthorPubkey: "authorpk", + rootIdentifier: "video-root", parentCommentId: "", }); assert.equal(lastFetchOptions.limit, service.defaultLimit); @@ -308,6 +802,7 @@ test( assert.equal(initialSnapshot.videoDefinitionAddress, "30078:authorpk:video-root"); assert.equal(initialSnapshot.videoKind, "30078"); assert.equal(initialSnapshot.videoAuthorPubkey, "authorpk"); + assert.equal(initialSnapshot.rootIdentifier, "video-root"); assert.equal(initialSnapshot.parentCommentKind, null); assert.equal(initialSnapshot.parentCommentPubkey, null); assert.deepStrictEqual(initialSnapshot.topLevelIds, [ @@ -358,6 +853,7 @@ test( assert.equal(appendPayload.parentCommentId, "comment-2"); assert.deepStrictEqual(appendPayload.commentIds, ["reply-2"]); assert.ok(appendPayload.commentsById instanceof Map); + assert.equal(appendPayload.rootIdentifier, "video-root"); assert.ok( appendPayload.commentsById.has("reply-2"), "appended payload should contain the new reply", @@ -401,6 +897,7 @@ test( const errors = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments: async (target, options) => { fetchTargets.push(target); fetchOptions.push(options); @@ -424,6 +921,7 @@ test( videoEventId: "video123", videoKind: "30078", videoAuthorPubkey: "authorpk", + rootIdentifier: "video-root", parentCommentId: "", }); assert.equal( @@ -457,6 +955,91 @@ test( }, ); +test( + "CommentThreadService requests and snapshots comments by root identifier", + async () => { + const video = { + id: "root-only-video", + pubkey: "rootpk", + kind: 30078, + videoRootId: "root-only", + tags: [], + }; + + const rootOnlyComment = { + id: "comment-root", + kind: COMMENT_EVENT_KIND, + pubkey: "commenter", + created_at: 1700000000, + content: "Root scoped", + tags: [["i", "root-only"], ["p", "rootpk"]], + }; + + let fetchTarget = null; + const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, + fetchVideoComments: async (target) => { + fetchTarget = target; + return [rootOnlyComment]; + }, + subscribeVideoComments: () => () => {}, + }); + + const threadReadyPayloads = []; + service.setCallbacks({ + onThreadReady: (payload) => threadReadyPayloads.push(payload), + }); + + const snapshot = await service.loadThread({ video }); + + assert.ok(fetchTarget, "fetch should run when only root id is provided"); + assert.equal(fetchTarget.rootIdentifier, "root-only"); + assert.equal(snapshot.rootIdentifier, "root-only"); + assert.equal(snapshot.commentsById.get("comment-root").content, "Root scoped"); + assert.equal(threadReadyPayloads[0].rootIdentifier, "root-only"); + }, +); + +test( + "CommentThreadService falls back to pointerIdentifiers root id", + async () => { + const video = { + id: "pointer-video", + pubkey: "rootpk", + kind: 30078, + tags: [], + pointerIdentifiers: { + videoRootId: "pointer-root", + }, + }; + + const pointerComment = { + id: "comment-pointer", + kind: COMMENT_EVENT_KIND, + pubkey: "commenter", + created_at: 1700000001, + content: "Pointer scoped", + tags: [["i", "pointer-root"], ["p", "rootpk"]], + }; + + let fetchTarget = null; + const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, + fetchVideoComments: async (target) => { + fetchTarget = target; + return [pointerComment]; + }, + subscribeVideoComments: () => () => {}, + }); + + const snapshot = await service.loadThread({ video }); + + assert.ok(fetchTarget, "fetch should run when pointer identifiers are provided"); + assert.equal(fetchTarget.rootIdentifier, "pointer-root"); + assert.equal(snapshot.rootIdentifier, "pointer-root"); + }, +); + test( "CommentThreadService preserves raw video author pubkeys during hydration fetches", async () => { @@ -498,6 +1081,7 @@ test( const subscribeTargets = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments, subscribeVideoComments: (target, _options = {}) => { subscribeTargets.push(target); @@ -555,6 +1139,7 @@ test( let subscriptionHandler = null; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments: async () => [], subscribeVideoComments: (_target, options = {}) => { subscriptionHandler = typeof options.onEvent === "function" ? options.onEvent : null; diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index d30959a6..f6bb3b66 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -69,6 +69,7 @@ test( async list(relays, filters) { assert.deepEqual(relays, ["wss://relay.one"]); assert.equal(filters.length, 1); + assert.deepEqual(filters[0].kinds, [30015, 30005]); assert.equal(filters[0]["#d"][0], "bitvid:tag-preferences"); return [ { @@ -244,6 +245,7 @@ test( assert.equal(publishedEvents.length, 1); const publishedEvent = publishedEvents[0].event; assert.deepEqual(publishedEvents[0].urls, ["wss://relay.publish"]); + assert.equal(publishedEvent.kind, 30015); const encryptedTag = publishedEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "encrypted", ); @@ -318,6 +320,7 @@ test( assert.ok(encryptCalls[1].plaintext.includes("windowtag")); assert.equal(publishedEvents.length, 1); assert.deepEqual(publishedEvents[0].urls, ["wss://relay.window"]); + assert.equal(publishedEvents[0].event.kind, 30015); const encryptedTag = publishedEvents[0].event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "encrypted", ); @@ -325,3 +328,86 @@ test( }, ); +test( + "load prefers canonical kind when timestamps match while accepting legacy payload", + { concurrency: false }, + async () => { + const pubkey = "f".repeat(64); + const decryptCalls = []; + + setActiveSigner({ + nip44Decrypt: async (target, ciphertext) => { + decryptCalls.push({ target, ciphertext }); + if (ciphertext === "canonical-cipher") { + return JSON.stringify({ + version: 2, + interests: ["NewKind"], + disinterests: [], + }); + } + if (ciphertext === "legacy-cipher") { + return JSON.stringify({ + version: 1, + interests: ["Legacy"], + disinterests: [], + }); + } + throw new Error(`unexpected ciphertext: ${ciphertext}`); + }, + }); + + nostrClient.pool = { + async list(relays, filters) { + assert.deepEqual(relays, ["wss://relay.tie"]); + assert.deepEqual(filters[0].kinds, [30015, 30005]); + return [ + { + id: "evt-canonical", + kind: 30015, + created_at: 500, + pubkey, + content: "canonical-cipher", + tags: [["encrypted", "nip44_v2"]], + }, + { + id: "evt-legacy", + kind: 30005, + created_at: 500, + pubkey, + content: "legacy-cipher", + tags: [["encrypted", "nip44_v2"]], + }, + ]; + }, + }; + nostrClient.relays = ["wss://relay.tie"]; + nostrClient.writeRelays = ["wss://relay.tie"]; + + await hashtagPreferences.load(pubkey); + + assert.deepEqual(decryptCalls.map((call) => call.ciphertext), [ + "canonical-cipher", + ]); + assert.deepEqual(hashtagPreferences.getInterests(), ["newkind"]); + + nostrClient.pool.list = async () => [ + { + id: "evt-legacy-only", + kind: 30005, + created_at: 600, + pubkey, + content: "legacy-cipher", + tags: [["encrypted", "nip44_v2"]], + }, + ]; + + await hashtagPreferences.load(pubkey); + + assert.deepEqual(decryptCalls.map((call) => call.ciphertext), [ + "canonical-cipher", + "legacy-cipher", + ]); + assert.deepEqual(hashtagPreferences.getInterests(), ["legacy"]); + }, +); + diff --git a/tests/moderation/trust-seeds.test.mjs b/tests/moderation/trust-seeds.test.mjs index 06901be9..ec81cf20 100644 --- a/tests/moderation/trust-seeds.test.mjs +++ b/tests/moderation/trust-seeds.test.mjs @@ -87,6 +87,37 @@ test("trusted seeds contribute to trusted report counts", (t) => { assert.deepEqual(reporters.map((entry) => entry.pubkey), [seedHex]); }); +test("trusted seed updates recompute guest report summaries", (t) => { + withMockedNostrTools(t); + + const eventId = "a".repeat(64); + const reporterHex = "b".repeat(64); + const reporterNpub = `npub${reporterHex}`; + + const { service } = createModerationServiceHarness(t); + + const report = createReportEvent({ + id: "c".repeat(64), + reporter: reporterHex, + eventId, + createdAt: 1_700_000_999, + type: "spam", + }); + + service.ingestReportEvent(report); + + const preSeedSummary = service.getTrustedReportSummary(eventId); + assert.equal(preSeedSummary.totalTrusted, 0); + assert.equal(preSeedSummary.types.spam?.trusted ?? 0, 0); + + service.setTrustedSeeds([reporterNpub]); + + const postSeedSummary = service.getTrustedReportSummary(eventId); + assert.equal(postSeedSummary.totalTrusted, 1); + assert.equal(postSeedSummary.types.spam.trusted, 1); + assert.equal(service.trustedReportCount(eventId, "spam"), 1); +}); + test("moderator seeds populate trusted contacts", (t) => { withMockedNostrTools(t); @@ -150,6 +181,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as }; const setTrustedSeedsCalls = []; + const recomputeCalls = []; const moderationServiceMock = { setTrustedSeeds: (seeds) => { const snapshot = []; @@ -160,12 +192,16 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as } setTrustedSeedsCalls.push(snapshot); }, + recomputeAllSummaries: () => { + recomputeCalls.push(true); + }, }; globalThis.__bootstrapAccessControlMock = accessControlMock; globalThis.__bootstrapModerationServiceMock = moderationServiceMock; - await import("../../js/bootstrap.js"); + const bootstrapModule = await import("../../js/bootstrap.js"); + await bootstrapModule.trustedSeedsReadyPromise; assert.equal(ensureReadyCalls, 1); assert.ok( @@ -174,6 +210,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as ); assert.equal(setTrustedSeedsCalls.length, 1); + assert.equal(recomputeCalls.length, 1, "summaries should recompute after trusted seeds apply"); const [initialSeeds] = setTrustedSeedsCalls; assert.ok( initialSeeds.includes(ADMIN_SUPER_NPUB), diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs index 7f8098af..4131bca4 100644 --- a/tests/nostr/comment-events.test.mjs +++ b/tests/nostr/comment-events.test.mjs @@ -555,6 +555,107 @@ test("listVideoComments builds filters with uppercase roots plus legacy fallback ); }); +test("listVideoComments adds lowercase root filters when only the identifier is known", async () => { + const matchingEvent = { + id: "comment-root", + kind: COMMENT_EVENT_KIND, + tags: [ + ["I", "root-only"], + ["P", "root-author"], + ["e", "video-rooted"], + ], + }; + + const { client, pool } = createMockClient(); + + let receivedFilters = null; + pool.list = async (relays, filters) => { + receivedFilters = { relays, filters }; + return [[matchingEvent]]; + }; + + const events = await listVideoComments( + client, + { + videoEventId: "video-rooted", + videoKind: COMMENT_EVENT_KIND, + videoAuthorPubkey: "root-author", + rootIdentifier: "root-only", + }, + { since: 1700000000, limit: 5 }, + ); + + assert.equal(events.length, 1, "matching comment should be returned"); + assert.ok(receivedFilters, "pool.list should receive filters"); + assert.equal( + receivedFilters.filters.length, + 4, + "root identifier requests should include lowercase fallbacks", + ); + + const [eventFilter, uppercaseEventFilter, lowercaseRootFilter, uppercaseRootFilter] = + receivedFilters.filters; + + assert.deepEqual( + eventFilter, + { + kinds: [COMMENT_EVENT_KIND], + "#e": ["video-rooted"], + since: 1700000000, + limit: 5, + }, + "event filter should continue targeting the video event id", + ); + + assert.deepEqual( + uppercaseEventFilter, + { + kinds: [COMMENT_EVENT_KIND], + "#E": ["video-rooted"], + since: 1700000000, + limit: 5, + }, + "uppercase event filter should continue targeting legacy #E pointers", + ); + + assert.deepEqual( + lowercaseRootFilter, + { + kinds: [COMMENT_EVENT_KIND], + "#i": ["root-only"], + since: 1700000000, + limit: 5, + }, + "root identifier filter should include lowercase #i tag queries", + ); + + assert.equal( + uppercaseRootFilter.kinds[0], + COMMENT_EVENT_KIND, + "uppercase root filter should continue targeting comment kind", + ); + assert.deepEqual( + uppercaseRootFilter["#I"], + ["root-only"], + "uppercase root filter should continue binding the root identifier via #I", + ); + assert.deepEqual( + uppercaseRootFilter["#P"], + ["root-author"], + "uppercase root filter should scope by author when available", + ); + assert.equal( + uppercaseRootFilter.since, + 1700000000, + "since option should propagate to uppercase root filter", + ); + assert.equal( + uppercaseRootFilter.limit, + 5, + "limit option should propagate to uppercase root filter", + ); +}); + test("listVideoComments supports legacy targets without a definition address", async () => { const legacyEvent = { id: "legacy-comment", diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 1888dd76..147b554b 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -120,6 +120,374 @@ test("VideoModal comment section toggles visibility and renders hydrated comment assert.equal(modal.commentsList.getAttribute("data-empty"), null); }); +test( + "Unauthenticated users can read comments while the composer stays disabled", + async (t) => { + const { document, modal, cleanup } = await setupModal(); + t.after(cleanup); + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 2, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(), + processIncomingEvent: () => {}, + }, + videoModal: modal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-login-required", enableComments: true }); + await Promise.resolve(); + + controller.handleCommentThreadReady({ + videoEventId: "video-login-required", + parentCommentId: null, + commentsById: new Map([ + [ + "guest-comment", + createCommentEvent({ + id: "guest-comment", + pubkey: "pk-guest", + content: "Looking forward to more uploads", + createdAt: 1700000010, + }), + ], + ]), + childrenByParent: new Map([[null, ["guest-comment"]]]), + profiles: new Map([["pk-guest", { name: "Guest" }]]), + }); + + const commentsRoot = document.querySelector("[data-comments-root]"); + assert.equal(commentsRoot.hasAttribute("hidden"), false); + assert.equal(commentsRoot.classList.contains("hidden"), false); + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "guest-comment"); + + assert.equal(modal.commentComposerState.reason, "login-required"); + assert.equal(modal.commentsInput?.disabled, true); + assert.equal(modal.commentsSubmitButton?.disabled, true); + + const disabledPlaceholder = document.querySelector( + "[data-comments-disabled-placeholder]", + ); + assert.equal(disabledPlaceholder.hasAttribute("hidden"), true); + assert.equal( + disabledPlaceholder.textContent.trim(), + "Comments have been turned off for this video.", + ); + }, +); + +test( + "Guest users render loaded thread snapshots before composer auth enforcement", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const snapshot = { + videoEventId: "video-loaded-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "loaded-comment", + createCommentEvent({ + id: "loaded-comment", + pubkey: "pk-loaded", + content: "Snapshot content shows up", + createdAt: 1700000100, + }), + ], + ]), + childrenByParent: new Map([[null, ["loaded-comment"]]]), + profiles: new Map([["pk-loaded", { name: "Loaded" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(snapshot), + }, + videoModal: modal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-loaded-snapshot", enableComments: true }); + await controller.modalCommentLoadPromise; + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "loaded-comment"); + + const statusText = + modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || + ""; + assert.equal(statusText, ""); + assert.equal(modal.commentComposerState.reason, "login-required"); + }, +); + +test( + "Thread ready snapshots force comments visible before composer gating", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const visibilityCalls = []; + const instrumentedModal = { + ...modal, + setCommentsVisibility: (isVisible) => { + visibilityCalls.push(isVisible); + modal.setCommentsVisibility(isVisible); + }, + }; + + const snapshot = { + videoEventId: "video-visibility-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "visible-comment", + createCommentEvent({ + id: "visible-comment", + pubkey: "pk-visible", + content: "Rendered as soon as thread is ready", + createdAt: 1700000300, + }), + ], + ]), + childrenByParent: new Map([[null, ["visible-comment"]]]), + profiles: new Map([["pk-visible", { name: "Visible" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(snapshot), + }, + videoModal: instrumentedModal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-visibility-snapshot", enableComments: true }); + await controller.modalCommentLoadPromise; + + assert.ok(visibilityCalls.length >= 2); + assert.equal(visibilityCalls.pop(), true); + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "visible-comment"); + }, +); + +test( + "Controller renders synchronous loadThread snapshots immediately", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const snapshot = { + videoEventId: "video-sync-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "sync-comment", + createCommentEvent({ + id: "sync-comment", + pubkey: "pk-sync", + content: "Snapshot delivered instantly", + createdAt: 1700000200, + }), + ], + ]), + childrenByParent: new Map([[null, ["sync-comment"]]]), + profiles: new Map([["pk-sync", { name: "Synchronous" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => snapshot, + }, + videoModal: modal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-sync-snapshot", enableComments: true }); + await Promise.resolve(); + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "sync-comment"); + + const statusText = + modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || + ""; + assert.equal(statusText, ""); + assert.equal(modal.commentComposerState.reason, "login-required"); + }, +); + +test( + "VideoModalCommentController attaches profiles using normalized pubkeys", + async () => { + const appended = []; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(), + }, + videoModal: { + setCommentSectionCallbacks: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + setCommentsVisibility: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + appendComment: (comment) => appended.push(comment), + }, + auth: { + isLoggedIn: () => true, + }, + }); + + controller.load({ id: "VideoCase", enableComments: true }); + await Promise.resolve(); + + controller.handleCommentThreadAppend({ + videoEventId: "VideoCase", + commentsById: new Map([ + [ + "c1", + { + id: "c1", + pubkey: "AbCd", + kind: COMMENT_EVENT_KIND, + content: "Hi", + tags: [], + }, + ], + ]), + commentIds: ["c1"], + profiles: new Map([["ABCD", { name: "Mixed Case" }]]), + }); + + assert.equal(appended.length, 1); + assert.deepEqual(appended[0].profile, { name: "Mixed Case" }); + }, +); + +test( + "VideoModalCommentController accepts mixed-case videoEventIds for snapshots", + async () => { + const renderedSnapshots = []; + const appended = []; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(), + processIncomingEvent: () => {}, + }, + videoModal: { + setCommentSectionCallbacks: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + setCommentsVisibility: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: (snapshot) => renderedSnapshots.push(snapshot), + appendComment: (comment) => appended.push(comment), + }, + auth: { + isLoggedIn: () => true, + }, + utils: { + normalizeHexPubkey: (value) => value, + }, + }); + + controller.modalCommentState.videoEventId = "VideoCaseMixed"; + + const snapshot = { + videoEventId: "videocasemixed", + parentCommentId: null, + commentsById: new Map([ + [ + "comment-1", + { + id: "comment-1", + pubkey: "pk1", + kind: COMMENT_EVENT_KIND, + content: "First!", + tags: [], + }, + ], + ]), + childrenByParent: new Map([[null, ["comment-1"]]]), + profiles: new Map(), + }; + + controller.handleCommentThreadReady(snapshot); + + const payload = { + videoEventId: " VIDEOCASEMIXED ", + commentsById: new Map([ + [ + "comment-2", + { + id: "comment-2", + pubkey: "pk2", + kind: COMMENT_EVENT_KIND, + content: "Second!", + tags: [], + }, + ], + ]), + commentIds: ["comment-2"], + profiles: new Map(), + }; + + controller.handleCommentThreadAppend(payload); + + assert.equal(renderedSnapshots.length, 1, "snapshot should render despite casing"); + assert.equal(appended.length, 1, "append should accept trimmed, mixed-case IDs"); + }, +); + test("VideoModal comment composer updates messaging and dispatches events", async (t) => { const { window, document, modal, cleanup } = await setupModal(); t.after(cleanup); @@ -219,9 +587,6 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); const video = { @@ -230,6 +595,8 @@ test( enableComments: true, kind: 30078, tags: [["d", "video-root"]], + videoRootId: "video-root", + videoRootRelay: "wss://root.example", }; controller.load(video); @@ -240,8 +607,10 @@ test( assert.equal(publishCalls.length, 1, "publishComment should be called once"); const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); - assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); - assert.equal(payload.videoDefinitionAddress, "30078:AUTHORPK:video-root"); + assert.equal(payload.videoAuthorPubkey, "authorpk"); + assert.equal(payload.videoDefinitionAddress, "30078:authorpk:video-root"); + assert.equal(payload.rootIdentifier, "video-root"); + assert.equal(payload.rootIdentifierRelay, "wss://root.example"); }, ); @@ -277,9 +646,6 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); const video = { @@ -287,12 +653,16 @@ test( pubkey: "AUTHORPK", enableComments: true, kind: 30078, + videoRootId: "video-root", + videoRootRelay: "wss://root.example", // No tags provided; buildVideoAddressPointer should return an empty string. }; controller.load(video); controller.modalCommentState.videoDefinitionAddress = - " 30078:AUTHORPK:video-root "; + " 30078:authorpk:video-root "; + controller.modalCommentState.videoRootId = " video-root "; + controller.modalCommentState.videoRootRelay = " wss://root.example "; controller.submit({ text: "Fallback pointer" }); @@ -302,9 +672,168 @@ test( const payload = publishCalls[0]; assert.equal( payload.videoDefinitionAddress, - "30078:AUTHORPK:video-root", + "30078:authorpk:video-root", "publish payload should reuse pointer from the loaded thread", ); + assert.equal(payload.rootIdentifier, "video-root"); + assert.equal(payload.rootIdentifierRelay, "wss://root.example"); + }, +); + +test( + "VideoModalCommentController preserves videoRootId when only root pointer is provided", + async () => { + const publishCalls = []; + const loadTargets = []; + let threadReady = null; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: ({ onThreadReady }) => { + threadReady = onThreadReady; + }, + teardown: () => {}, + defaultLimit: 10, + loadThread: async (target) => { + loadTargets.push(target); + return {}; + }, + processIncomingEvent: () => {}, + }, + videoModal: { + setCommentSectionCallbacks: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + setCommentsVisibility: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: () => {}, + }, + auth: { + isLoggedIn: () => true, + }, + services: { + publishComment: async (payload) => { + publishCalls.push(payload); + return { ok: true, event: { id: "comment", tags: [] } }; + }, + }, + }); + + controller.commentThreadServiceReady = threadReady; + + const video = { + id: "video789", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + videoRootId: "root-only", + videoRootRelay: "wss://root-only", + tags: [], + }; + + controller.load(video); + await Promise.resolve(); + + assert.equal(loadTargets.length, 1, "loadThread should be invoked once"); + assert.equal(loadTargets[0]?.video?.videoRootId, "root-only"); + assert.equal(loadTargets[0]?.video?.videoRootRelay, "wss://root-only"); + + const commentEvent = { + id: "comment-root", + pubkey: "commenter", + kind: COMMENT_EVENT_KIND, + content: "Root pointer only", + tags: [["I", "root-only"], ["p", "AUTHORPK"]], + }; + + controller.handleCommentThreadReady({ + videoEventId: "video789", + parentCommentId: null, + rootIdentifier: "root-only", + rootIdentifierRelay: "wss://root-only", + commentsById: new Map([["comment-root", commentEvent]]), + childrenByParent: new Map([[null, ["comment-root"]]]), + profiles: new Map(), + }); + + controller.submit({ text: "Posting" }); + await controller.modalCommentPublishPromise; + + assert.equal(publishCalls.length, 1, "publishComment should use root pointer"); + const payload = publishCalls[0]; + assert.equal(payload.rootIdentifier, "root-only"); + assert.equal(payload.rootIdentifierRelay, "wss://root-only"); + }, +); + +test( + "VideoModalCommentController uses pointerIdentifiers root id fallback", + async () => { + const publishCalls = []; + const loadTargets = []; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: () => {}, + teardown: () => {}, + defaultLimit: 10, + loadThread: async (target) => { + loadTargets.push(target); + return {}; + }, + processIncomingEvent: () => {}, + }, + videoModal: { + setCommentSectionCallbacks: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + setCommentsVisibility: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: () => {}, + }, + auth: { + isLoggedIn: () => true, + }, + services: { + publishComment: async (payload) => { + publishCalls.push(payload); + return { ok: true, event: { id: "comment", tags: [] } }; + }, + }, + }); + + const video = { + id: "pointer-video", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + pointerIdentifiers: { + videoRootId: "pointer-root", + }, + }; + + controller.load(video); + await Promise.resolve(); + + assert.equal(loadTargets.length, 1, "loadThread should capture pointer target"); + assert.equal( + loadTargets[0]?.video?.pointerIdentifiers?.videoRootId, + "pointer-root", + "pointerIdentifiers should carry the normalized root id", + ); + + controller.submit({ text: "Pointer fallback" }); + await controller.modalCommentPublishPromise; + + assert.equal(publishCalls.length, 1, "publishComment should run once"); + const payload = publishCalls[0]; + assert.equal(payload.rootIdentifier, "pointer-root"); }, ); @@ -359,9 +888,6 @@ test( }; }, }, - utils: { - normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, - }, }); controller.currentVideo = { @@ -381,9 +907,9 @@ test( videoEventId: "legacy-video", parentCommentId: null, videoKind: "30078", - videoAuthorPubkey: "AUTHORPK", + videoAuthorPubkey: "authorpk", rootKind: "30078", - rootAuthorPubkey: "AUTHORPK", + rootAuthorPubkey: "authorpk", }); assert.equal( "videoDefinitionAddress" in payload, @@ -407,6 +933,113 @@ test( }, ); +test( + "VideoModalCommentController disposes safely during in-flight comment loads", + async () => { + const renderCalls = []; + const appendCalls = []; + const teardownCalls = []; + const setCallbacksCalls = []; + let onThreadReady = null; + let onCommentsAppended = null; + let resolveLoadThread; + + const commentThreadService = { + defaultLimit: 5, + setCallbacks: ({ onThreadReady: ready, onCommentsAppended: appended }) => { + onThreadReady = ready; + onCommentsAppended = appended; + }, + loadThread: () => + new Promise((resolve) => { + resolveLoadThread = resolve; + }), + teardown: () => { + teardownCalls.push("teardown"); + }, + processIncomingEvent: () => {}, + }; + + const videoModal = { + setCommentSectionCallbacks: (callbacks) => { + setCallbacksCalls.push(callbacks); + }, + setCommentsVisibility: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: (payload) => renderCalls.push(payload), + appendComment: (payload) => appendCalls.push(payload), + }; + + const controller = new VideoModalCommentController({ + commentThreadService, + videoModal, + auth: { isLoggedIn: () => true }, + callbacks: { + showError: () => {}, + showStatus: () => {}, + muteAuthor: () => Promise.resolve(), + shouldHideAuthor: () => false, + }, + services: { + publishComment: async () => ({ ok: true, event: { id: "event", tags: [] } }), + }, + }); + + controller.load({ + id: "video-inflight", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + tags: [], + }); + + await Promise.resolve(); + teardownCalls.length = 0; // ignore the initial dispose inside load + + controller.dispose(); + + assert.equal(teardownCalls.length, 1, "dispose should teardown the active thread"); + assert.equal( + setCallbacksCalls.length > 0, + true, + "comment callbacks should be registered even before load resolves", + ); + + resolveLoadThread?.(); + await Promise.resolve(); + + onThreadReady?.({ + videoEventId: "video-inflight", + parentCommentId: null, + commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), + childrenByParent: new Map([[null, ["event-1"]]]), + profiles: new Map(), + }); + + assert.equal(renderCalls.length, 0, "stale thread payloads should be ignored after dispose"); + + onCommentsAppended?.({ + videoEventId: "video-inflight", + parentCommentId: null, + commentIds: ["event-1"], + commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), + childrenByParent: new Map([[null, ["event-1"]]]), + profiles: new Map(), + }); + + assert.equal( + appendCalls.length, + 0, + "stale appends should not render duplicate comments after dispose", + ); + }, +); + test( "VideoModalCommentController prompts login when publish requires authentication", async () => { @@ -541,9 +1174,6 @@ test( return { ok: true, event: { id: "reply", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, - }, }); controller.currentVideo = { @@ -557,6 +1187,8 @@ test( videoDefinitionAddress: null, videoKind: "30078", videoAuthorPubkey: "AUTHORPK", + videoRootId: null, + videoRootRelay: null, parentCommentId: "parent-1", parentCommentKind: String(COMMENT_EVENT_KIND), parentCommentPubkey: null, @@ -571,12 +1203,12 @@ test( const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); assert.equal(payload.videoKind, "30078"); - assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); + assert.equal(payload.videoAuthorPubkey, "authorpk"); assert.equal(payload.parentCommentId, "parent-1"); assert.equal(payload.parentCommentKind, String(COMMENT_EVENT_KIND)); assert.equal(payload.parentCommentPubkey, "parentpk"); assert.equal(payload.parentAuthorPubkey, "parentpk"); - assert.equal(payload.rootAuthorPubkey, "AUTHORPK"); + assert.equal(payload.rootAuthorPubkey, "authorpk"); }, );