This commit is contained in:
thePR0M3TH3AN committed 2026-01-03 13:39:01 -05:00
1 parent 071d78dd6c
commit 93ffe552a5
28 files changed
+3011 -330

No files matched your search

+2 -1
View File
@@ -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', <scheme>]` 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】
+2 -2
View File
@@ -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.
+58
View File
@@ -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));
+2 -1
View File
@@ -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
+40 -4
View File
@@ -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', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, 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', <hex>], …]`) |
| 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', <pubkey>]` entries | Empty content |
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` 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,
+12 -1
View File
@@ -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({
+108 -11
View File
@@ -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,
+11 -11
View File
@@ -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();
+541 -82
View File
@@ -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) => {
+7
View File
@@ -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
+31 -32
View File
@@ -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,
+2 -2
View File
@@ -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,
+10
View File
@@ -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();
+255 -68
View File
@@ -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();
+73 -28
View File
@@ -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
+17 -5
View File
@@ -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();
+1
View File
@@ -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…");
@@ -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;
}
}
+141 -40
View File
@@ -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;
}
+11
View File
@@ -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;
+2 -1
View File
@@ -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 "";
}
+10 -10
View File
@@ -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,
+53
View File
@@ -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));
+586 -1
View File
@@ -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;
+86
View File
@@ -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"]);
},
);
+38 -1
View File
@@ -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),
+101
View File
@@ -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",
+652 -20
View File
@@ -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");
},
);