mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
update
This commit is contained in:
1 parent
071d78dd6c
commit
93ffe552a5
28 files changed
+3011
-330
No files matched your search
@@ -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】
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Vendored
+108
-11
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user