From 48410b1abb4e2aa51f62197faabff9e3c6155f1d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 2 Oct 2025 11:43:15 -0400
Subject: [PATCH 1/3] chore: remove watch history logic
---
docs/nostr-analytics.md | 12 +-
docs/nostr-event-schemas.md | 18 +-
docs/watch-history-logging.md | 14 +-
js/app.js | 95 +-
js/historyView.js | 1594 +---------------
js/index.js | 41 -
js/nostr.js | 3394 +--------------------------------
tests/view-counter.test.mjs | 7 -
tests/watch-history.test.mjs | 1662 ----------------
9 files changed, 109 insertions(+), 6728 deletions(-)
delete mode 100644 tests/watch-history.test.mjs
diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md
index e181e8fb..4ba6fe1b 100644
--- a/docs/nostr-analytics.md
+++ b/docs/nostr-analytics.md
@@ -18,7 +18,7 @@ Playback telemetry runs even when a viewer has not connected a Nostr account. Th
When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing.
-`recordVideoView` no longer mutates watch history—it strictly publishes analytics events. Clients that want personal playback timelines must independently call [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) once their threshold logic fires. See the [watch history pipeline](#watch-history-pipeline) for integration details.
+The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing.
To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts.
@@ -28,13 +28,3 @@ Every view event includes a unique `#d` tag derived from the pointer scope, the
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
-## Watch history pipeline
-
-BitVid ships a parallel watch-history system so operators can disable analytics entirely while still giving viewers a private "continue watching" shelf. [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) gathers the latest pointer plus any cached entries, normalizes them, and persists an encrypted snapshot through [`publishWatchHistorySnapshot`](../js/nostr.js#L2170-L2584). Each publish produces:
-
-- A monotonic index event that references the active snapshot ID and every chunk address.
-- One or more encrypted chunk events containing `{ type, value, relay?, watchedAt }` tuples for each pointer. `watchedAt` is truncated to seconds and defaults to `Date.now()` when the caller omits it.
-
-Snapshots dedupe pointers, cap the list at the configured maximum, and are signed by the same session actor used for views. Clients should supply the same pointer structure they feed into [`recordVideoView`](../js/nostr.js#L4516-L4555)—typically `{ type: 'a', value:
, relay }`—while setting a `watchedAt` timestamp if they want to preserve historical ordering during backfills.
-
-Because view counting and watch history are intentionally decoupled, invoking `updateWatchHistoryList` is the caller's responsibility. Most integrations fire it immediately after `recordVideoView` confirms success, but privacy-focused deployments can disable view logging and still call the watch-history API. Cross-check the [publish latency expectations](#publish-latency-expectations) section when wiring both flows so operators understand they can operate independently.
diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md
index 9316ee3d..d7c8dc67 100644
--- a/docs/nostr-event-schemas.md
+++ b/docs/nostr-event-schemas.md
@@ -49,8 +49,8 @@ read/write split.
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a', ]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
-| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a', ]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` |
-| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
+| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`, legacy) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a', ]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` |
+| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`, legacy) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content |
@@ -63,16 +63,4 @@ builders inherit the same debugging knobs.
### Watch history identifiers
-Watch-history snapshots now publish a dedicated index event with
-`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history`). The
-index advertises the active snapshot id, total chunk count, and a pointer to
-each chunk via `a` tags so clients can fetch the encrypted payloads without
-guessing identifiers. Deployments that previously used
-`watch-history:v2:index` continue to resolve thanks to the legacy identifier
-list.
-
-Every chunk event uses a unique `d` tag such as
-`watch-history:v2//`. Legacy snapshots created before the
-index event shipped stored the first chunk at `#d watch-history`; new clients
-should continue reading that identifier for backward compatibility when
-hydrating older histories.
+The watch history publishing pipeline has been retired. These identifiers remain documented so existing relays can keep serving legacy payloads, but new clients no longer emit or refresh watch-history events.
diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md
index b5cf1e89..78f354a8 100644
--- a/docs/watch-history-logging.md
+++ b/docs/watch-history-logging.md
@@ -1,15 +1,3 @@
# Watch History Logging Cheatsheet
-The watch history publisher emits developer-mode console messages from `publishWatchHistorySnapshot()`.
-
-- `Watch history snapshot updated` (info) — all chunks and the index event were accepted by at least one relay. The summary includes:
- - `actor`: abbreviated pubkey that authored the snapshot.
- - `itemCount`: number of watch history entries that made it into the snapshot payload.
- - `snapshot`: identifier constructed from the current unix timestamp and a random suffix.
- - `latestPointer`: truncated pointer key for the newest watch entry that survived chunking.
- - `latestWatchedAt`: ISO timestamp derived from the most recent entry's `watchedAt` (falls back to `Date.now()` when missing).
-- `Watch history snapshot publish incomplete` (warning) — at least one chunk or the index event failed to reach any relay. When this fires the snapshot is still cached locally and the client will retry publication later.
-
-Errors that precede the info log do **not** necessarily indicate a failure. For example, a message like `WebSocket connection to 'ws://127.0.0.1:4869/' failed` simply means one relay in the user's configured relay list is offline. The publisher only requires that at least one relay accepts each chunk before reporting success.
-
-Refer to the log source in `js/nostr.js` for the exact structure and retry behavior.
+The legacy watch history publisher has been removed. Clients no longer emit watch-history snapshot events, and related console logs have been retired alongside the feature.
diff --git a/js/app.js b/js/app.js
index 57b5bd45..cdc147a9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1,11 +1,7 @@
// js/app.js
import { loadView } from "./viewManager.js";
-import {
- nostrClient,
- recordVideoView,
- updateWatchHistoryList,
-} from "./nostr.js";
+import { nostrClient, recordVideoView } from "./nostr.js";
import { torrentClient } from "./webtorrent.js";
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
import { accessControl, normalizeNpub } from "./accessControl.js";
@@ -4289,29 +4285,6 @@ class bitvidApp {
}
button.dataset.navBound = "true";
button.addEventListener("click", () => {
- if (name === "history") {
- let actorCandidate;
- if (typeof this.pubkey === "string" && this.pubkey.trim()) {
- actorCandidate = this.pubkey.trim();
- } else if (
- typeof window?.app?.pubkey === "string" &&
- window.app.pubkey.trim()
- ) {
- actorCandidate = window.app.pubkey.trim();
- }
-
- void nostrClient
- .fetchWatchHistory(actorCandidate, { forceRefresh: true })
- .catch((error) => {
- if (isDevMode) {
- console.warn(
- "[profileModal] Failed to prefetch watch history on navigation:",
- error
- );
- }
- });
- }
-
this.selectProfilePane(name);
});
});
@@ -6987,10 +6960,6 @@ class bitvidApp {
clearTimeout(state.viewTimerId);
}
- if (state.historyTimerId) {
- clearTimeout(state.historyTimerId);
- }
-
if (Array.isArray(state.handlers) && state.videoEl) {
for (const { eventName, handler } of state.handlers) {
try {
@@ -7085,7 +7054,6 @@ class bitvidApp {
}
const VIEW_THRESHOLD_SECONDS = 12;
- const WATCH_HISTORY_THRESHOLD_SECONDS = 10;
const state = {
videoEl,
pointer,
@@ -7093,15 +7061,7 @@ class bitvidApp {
viewerIdentityKey,
handlers: [],
viewTimerId: null,
- historyTimerId: null,
viewFired: false,
- historyFired: false,
- };
-
- const maybeCleanup = () => {
- if (state.viewFired && state.historyFired) {
- this.cancelPendingViewLogging();
- }
};
const clearTimer = (timerKey) => {
@@ -7118,7 +7078,7 @@ class bitvidApp {
}
state.viewFired = true;
clearTimer("view");
- maybeCleanup();
+ this.cancelPendingViewLogging();
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
state;
@@ -7171,41 +7131,6 @@ class bitvidApp {
});
};
- const finalizeWatchHistory = () => {
- if (state.historyFired) {
- return;
- }
- state.historyFired = true;
- clearTimer("history");
- maybeCleanup();
-
- const { pointer: thresholdPointer } = state;
-
- (async () => {
- try {
- const historyResult = await updateWatchHistoryList(thresholdPointer);
- if (!historyResult?.ok) {
- console.warn(
- "[playVideoWithFallback] Watch history update failed:",
- historyResult
- );
- }
- } catch (historyError) {
- console.warn(
- "[playVideoWithFallback] Exception while updating watch history:",
- historyError
- );
- }
- })().catch((error) => {
- if (isDevMode) {
- console.warn(
- "[playVideoWithFallback] Unexpected error while updating watch history:",
- error
- );
- }
- });
- };
-
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
const firedKey = `${timerKey}Fired`;
const idKey = `${timerKey}TimerId`;
@@ -7232,12 +7157,6 @@ class bitvidApp {
};
registerHandler("timeupdate", () => {
- if (
- !state.historyFired &&
- videoEl.currentTime >= WATCH_HISTORY_THRESHOLD_SECONDS
- ) {
- finalizeWatchHistory();
- }
if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) {
finalizeView();
}
@@ -7247,9 +7166,6 @@ class bitvidApp {
if (!state.viewFired) {
clearTimer("view");
}
- if (!state.historyFired) {
- clearTimer("history");
- }
};
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
@@ -7257,14 +7173,9 @@ class bitvidApp {
);
const resumeIfNeeded = () => {
- if (state.viewFired && state.historyFired) {
+ if (state.viewFired) {
return;
}
- scheduleTimer(
- "history",
- WATCH_HISTORY_THRESHOLD_SECONDS,
- finalizeWatchHistory
- );
scheduleTimer("view", VIEW_THRESHOLD_SECONDS, finalizeView);
};
diff --git a/js/historyView.js b/js/historyView.js
index ff40eff1..4d025f50 100644
--- a/js/historyView.js
+++ b/js/historyView.js
@@ -1,1584 +1,132 @@
// js/historyView.js
-import { nostrClient } from "./nostr.js";
-import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js";
-import { subscriptions } from "./subscriptions.js";
-import { accessControl } from "./accessControl.js";
-import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
-import {
- toNumber,
- getWatchedAtScore,
- compareByWatchedAtDesc,
-} from "./watchHistorySort.js";
-
-const DEFAULT_BATCH_SIZE = 20;
-const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1;
-
export const WATCH_HISTORY_EMPTY_COPY =
"Your watch history is empty. Watch some videos to populate this list.";
-const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…";
-const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet.";
-
-const WATCH_HISTORY_SHARED_UI_CACHE = new Map();
-
-function normalizeActorCacheKey(actor) {
- if (typeof actor !== "string") {
- return "";
- }
-
- const trimmed = actor.trim();
- if (!trimmed) {
- return "";
- }
-
- if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
- return trimmed.toLowerCase();
- }
-
- return trimmed;
-}
-
-function cloneWatchHistoryVideosForCache(videos) {
- if (!Array.isArray(videos)) {
- return [];
- }
-
- if (typeof structuredClone === "function") {
- try {
- return structuredClone(videos);
- } catch (error) {
- // Fall back to a shallow clone below if structuredClone is unavailable
- // or throws (for example, when encountering unsupported values).
- }
- }
-
- return videos.map((video) => {
- if (!video || typeof video !== "object") {
- return video;
- }
-
- const clone = { ...video };
-
- if (video.watchHistory && typeof video.watchHistory === "object") {
- clone.watchHistory = { ...video.watchHistory };
- }
-
- if (Array.isArray(video.tags)) {
- clone.tags = video.tags.map((tag) =>
- Array.isArray(tag) ? tag.slice() : tag
- );
- }
-
- return clone;
- });
-}
-
-function getSharedUiSnapshot(actor) {
- const key = normalizeActorCacheKey(actor);
- if (!key) {
+function resolveElement(selector) {
+ if (!selector || typeof selector !== "string") {
return null;
}
-
- const entry = WATCH_HISTORY_SHARED_UI_CACHE.get(key);
- if (!entry) {
+ try {
+ return document.querySelector(selector);
+ } catch (error) {
+ console.warn("[historyView] Failed to query selector:", selector, error);
return null;
}
-
- return {
- resolvedVideos: cloneWatchHistoryVideosForCache(entry.resolvedVideos),
- hasMore: entry.hasMore !== false,
- snapshotFingerprint:
- typeof entry.snapshotFingerprint === "string" && entry.snapshotFingerprint
- ? entry.snapshotFingerprint
- : null,
- updatedAt: entry.updatedAt || 0,
- };
}
-function setSharedUiSnapshot(actor, snapshot) {
- const key = normalizeActorCacheKey(actor);
- if (!key) {
+function setHidden(element, hidden) {
+ if (!(element instanceof HTMLElement)) {
return;
}
-
- const normalized = {
- resolvedVideos: cloneWatchHistoryVideosForCache(snapshot.resolvedVideos),
- hasMore: snapshot.hasMore !== false,
- snapshotFingerprint:
- typeof snapshot.snapshotFingerprint === "string" &&
- snapshot.snapshotFingerprint
- ? snapshot.snapshotFingerprint
- : null,
- updatedAt: Date.now(),
- };
-
- WATCH_HISTORY_SHARED_UI_CACHE.set(key, normalized);
+ element.classList.toggle("hidden", hidden);
+ if (hidden) {
+ element.setAttribute("aria-hidden", "true");
+ } else {
+ element.removeAttribute("aria-hidden");
+ }
}
-function getVideoAuthorIdentifiers(video) {
- if (!video || typeof video !== "object") {
- return { pubkey: "", npub: "" };
- }
-
- const extractString = (candidate) =>
- typeof candidate === "string" && candidate.trim() ? candidate.trim() : "";
-
- const candidateNpubs = [
- extractString(video.npub),
- extractString(video.authorNpub),
- extractString(video?.author?.npub),
- extractString(video?.watchHistory?.npub),
- extractString(video?.watchHistory?.npubEncoded),
- extractString(video?.watchHistory?.authorNpub),
- extractString(video?.watchHistory?.ownerNpub),
- ];
-
- let pubkey = extractString(video.pubkey);
- if (!pubkey && video?.author && typeof video.author === "object") {
- pubkey = extractString(video.author.pubkey);
- }
-
- let npub = candidateNpubs.find((value) => value) || "";
-
- if (!npub) {
- if (pubkey && pubkey.startsWith("npub")) {
- npub = pubkey;
- } else if (pubkey && window?.NostrTools?.nip19?.npubEncode) {
- try {
- npub = window.NostrTools.nip19.npubEncode(pubkey);
- } catch (error) {
- npub = "";
- }
- }
- }
-
- if (!pubkey && npub && window?.NostrTools?.nip19?.decode) {
- try {
- const decoded = window.NostrTools.nip19.decode(npub);
- if (decoded?.type === "npub") {
- if (typeof decoded?.data === "string" && decoded.data) {
- pubkey = decoded.data;
- } else if (
- decoded?.data &&
- typeof decoded.data === "object" &&
- typeof decoded.data.hex === "string"
- ) {
- pubkey = decoded.data.hex;
- }
- }
- } catch (error) {
- pubkey = "";
- }
- }
-
- return { pubkey, npub };
-}
-
-function isVideoBlocked(video) {
- const { pubkey, npub } = getVideoAuthorIdentifiers(video);
-
- if (pubkey && typeof window?.app?.isAuthorBlocked === "function") {
- try {
- if (window.app.isAuthorBlocked(pubkey)) {
- return true;
- }
- } catch (error) {
- console.warn("[historyView] Failed to check personal block list:", error);
- }
- }
-
- if (npub && typeof accessControl?.isBlacklisted === "function") {
- try {
- if (accessControl.isBlacklisted(npub)) {
- return true;
- }
- } catch (error) {
- console.warn("[historyView] Failed to check access blacklist:", error);
- }
- }
-
- return false;
-}
-
-function filterAccessibleVideos(videos) {
- if (!Array.isArray(videos)) {
- return [];
- }
-
- return videos.filter((video) => {
- if (!video || typeof video !== "object") {
- return false;
- }
-
- return !isVideoBlocked(video);
- });
-}
-
-function getAbsoluteShareUrl(nevent) {
- if (!nevent) {
- return "";
- }
-
- if (window.app?.buildShareUrlFromNevent) {
- const candidate = window.app.buildShareUrlFromNevent(nevent);
- if (candidate) {
- return candidate;
- }
- }
-
- const origin = window.location?.origin || "";
- const pathname = window.location?.pathname || "";
- let base = origin || pathname ? `${origin}${pathname}` : "";
- if (!base) {
- const href = window.location?.href || "";
- base = href ? href.split(/[?#]/)[0] : "";
- }
-
- if (!base) {
- return `?v=${encodeURIComponent(nevent)}`;
- }
-
- return `${base}?v=${encodeURIComponent(nevent)}`;
-}
-
-function renderWatchHistoryGrid(videos, containerOrElement) {
- let container = null;
- if (typeof containerOrElement === "string") {
- container = document.getElementById(containerOrElement);
- } else if (containerOrElement instanceof Element) {
- container = containerOrElement;
- }
-
- if (!container) {
+function setTextContent(element, text) {
+ if (!(element instanceof HTMLElement)) {
return;
}
-
- const safeVideos = Array.isArray(videos) ? videos : [];
- const dedupedVideos =
- window.app?.dedupeVideosByRoot?.(safeVideos) ??
- subscriptions.dedupeToNewestByRoot(safeVideos);
-
- const filteredVideos = filterAccessibleVideos(dedupedVideos);
-
- if (!filteredVideos.length) {
- container.innerHTML = `
-
- No videos available yet.
-
`;
- return;
- }
-
- const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
- const fragment = document.createDocumentFragment();
- const localAuthorSet = new Set();
-
- filteredVideos.forEach((video, index) => {
- if (!video.id || !video.title) {
- console.error("Missing ID or title:", video);
- return;
- }
-
- window.app?.videosMap?.set(video.id, video);
-
- localAuthorSet.add(video.pubkey);
-
- const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
- const shareUrl = getAbsoluteShareUrl(nevent);
- const canEdit = window.app?.pubkey === video.pubkey;
-
- const highlightClass =
- video.isPrivate && canEdit ? "watch-history-card--private" : "";
-
- const timeAgo = window.app?.formatTimeAgo
- ? window.app.formatTimeAgo(video.created_at)
- : new Date(video.created_at * 1000).toLocaleString();
-
- const watchedAtTimestamp = toNumber(
- video?.watchHistory?.watchedAt,
- null
- );
- let watchedAtLabel = "Watch date unavailable";
- if (Number.isFinite(watchedAtTimestamp) && watchedAtTimestamp > 0) {
- const watchedDate = new Date(watchedAtTimestamp);
- if (!Number.isNaN(watchedDate.getTime())) {
- try {
- watchedAtLabel = `Watched on ${watchedDate.toLocaleString(undefined, {
- dateStyle: "medium",
- timeStyle: "short",
- })}`;
- } catch (err) {
- watchedAtLabel = `Watched on ${watchedDate.toLocaleString()}`;
- }
- }
- }
-
- const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
- const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
- const playbackUrl = typeof video.url === "string" ? video.url : "";
- const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
- const trimmedMagnet =
- typeof video.magnet === "string" ? video.magnet.trim() : "";
- const legacyInfoHash =
- typeof video.infoHash === "string" ? video.infoHash.trim() : "";
- const magnetCandidate = trimmedMagnet || legacyInfoHash;
- const playbackMagnet = magnetCandidate;
- const magnetProvided = magnetCandidate.length > 0;
- const watchHistoryKey = video?.watchHistory?.key || "";
- const cardHtml = `
-
-
-
-
-
-
-
-
-
- ${safeTitle}
-
-
${timeAgo}
-
-
-
-
${watchedAtLabel}
-
-
-
-
-
- Loading name...
-
-
-
-
- View
-
-
- Share
-
-
- Remove
-
-
-
-
- `;
-
- const t = document.createElement("template");
- t.innerHTML = cardHtml.trim();
- const cardEl = t.content.firstElementChild;
- if (cardEl) {
- cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
- if (typeof video.pubkey === "string" && video.pubkey) {
- cardEl.dataset.ownerPubkey = video.pubkey;
- } else if (cardEl.dataset.ownerPubkey) {
- delete cardEl.dataset.ownerPubkey;
- }
-
- const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
- interactiveEls.forEach((el) => {
- if (!el.dataset) return;
-
- if (trimmedUrl) {
- el.dataset.playUrl = encodeURIComponent(trimmedUrl);
- } else {
- delete el.dataset.playUrl;
- }
-
- el.dataset.playMagnet = playbackMagnet || "";
- });
-
- if (magnetProvided) {
- cardEl.dataset.magnet = playbackMagnet;
- } else if (cardEl.dataset.magnet) {
- delete cardEl.dataset.magnet;
- }
-
- }
- fragment.appendChild(cardEl);
- });
-
- container.innerHTML = "";
- container.appendChild(fragment);
-
- window.app?.attachVideoListHandler?.();
-
- const lazyEls = container.querySelectorAll("[data-lazy]");
- lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
-
- const authorPics = container.querySelectorAll(".author-pic");
- const authorNames = container.querySelectorAll(".author-name");
- const creatorButtons = container.querySelectorAll(
- ".watch-history-card__creatorAvatar"
- );
-
- authorPics.forEach((pic) => {
- localAuthorSet.add(pic.getAttribute("data-pubkey"));
- });
- authorNames.forEach((nameEl) => {
- localAuthorSet.add(nameEl.getAttribute("data-pubkey"));
- });
- creatorButtons.forEach((btn) => {
- localAuthorSet.add(btn.getAttribute("data-pubkey"));
- });
-
- if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) {
- window.app.batchFetchProfiles(localAuthorSet);
- }
-
- authorPics.forEach((pic) => {
- if (!pic.dataset.watchHistoryClickAttached) {
- pic.dataset.watchHistoryClickAttached = "true";
- pic.style.cursor = "pointer";
- pic.addEventListener("click", (ev) => {
- ev.preventDefault();
- ev.stopPropagation();
- const pubkey = pic.getAttribute("data-pubkey");
- window.app?.goToProfile(pubkey);
- });
- }
- });
-
- creatorButtons.forEach((btn) => {
- if (!btn.dataset.watchHistoryClickAttached) {
- btn.dataset.watchHistoryClickAttached = "true";
- btn.addEventListener("click", (ev) => {
- ev.preventDefault();
- ev.stopPropagation();
- const pubkey = btn.getAttribute("data-pubkey");
- window.app?.goToProfile(pubkey);
- });
- }
- });
-
- authorNames.forEach((nameEl) => {
- if (!nameEl.dataset.watchHistoryClickAttached) {
- nameEl.dataset.watchHistoryClickAttached = "true";
- nameEl.style.cursor = "pointer";
- nameEl.addEventListener("click", (ev) => {
- ev.preventDefault();
- ev.stopPropagation();
- const pubkey = nameEl.getAttribute("data-pubkey");
- window.app?.goToProfile(pubkey);
- });
- }
- });
+ element.textContent = text;
}
export function createWatchHistoryRenderer(config = {}) {
- const fallbackGetActor = async () => {
- const candidate =
- typeof window?.app?.pubkey === "string" && window.app.pubkey.trim()
- ? window.app.pubkey.trim()
- : "";
- return candidate || undefined;
- };
-
- const mergedConfig = {
- viewSelector: "#watchHistoryView",
- gridSelector: "#watchHistoryGrid",
- loadingSelector: "#watchHistoryLoading",
- statusSelector: "#watchHistoryStatus",
- emptySelector: "#watchHistoryEmpty",
- sentinelSelector: "#watchHistorySentinel",
- scrollContainerSelector: null,
- emptyCopy: WATCH_HISTORY_EMPTY_COPY,
- batchSize: BATCH_SIZE,
- getActor: fallbackGetActor,
- beforeInitialLoad: null,
- getSnapshotFingerprint: null,
- resolveBatch: (size) => nostrClient.resolveWatchHistory(size),
- renderGrid: null,
- ...config,
- };
-
const {
- viewSelector,
- gridSelector,
- loadingSelector,
- statusSelector,
- emptySelector,
- sentinelSelector,
- scrollContainerSelector,
- emptyCopy,
- batchSize,
- getActor,
- resolveBatch,
- renderGrid,
- } = mergedConfig;
-
- const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad;
- const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint;
-
- let authChangeListener = null;
- let authRefreshPromise = null;
- let scheduleAuthRefresh = null;
-
- const normalizeActorHint = (actorHint) => {
- if (typeof actorHint === "string") {
- const trimmed = actorHint.trim();
- if (trimmed) {
- return trimmed;
- }
- }
- return null;
- };
-
- const attachAuthChangeListener = () => {
- if (authChangeListener || typeof window === "undefined") {
- return;
- }
-
- authChangeListener = (event) => {
- const detail = event?.detail || {};
- const actorHint =
- typeof detail.pubkey === "string" ? detail.pubkey : null;
- try {
- scheduleAuthRefresh?.(actorHint ?? null);
- } catch (error) {
- console.warn("[historyView] Failed to schedule auth refresh:", error);
- }
- };
-
- window.addEventListener("bitvid:auth-changed", authChangeListener);
- };
-
- const detachAuthChangeListener = () => {
- if (!authChangeListener || typeof window === "undefined") {
- return;
- }
-
- window.removeEventListener("bitvid:auth-changed", authChangeListener);
- authChangeListener = null;
- };
-
- const selectors = {
- view: viewSelector,
- grid: gridSelector,
- loading: loadingSelector,
- status: statusSelector,
- empty: emptySelector,
- sentinel: sentinelSelector,
- scrollContainer: scrollContainerSelector,
- };
-
- const LOG_PREFIX = "[historyView]";
-
- const debugLog = (...args) => {
- if (typeof console !== "undefined") {
- if (typeof console.debug === "function") {
- console.debug(LOG_PREFIX, ...args);
- return;
- }
- if (typeof console.log === "function") {
- console.log(LOG_PREFIX, ...args);
- }
- }
- };
-
- const resolveActor = async (hint = null) => {
- if (typeof hint === "string" && hint.trim()) {
- return hint.trim();
- }
-
- if (typeof getActor === "function") {
- try {
- const candidate = await getActor();
- if (typeof candidate === "string" && candidate.trim()) {
- return candidate.trim();
- }
- } catch (error) {
- debugLog("failed to resolve actor via getActor", error);
- }
- }
-
- return null;
- };
-
- const callBeforeInitialLoad = async (context = {}) => {
- let actor = await resolveActor(context.actor ?? null);
- const finalContext = { ...context, actor };
-
- if (typeof providedBeforeInitialLoad === "function") {
- return providedBeforeInitialLoad(finalContext);
- }
-
- const snapshot = await nostrClient.fetchWatchHistory(actor || undefined);
-
- if (!actor) {
- try {
- const ensured = await nostrClient.ensureSessionActor();
- if (typeof ensured === "string" && ensured.trim()) {
- actor = ensured.trim();
- }
- } catch (error) {
- debugLog("failed to ensure session actor while resolving history", error);
- }
- }
-
- return { actor: actor || null, snapshot };
- };
-
- const computeSnapshotFingerprint = async (context = {}) => {
- const actor = await resolveActor(context.actor ?? null);
- const finalContext = { ...context, actor };
-
- if (typeof providedGetSnapshotFingerprint === "function") {
- return providedGetSnapshotFingerprint(finalContext);
- }
-
- if (finalContext.refresh) {
- await nostrClient.fetchWatchHistory(actor || undefined);
- }
-
- return nostrClient.getWatchHistoryFingerprint(actor || undefined);
- };
+ viewSelector = "#watchHistoryView",
+ gridSelector = "#watchHistoryGrid",
+ loadingSelector = "#watchHistoryLoading",
+ statusSelector = "#watchHistoryStatus",
+ emptySelector = "#watchHistoryEmpty",
+ emptyCopy = WATCH_HISTORY_EMPTY_COPY,
+ } = config;
const state = {
- isLoading: false,
- hasMore: true,
- observer: null,
- scrollListener: null,
- resolvedVideos: [],
initialized: false,
- emptyCopy,
- batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)),
+ resolvedVideos: [],
+ hasMore: false,
+ isLoading: false,
actor: null,
- snapshotFingerprint: null,
- gridEventHandler: null,
};
- const applyAccessFilters = () => {
- if (!Array.isArray(state.resolvedVideos)) {
- state.resolvedVideos = [];
- return state.resolvedVideos;
- }
-
- const beforeLength = state.resolvedVideos.length;
- const filtered = filterAccessibleVideos(state.resolvedVideos);
-
- if (filtered.length !== beforeLength) {
- debugLog("filtered resolved videos due to block settings", {
- before: beforeLength,
- after: filtered.length,
- });
- }
-
- state.resolvedVideos = filtered;
- return state.resolvedVideos;
+ let elements = {
+ view: null,
+ grid: null,
+ loading: null,
+ status: null,
+ empty: null,
};
- const query = (selector) => {
- if (!selector) {
- return null;
- }
- try {
- return document.querySelector(selector);
- } catch (error) {
- console.warn("[historyView] Failed to query selector:", selector, error);
- return null;
- }
- };
-
- const getElements = () => ({
- view: query(selectors.view),
- grid: query(selectors.grid),
- loadingEl: query(selectors.loading),
- statusEl: query(selectors.status),
- emptyEl: query(selectors.empty),
- sentinel: query(selectors.sentinel),
- scrollContainer: query(selectors.scrollContainer),
- });
-
- const setStatusMessage = (message = "") => {
- const { statusEl } = getElements();
- if (!statusEl) {
- return;
- }
-
- const nextMessage = typeof message === "string" ? message.trim() : "";
- if (nextMessage) {
- const showSpinner = nextMessage === WATCH_HISTORY_LOADING_STATUS;
- statusEl.innerHTML = getSidebarLoadingMarkup(nextMessage, {
- showSpinner,
- });
- statusEl.classList.remove("hidden");
- } else {
- statusEl.textContent = "";
- statusEl.innerHTML = "";
- statusEl.classList.add("hidden");
- }
- };
-
- const clearStatusMessage = () => {
- setStatusMessage("");
- };
-
- const setLoadingVisible = (visible, { statusMessage } = {}) => {
- const { loadingEl } = getElements();
- if (loadingEl) {
- if (visible) {
- loadingEl.classList.remove("hidden");
- } else {
- loadingEl.classList.add("hidden");
- }
- }
-
- if (visible) {
- const message =
- typeof statusMessage === "string" && statusMessage.trim()
- ? statusMessage
- : WATCH_HISTORY_LOADING_STATUS;
- setStatusMessage(message);
- return;
- }
-
- if (typeof statusMessage === "string") {
- setStatusMessage(statusMessage);
- } else {
- clearStatusMessage();
- }
- };
-
- const resetUiState = () => {
- const { grid, emptyEl } = getElements();
- if (grid) {
- grid.innerHTML = "";
- grid.classList.add("hidden");
- }
- if (emptyEl) {
- emptyEl.textContent = state.emptyCopy;
- emptyEl.classList.add("hidden");
- }
- setLoadingVisible(true);
- };
-
- const cleanupObservers = () => {
- if (state.observer) {
- debugLog("disconnecting intersection observer");
- state.observer.disconnect();
- state.observer = null;
- }
- if (state.scrollListener?.target && state.scrollListener?.handler) {
- debugLog("removing scroll listener");
- state.scrollListener.target.removeEventListener(
- "scroll",
- state.scrollListener.handler
- );
- state.scrollListener = null;
- }
- };
-
- const cleanupGridHandler = () => {
- if (!state.gridEventHandler) {
- return;
- }
- const { grid } = getElements();
- if (grid) {
- grid.removeEventListener("click", state.gridEventHandler);
- }
- state.gridEventHandler = null;
- };
-
- const showEmptyState = (message = state.emptyCopy) => {
- const { grid, emptyEl } = getElements();
- if (grid) {
- grid.innerHTML = "";
- grid.classList.add("hidden");
- }
- if (emptyEl) {
- emptyEl.textContent = message;
- emptyEl.classList.remove("hidden");
- }
- const shouldShowEmptyStatus = message === state.emptyCopy;
- const statusMessage = shouldShowEmptyStatus ? WATCH_HISTORY_EMPTY_STATUS : "";
- setLoadingVisible(false, { statusMessage });
- state.hasMore = false;
- cleanupObservers();
- debugLog("showing empty state", { message });
- persistSharedUiState();
- };
-
- const mergeResolvedVideos = (nextVideos) => {
- const ingest = (map, video) => {
- if (!video || typeof video !== "object" || !video.id) {
- return;
- }
-
- const existing = map.get(video.id);
- if (!existing) {
- map.set(video.id, video);
- return;
- }
-
- const existingScore = getWatchedAtScore(existing);
- const nextScore = getWatchedAtScore(video);
-
- if (nextScore > existingScore) {
- map.set(video.id, video);
- return;
- }
-
- if (nextScore === existingScore) {
- const existingCreated = toNumber(
- existing?.created_at,
- Number.NEGATIVE_INFINITY
- );
- const nextCreated = toNumber(
- video?.created_at,
- Number.NEGATIVE_INFINITY
- );
-
- if (nextCreated > existingCreated) {
- map.set(video.id, video);
- }
- }
- };
-
- if (!Array.isArray(state.resolvedVideos)) {
- state.resolvedVideos = [];
- }
-
- if (!Array.isArray(nextVideos) || !nextVideos.length) {
- applyAccessFilters();
- return;
- }
-
- const dedupeMap = new Map();
- state.resolvedVideos.forEach((video) => ingest(dedupeMap, video));
- nextVideos.forEach((video) => ingest(dedupeMap, video));
-
- state.resolvedVideos = Array.from(dedupeMap.values()).sort(
- compareByWatchedAtDesc
- );
- applyAccessFilters();
- persistSharedUiState();
- };
-
- const ensureGridEventHandlers = () => {
- const { grid } = getElements();
- if (!grid || state.gridEventHandler) {
- return;
- }
-
- const showRemoveSpinner = (button) => {
- if (!button || !button.dataset) {
- return;
- }
-
- if (button.dataset.originalHtml === undefined) {
- button.dataset.originalHtml = button.innerHTML;
- }
-
- if (button.dataset.originalAriaLabel === undefined) {
- button.dataset.originalAriaLabel = button.getAttribute("aria-label") || "";
- }
-
- button.innerHTML =
- ' ';
- button.setAttribute("aria-label", "Removing from watch history");
- };
-
- const restoreRemoveButton = (button) => {
- if (!button || !button.dataset) {
- return;
- }
-
- const originalHtml = button.dataset.originalHtml;
- if (typeof originalHtml === "string") {
- button.innerHTML = originalHtml;
- }
-
- const originalAria = button.dataset.originalAriaLabel || "";
- if (originalAria) {
- button.setAttribute("aria-label", originalAria);
- } else {
- button.removeAttribute("aria-label");
- }
-
- delete button.dataset.originalHtml;
- delete button.dataset.originalAriaLabel;
- };
-
- const handler = async (event) => {
- const removeTrigger = event.target.closest(
- "[data-history-remove-key]"
- );
- if (removeTrigger) {
- event.preventDefault();
- event.stopPropagation();
- if (removeTrigger.disabled) {
- return;
- }
-
- const targetKey = removeTrigger
- .getAttribute("data-history-remove-key")
- ?.trim();
- const targetEventId = removeTrigger
- .getAttribute("data-history-remove-event-id")
- ?.trim();
- if (!targetKey) {
- window.app?.showError?.(
- "Unable to remove this video from watch history."
- );
- return;
- }
-
- removeTrigger.disabled = true;
- removeTrigger.setAttribute("aria-busy", "true");
- showRemoveSpinner(removeTrigger);
-
- try {
- const result = await nostrClient.removeWatchHistoryItem(targetKey);
- if (!result?.ok) {
- const errorMessage =
- typeof result?.error === "string" && result.error
- ? result.error
- : "remove-failed";
- throw new Error(errorMessage);
- }
-
- state.resolvedVideos = state.resolvedVideos.filter((video) => {
- const key = video?.watchHistory?.key || "";
- if (key) {
- return key !== targetKey;
- }
- if (targetEventId) {
- return video?.id !== targetEventId;
- }
- return true;
- });
-
- applyAccessFilters();
-
- setLoadingVisible(false);
- renderResolvedVideos();
- persistSharedUiState();
- window.app?.showSuccess?.("Removed from watch history.");
- } catch (error) {
- console.error(
- "[historyView] Failed to remove watch history item:",
- error
- );
- removeTrigger.disabled = false;
- window.app?.showError?.(
- "Failed to remove this video from watch history."
- );
- } finally {
- if (removeTrigger.isConnected) {
- restoreRemoveButton(removeTrigger);
- removeTrigger.removeAttribute("aria-busy");
- }
- }
-
- return;
- }
-
- const shareTrigger = event.target.closest("[data-history-share]");
- if (shareTrigger) {
- event.preventDefault();
- event.stopPropagation();
-
- const shareUrl = shareTrigger.getAttribute("data-share-url") || "";
- const shareTitle = shareTrigger.getAttribute("data-share-title") || "";
- const eventId = shareTrigger.getAttribute("data-event-id") || "";
-
- const normalizedTitle = shareTitle || "Watch on Bitvid";
-
- try {
- if (shareUrl && navigator.share) {
- await navigator.share({ title: normalizedTitle, url: shareUrl });
- return;
- }
-
- if (window.app?.handleMoreMenuAction) {
- await window.app.handleMoreMenuAction("copy-link", { eventId });
- return;
- }
-
- if (shareUrl && navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(shareUrl);
- window.app?.showSuccess?.("Video link copied to clipboard!");
- return;
- }
-
- if (shareUrl) {
- window.open(shareUrl, "_blank", "noopener,noreferrer");
- return;
- }
-
- window.app?.showError?.("Unable to share this video right now.");
- } catch (error) {
- if (error?.name === "AbortError") {
- return;
- }
- console.error(
- "[historyView] Failed to share watch history item:",
- error
- );
- window.app?.showError?.("Unable to share this video right now.");
- }
- }
- };
-
- grid.addEventListener("click", handler);
- state.gridEventHandler = handler;
- };
-
- const renderResolvedVideos = () => {
- const { grid, emptyEl } = getElements();
- if (!grid) {
- return;
- }
-
- const accessibleVideos = applyAccessFilters();
-
- if (!accessibleVideos.length) {
- if (emptyEl) {
- emptyEl.textContent = state.emptyCopy;
- emptyEl.classList.remove("hidden");
- }
- grid.classList.add("hidden");
- return;
- }
-
- if (typeof renderGrid === "function") {
- renderGrid(accessibleVideos, grid);
- } else {
- renderWatchHistoryGrid(accessibleVideos, grid);
- }
-
- ensureGridEventHandlers();
-
- grid.classList.remove("hidden");
- if (emptyEl) {
- emptyEl.classList.add("hidden");
- }
- };
-
- const persistSharedUiState = () => {
- if (!state.actor) {
- return;
- }
-
- setSharedUiSnapshot(state.actor, {
- resolvedVideos: state.resolvedVideos,
- hasMore: state.hasMore,
- snapshotFingerprint: state.snapshotFingerprint,
- });
- };
-
- const hydrateFromSharedUiCache = () => {
- if (!state.actor) {
- return {
- hydrated: false,
- sharedHasMore: false,
- fingerprintMatches: false,
- };
- }
-
- const snapshot = getSharedUiSnapshot(state.actor);
- if (!snapshot) {
- return {
- hydrated: false,
- sharedHasMore: false,
- fingerprintMatches: false,
- };
- }
-
- state.resolvedVideos = Array.isArray(snapshot.resolvedVideos)
- ? snapshot.resolvedVideos
- : [];
- state.hasMore = snapshot.hasMore !== false;
-
- applyAccessFilters();
-
- const nextFingerprint =
- typeof snapshot.snapshotFingerprint === "string" &&
- snapshot.snapshotFingerprint
- ? snapshot.snapshotFingerprint
- : null;
-
- let fingerprintMatches = true;
- if (
- typeof state.snapshotFingerprint === "string" &&
- state.snapshotFingerprint &&
- nextFingerprint &&
- nextFingerprint !== state.snapshotFingerprint
- ) {
- fingerprintMatches = false;
- }
-
- if (!state.snapshotFingerprint && nextFingerprint) {
- state.snapshotFingerprint = nextFingerprint;
- }
-
- if (state.resolvedVideos.length) {
- setLoadingVisible(false);
- renderResolvedVideos();
- }
-
- return {
- hydrated: true,
- sharedHasMore: state.hasMore,
- fingerprintMatches,
+ const refreshElements = () => {
+ elements = {
+ view: resolveElement(viewSelector),
+ grid: resolveElement(gridSelector),
+ loading: resolveElement(loadingSelector),
+ status: resolveElement(statusSelector),
+ empty: resolveElement(emptySelector),
};
};
- const loadNextBatch = async ({ initial = false } = {}) => {
- if (state.isLoading || !state.hasMore) {
- debugLog("skipping loadNextBatch", {
- reason: state.isLoading ? "already loading" : "no more items",
- initial,
- resolvedCount: state.resolvedVideos.length,
- });
- if (initial && !state.isLoading && state.resolvedVideos.length === 0) {
- setLoadingVisible(false);
- }
- return;
- }
-
- setLoadingVisible(true);
- state.isLoading = true;
- debugLog("loading next batch", {
- initial,
- batchSize: state.batchSize,
- resolvedCount: state.resolvedVideos.length,
- });
-
- try {
- const videos = await resolveBatch(state.batchSize);
- debugLog("resolveBatch completed", {
- initial,
- received: Array.isArray(videos) ? videos.length : "non-array",
- });
-
- if (!Array.isArray(videos) || videos.length === 0) {
- if (initial && state.resolvedVideos.length === 0) {
- showEmptyState();
- } else {
- state.hasMore = false;
- cleanupObservers();
- setLoadingVisible(false);
- debugLog("no videos returned", {
- initial,
- resolvedCount: state.resolvedVideos.length,
- });
- persistSharedUiState();
- }
- return;
- }
-
- mergeResolvedVideos(videos);
- renderResolvedVideos();
- setLoadingVisible(false);
- persistSharedUiState();
- debugLog("rendered videos", {
- totalResolved: state.resolvedVideos.length,
- initial,
- });
- } catch (error) {
- console.error("[historyView] Failed to resolve watch history:", error);
- if (initial && state.resolvedVideos.length === 0) {
- showEmptyState(
- "We couldn't load your watch history. Please try again later."
- );
- }
- } finally {
- state.isLoading = false;
- debugLog("loadNextBatch finished", {
- initial,
- hasMore: state.hasMore,
- resolvedCount: state.resolvedVideos.length,
- });
+ const hideLoading = () => {
+ setHidden(elements.loading, true);
+ if (elements.status) {
+ setHidden(elements.status, true);
}
};
- const attachObservers = () => {
- const { sentinel, scrollContainer } = getElements();
- if (!sentinel || !state.hasMore) {
- debugLog("skipping observer attachment", {
- hasSentinel: Boolean(sentinel),
- hasMore: state.hasMore,
- });
- return;
+ const showEmpty = () => {
+ if (elements.grid) {
+ elements.grid.innerHTML = "";
+ setHidden(elements.grid, true);
}
-
- if ("IntersectionObserver" in window) {
- debugLog("attaching intersection observer", {
- useScrollContainer: Boolean(scrollContainer),
- });
- state.observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- debugLog("sentinel intersected");
- loadNextBatch();
- }
- });
- },
- {
- root: scrollContainer || null,
- rootMargin: scrollContainer ? "200px 0px" : "300px 0px",
- }
- );
- state.observer.observe(sentinel);
- return;
+ if (elements.empty) {
+ setTextContent(elements.empty, emptyCopy);
+ setHidden(elements.empty, false);
}
-
- const target = scrollContainer || window;
- const handler = () => {
- const rect = sentinel.getBoundingClientRect();
- const threshold = scrollContainer
- ? scrollContainer.clientHeight
- : window.innerHeight;
- if (rect.top <= threshold + 200) {
- debugLog("scroll threshold met");
- loadNextBatch();
- }
- };
-
- target.addEventListener("scroll", handler, { passive: true });
- state.scrollListener = { target, handler };
- handler();
- debugLog("attached scroll listener", {
- useScrollContainer: Boolean(scrollContainer),
- });
};
- scheduleAuthRefresh = (actorHint) => {
- const normalizedActor = normalizeActorHint(actorHint);
-
- const perform = async () => {
- const nextActor = normalizedActor || null;
-
- try {
- const { view } = getElements();
- if (!view) {
- state.actor = nextActor;
- state.snapshotFingerprint = null;
- state.initialized = false;
- state.resolvedVideos = [];
- state.hasMore = true;
- state.isLoading = false;
- cleanupObservers();
- return;
- }
-
- await runInitialLoad({ actor: nextActor });
- } catch (error) {
- console.error(
- "[historyView] Failed to refresh watch history after auth change:",
- error
- );
- }
- };
-
- const pendingRefresh =
- authRefreshPromise !== null
- ? authRefreshPromise.then(perform, perform)
- : perform();
-
- let currentPromise;
- const finalize = () => {
- if (authRefreshPromise === currentPromise) {
- authRefreshPromise = null;
- }
- };
-
- currentPromise = pendingRefresh.finally(finalize);
- authRefreshPromise = currentPromise;
- return currentPromise;
+ const resetUi = () => {
+ refreshElements();
+ hideLoading();
+ showEmpty();
};
- const runInitialLoad = async (options = {}) => {
- const { actor: actorHint = null, prefetched = null, fingerprintOverride } =
- typeof options === "object" && options !== null ? options : {};
-
- const { view } = getElements();
- if (!view) {
- debugLog("runInitialLoad aborted: view not found");
- return;
- }
-
- const resolvedActor = await resolveActor(actorHint ?? state.actor);
- state.actor = resolvedActor || null;
-
- cleanupObservers();
- state.resolvedVideos = [];
- state.hasMore = true;
- state.isLoading = false;
- state.initialized = false;
- state.snapshotFingerprint = null;
- resetUiState();
- debugLog("starting initial load", { actor: state.actor });
-
- let beforeResult;
- const hasPrefetched =
- prefetched &&
- Object.prototype.hasOwnProperty.call(prefetched, "beforeResult");
-
- if (hasPrefetched) {
- beforeResult = prefetched.beforeResult;
- } else {
- try {
- beforeResult = await callBeforeInitialLoad({ actor: state.actor });
- } catch (error) {
- console.error("[historyView] Failed to fetch watch history list:", error);
- showEmptyState(
- "We couldn't load your watch history. Please try again later."
- );
- return;
- }
- }
-
- if (
- beforeResult &&
- typeof beforeResult === "object" &&
- typeof beforeResult.actor === "string" &&
- beforeResult.actor.trim()
- ) {
- state.actor = beforeResult.actor.trim();
- }
-
- if (state.actor) {
- nostrClient.resetWatchHistoryProgress(state.actor);
- }
-
- let fingerprint;
- if (typeof fingerprintOverride !== "undefined") {
- fingerprint = fingerprintOverride;
- } else {
- try {
- fingerprint = await computeSnapshotFingerprint({
- actor: state.actor,
- beforeResult,
- refresh: false,
- });
- } catch (error) {
- debugLog("failed to compute snapshot fingerprint", error);
- }
- }
-
- if (typeof fingerprint === "string") {
- state.snapshotFingerprint = fingerprint;
- } else {
- state.snapshotFingerprint = null;
- }
-
- let sharedHydration = null;
- try {
- sharedHydration = hydrateFromSharedUiCache();
- } catch (error) {
- debugLog("failed to hydrate shared watch history state", error);
- }
-
- if (sharedHydration?.hydrated) {
- state.hasMore = true;
- }
-
- try {
- await accessControl.ensureReady();
- } catch (error) {
- console.warn(
- "[historyView] Access control not ready, proceeding with cached lists:",
- error
- );
- }
-
- await loadNextBatch({ initial: true });
-
- if (state.hasMore) {
- attachObservers();
- }
-
- state.initialized = true;
- debugLog("initial load complete", {
- hasMore: state.hasMore,
- resolvedCount: state.resolvedVideos.length,
- actor: state.actor,
- });
- };
-
- attachAuthChangeListener();
-
return {
async init() {
- debugLog("init called");
- await runInitialLoad();
+ resetUi();
+ state.initialized = true;
+ state.resolvedVideos = [];
+ state.hasMore = false;
+ state.isLoading = false;
},
async ensureInitialLoad() {
- const { view } = getElements();
- if (!view) {
- debugLog("ensureInitialLoad aborted: view not found");
- return;
- }
-
if (!state.initialized) {
- debugLog("ensureInitialLoad rerunning initial load (not initialized)");
- await runInitialLoad();
+ await this.init();
return;
}
-
- state.actor = (await resolveActor(state.actor)) || null;
-
- if (!state.isLoading) {
- try {
- const beforeResult = await callBeforeInitialLoad({
- actor: state.actor,
- reason: "ensureInitialLoad",
- });
- if (
- beforeResult &&
- typeof beforeResult === "object" &&
- typeof beforeResult.actor === "string" &&
- beforeResult.actor.trim()
- ) {
- state.actor = beforeResult.actor.trim();
- }
- const fingerprint = await computeSnapshotFingerprint({
- actor: state.actor,
- beforeResult,
- refresh: false,
- });
-
- if (typeof fingerprint === "string") {
- if (fingerprint !== state.snapshotFingerprint) {
- debugLog("ensureInitialLoad detected new snapshot", {
- previous: state.snapshotFingerprint,
- next: fingerprint,
- actor: state.actor,
- });
- await runInitialLoad({
- actor: state.actor,
- prefetched: { beforeResult },
- fingerprintOverride: fingerprint,
- });
- return;
- }
- state.snapshotFingerprint = fingerprint;
- }
- } catch (error) {
- console.warn(
- "[historyView] Failed to refresh watch history snapshot:",
- error
- );
- }
- } else {
- debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", {
- actor: state.actor,
- });
- }
-
- if (!state.resolvedVideos.length && !state.isLoading) {
- if (!state.hasMore) {
- debugLog("ensureInitialLoad resetting hasMore before retry");
- state.hasMore = true;
- }
- debugLog("ensureInitialLoad requesting new batch", {
- hasMore: state.hasMore,
- });
- await loadNextBatch({ initial: true });
-
- if (state.hasMore && !state.observer && !state.scrollListener) {
- debugLog("ensureInitialLoad reattaching observers");
- attachObservers();
- }
- } else {
- debugLog("ensureInitialLoad no action", {
- resolvedCount: state.resolvedVideos.length,
- isLoading: state.isLoading,
- hasMore: state.hasMore,
- });
- }
+ resetUi();
},
async loadMore() {
- debugLog("loadMore called");
- await loadNextBatch();
- },
- resume() {
- cleanupObservers();
- if (state.hasMore) {
- debugLog("resume attaching observers");
- attachObservers();
- }
- },
- pause() {
- debugLog("pause called");
- cleanupObservers();
+ return [];
},
+ resume() {},
+ pause() {},
destroy() {
- debugLog("destroy called");
- cleanupObservers();
- cleanupGridHandler();
- detachAuthChangeListener();
state.initialized = false;
state.resolvedVideos = [];
- state.hasMore = true;
+ state.hasMore = false;
state.isLoading = false;
- state.actor = null;
- state.snapshotFingerprint = null;
- resetUiState();
- setLoadingVisible(false);
+ hideLoading();
+ showEmpty();
},
- async refresh(actorHint = null) {
- await scheduleAuthRefresh(actorHint);
+ async refresh() {
+ await this.ensureInitialLoad();
},
render() {
- renderResolvedVideos();
+ resetUi();
},
getState() {
return {
diff --git a/js/index.js b/js/index.js
index dfb19a99..7f623e7b 100644
--- a/js/index.js
+++ b/js/index.js
@@ -2,45 +2,6 @@
import "./bufferPolyfill.js";
import { trackPageView } from "./analytics.js";
-import { nostrClient } from "./nostr.js";
-
-const HISTORY_PREFETCH_DEBOUNCE_MS = 250;
-
-function prefetchWatchHistory(options = {}) {
- if (typeof nostrClient?.fetchWatchHistory !== "function") {
- return;
- }
-
- const force = options.force === true;
- const now = Date.now();
-
- if (typeof window !== "undefined") {
- const last =
- typeof window.__bitvidLastHistoryPrefetch === "number"
- ? window.__bitvidLastHistoryPrefetch
- : 0;
-
- if (!force && now - last < HISTORY_PREFETCH_DEBOUNCE_MS) {
- return;
- }
-
- window.__bitvidLastHistoryPrefetch = now;
- }
-
- let actor;
- if (typeof window?.app?.pubkey === "string" && window.app.pubkey.trim()) {
- actor = window.app.pubkey.trim();
- }
-
- void nostrClient
- .fetchWatchHistory(actor, { forceRefresh: true })
- .catch((error) => {
- console.warn(
- "[index] Failed to prefetch watch history during navigation:",
- error
- );
- });
-}
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
@@ -298,7 +259,6 @@ export function setHashView(viewName) {
window.history.replaceState({}, "", newUrl);
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
- prefetchWatchHistory({ force: true });
}
handleHashChange();
@@ -448,7 +408,6 @@ async function handleHashChange() {
const viewName = match[1]; // only the chunk before any '&'
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
- prefetchWatchHistory();
}
const viewUrl = `views/${viewName}.html`;
diff --git a/js/nostr.js b/js/nostr.js
index 548eb0e1..328a40e6 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -2,14 +2,6 @@
import {
isDevMode,
- WATCH_HISTORY_KIND,
- WATCH_HISTORY_LIST_IDENTIFIER,
- WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
- WATCH_HISTORY_MAX_ITEMS,
- WATCH_HISTORY_BATCH_RESOLVE,
- WATCH_HISTORY_PAYLOAD_MAX_BYTES,
- WATCH_HISTORY_FETCH_EVENT_LIMIT,
- WATCH_HISTORY_CACHE_TTL_MS,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
VIEW_COUNT_BACKFILL_MAX_DAYS,
} from "./config.js";
@@ -25,8 +17,8 @@ import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildViewEvent,
- buildWatchHistoryIndexEvent,
- buildWatchHistoryChunkEvent,
+ getNostrEventSchema,
+ NOTE_TYPES,
} from "./nostrEventSchemas.js";
import {
publishEventToRelay,
@@ -55,100 +47,15 @@ const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again.";
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
-const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
-const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER =
- typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
- ? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase()
- : "";
-
-// Continue reading historical payloads from kind 30078 until operators retire it.
-const LEGACY_WATCH_HISTORY_KINDS = Object.freeze([30078]);
-
-const WATCH_HISTORY_READ_KINDS = Object.freeze(
- Array.from(
- new Set(
- [WATCH_HISTORY_KIND, ...LEGACY_WATCH_HISTORY_KINDS]
- .map((value) => Number.parseInt(value, 10))
- .filter((value) => Number.isFinite(value))
- )
- )
-);
-
-const WATCH_HISTORY_CHUNK_V2_PREFIX = (() => {
- const rawIdentifier =
- typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
- ? WATCH_HISTORY_LIST_IDENTIFIER.trim()
- : "";
-
- if (rawIdentifier) {
- const trimmed = rawIdentifier.replace(/\/+$/, "");
- if (/[:/]index$/i.test(trimmed)) {
- const base = trimmed.replace(/[:/]index$/i, "").replace(/\/+$/, "");
- return base || "watch-history:v2";
- }
-
- if (/:v\d+$/i.test(trimmed)) {
- return trimmed;
- }
-
- return `${trimmed}:v2`;
- }
-
- return "watch-history:v2";
-})();
-
-const LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => {
- const rawIdentifier =
- typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
- ? WATCH_HISTORY_LIST_IDENTIFIER.trim()
- : "";
- const suffixes = [":index", "/index"];
- for (const suffix of suffixes) {
- if (rawIdentifier.toLowerCase().endsWith(suffix)) {
- return rawIdentifier
- .slice(0, rawIdentifier.length - suffix.length)
- .replace(/\/+$/, "");
- }
- }
- return rawIdentifier.replace(/\/+$/, "") || "watch-history";
-})();
-
-const WATCH_HISTORY_CHUNK_V2_PREFIX_LOWER = WATCH_HISTORY_CHUNK_V2_PREFIX.toLowerCase();
-
-const LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER =
- LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX
- ? LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX.toLowerCase()
- : "";
-
-const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => {
- const identifiers = Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)
- ? WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS
- : [];
- const normalized = new Set(
- identifiers
- .map((value) =>
- typeof value === "string" ? value.trim().toLowerCase() : ""
- )
- .filter(Boolean)
- );
- if (!normalized.size) {
- normalized.add("watch-history");
- }
- return normalized;
-})();
-
-function isWatchHistoryKind(kind) {
- const numericKind = Number.parseInt(kind, 10);
- if (!Number.isFinite(numericKind)) {
- return false;
- }
- return WATCH_HISTORY_READ_KINDS.includes(numericKind);
-}
-
const viewEventPublishMemory = new Map();
+const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
+const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
+ ? VIEW_EVENT_SCHEMA.kind
+ : 30079;
+
let ingestLocalViewEventRef = null;
async function loadIngestLocalViewEvent() {
@@ -172,128 +79,6 @@ async function loadIngestLocalViewEvent() {
return null;
}
-function deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) {
- const sanitizedPrefix = WATCH_HISTORY_CHUNK_V2_PREFIX.replace(/\/+$/, "");
- const safeSnapshot =
- typeof snapshotId === "string" && snapshotId ? snapshotId : "0";
- const safeIndex = Number.isFinite(chunkIndex)
- ? Math.max(0, Math.floor(chunkIndex))
- : 0;
- return `${sanitizedPrefix}/${safeSnapshot}/${safeIndex}`;
-}
-
-function isIndexIdentifier(identifier) {
- if (!identifier || typeof identifier !== "string") {
- return false;
- }
- if (!WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
- return false;
- }
- return identifier.trim().toLowerCase() === WATCH_HISTORY_INDEX_IDENTIFIER_LOWER;
-}
-
-function isLegacyHeadIdentifier(identifier) {
- if (!identifier || typeof identifier !== "string") {
- return false;
- }
- const normalized = identifier.trim().toLowerCase();
- if (!normalized) {
- return false;
- }
- return LEGACY_WATCH_HISTORY_IDENTIFIER_SET.has(normalized);
-}
-
-function isWatchHistoryChunkIdentifier(identifier) {
- if (!identifier || typeof identifier !== "string") {
- return false;
- }
- const normalized = identifier.trim().toLowerCase();
- if (!normalized) {
- return false;
- }
- if (normalized.startsWith(`${WATCH_HISTORY_CHUNK_V2_PREFIX_LOWER}/`)) {
- return true;
- }
- if (
- LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER &&
- normalized.startsWith(
- `${LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER}/`
- )
- ) {
- return true;
- }
- return normalized.startsWith("watch-history/");
-}
-
-function extractSnapshotIdFromEvent(event) {
- if (!event || typeof event !== "object") {
- return "";
- }
-
- const tags = Array.isArray(event.tags) ? event.tags : [];
- for (const tag of tags) {
- if (Array.isArray(tag) && tag[0] === "snapshot" && typeof tag[1] === "string") {
- const snapshot = tag[1].trim();
- if (snapshot) {
- return snapshot;
- }
- }
- }
-
- if (typeof event.content === "string" && event.content.trim()) {
- const trimmedContent = event.content.trim();
- if (trimmedContent.startsWith("{") || trimmedContent.startsWith("[")) {
- try {
- const parsed = JSON.parse(trimmedContent);
- if (parsed && typeof parsed === "object") {
- const snapshot =
- typeof parsed.snapshot === "string" ? parsed.snapshot.trim() : "";
- if (snapshot) {
- return snapshot;
- }
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to parse snapshot from event content:",
- error
- );
- }
- }
- }
- }
-
- if (typeof event.id === "string" && event.id) {
- return event.id;
- }
-
- return "";
-}
-
-function getWatchHistoryEntrySnapshotId(entry) {
- if (!entry || typeof entry !== "object") {
- return "";
- }
-
- if (entry.pointerEvent) {
- const pointerSnapshot = extractSnapshotIdFromEvent(entry.pointerEvent);
- if (pointerSnapshot) {
- return pointerSnapshot;
- }
- }
-
- if (entry.chunkEvents instanceof Map) {
- for (const evt of entry.chunkEvents.values()) {
- const snapshot = extractSnapshotIdFromEvent(evt);
- if (snapshot) {
- return snapshot;
- }
- }
- }
-
- return "";
-}
-
// To limit error spam
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
@@ -608,7 +393,7 @@ function createVideoViewEventFilters(pointer) {
}
const pointerFilter = {
- kinds: WATCH_HISTORY_READ_KINDS,
+ kinds: [VIEW_EVENT_KIND],
"#t": ["view"],
};
@@ -622,7 +407,7 @@ function createVideoViewEventFilters(pointer) {
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
filters.push({
- kinds: WATCH_HISTORY_READ_KINDS,
+ kinds: [VIEW_EVENT_KIND],
"#t": ["view"],
"#video": [resolved.value],
});
@@ -809,7 +594,7 @@ function isVideoViewEvent(event, pointer) {
return false;
}
- if (!isWatchHistoryKind(event.kind)) {
+ if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) {
return false;
}
@@ -1489,7 +1274,7 @@ class NostrClient {
this.watchHistoryStorage = null;
this.watchHistoryRepublishTimers = new Map();
this.watchHistoryRefreshPromises = new Map();
- this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS;
+ this.watchHistoryCacheTtlMs = 0;
this.countRequestCounter = 0;
this.countUnsupportedRelays = new Set();
}
@@ -1637,1283 +1422,44 @@ class NostrClient {
}
/**
- * Resolve the effective TTL for watch-history cache entries.
+ * Watch history has been removed from the client. All related helpers now
+ * return disabled responses so callers can gracefully no-op without touching
+ * storage or relays.
*/
getWatchHistoryCacheTtlMs() {
- const candidate = this.watchHistoryCacheTtlMs;
- if (Number.isFinite(candidate) && candidate >= 0) {
- return candidate;
- }
- return WATCH_HISTORY_CACHE_TTL_MS;
+ return 0;
}
- /**
- * Load watch history cache payload from localStorage.
- */
getWatchHistoryStorage() {
- const base = { version: 1, actors: {} };
-
- if (!this.watchHistoryStorage) {
- if (typeof localStorage === "undefined") {
- this.watchHistoryStorage = base;
- return this.watchHistoryStorage;
- }
-
- let parsed = base;
- try {
- const raw = localStorage.getItem(WATCH_HISTORY_CACHE_STORAGE_KEY);
- if (raw) {
- const candidate = JSON.parse(raw);
- if (
- candidate &&
- typeof candidate === "object" &&
- candidate.version === 1 &&
- candidate.actors &&
- typeof candidate.actors === "object"
- ) {
- parsed = { version: 1, actors: candidate.actors };
- }
- }
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to parse watch history cache:", error);
- }
- }
-
- const actors = { ...(parsed.actors || {}) };
- this.watchHistoryStorage = { version: 1, actors };
- }
-
- const now = Date.now();
- const ttl = this.getWatchHistoryCacheTtlMs();
- let mutated = false;
- const actors = this.watchHistoryStorage.actors || {};
- for (const actor of Object.keys(actors)) {
- const info = actors[actor];
- if (
- !info ||
- typeof info !== "object" ||
- typeof info.savedAt !== "number" ||
- now - info.savedAt > ttl
- ) {
- delete actors[actor];
- mutated = true;
- }
- }
-
- if (mutated && typeof localStorage !== "undefined") {
- try {
- localStorage.setItem(
- WATCH_HISTORY_CACHE_STORAGE_KEY,
- JSON.stringify(this.watchHistoryStorage)
- );
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to persist cleaned watch history cache:",
- error
- );
- }
- }
- }
-
- return this.watchHistoryStorage;
+ return { version: 1, actors: {} };
}
- createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) {
- const normalizedItems = [];
- const seen = new Set();
+ persistWatchHistoryEntry() {}
- if (Array.isArray(items)) {
- for (const raw of items) {
- const pointer = normalizePointerInput(raw);
- if (!pointer) {
- continue;
- }
- const key = pointerKey(pointer);
- if (!key || seen.has(key)) {
- continue;
- }
- seen.add(key);
- const watchedAtRaw = Number.isFinite(pointer.watchedAt)
- ? pointer.watchedAt
- : null;
- const watchedAt =
- watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
- normalizedItems.push({ ...pointer, watchedAt });
- }
- }
+ cancelWatchHistoryRepublish() {}
- const entry = {
- pointerEvent: pointerEvent ? cloneEventForCache(pointerEvent) : null,
- items: normalizedItems,
- savedAt: typeof savedAt === "number" ? savedAt : Date.now(),
- resolved: previousEntry?.resolved
- ? new Map(previousEntry.resolved)
- : new Map(),
- resolvedVideos: previousEntry?.resolvedVideos
- ? new Map(previousEntry.resolvedVideos)
- : new Map(),
- resolving: previousEntry?.resolving
- ? new Set(previousEntry.resolving)
- : new Set(),
- delivered: previousEntry?.delivered instanceof Set
- ? new Set(previousEntry.delivered)
- : new Set(),
- chunkNeedsSigner: previousEntry?.chunkNeedsSigner instanceof Set
- ? new Set(previousEntry.chunkNeedsSigner)
- : new Set(),
- };
+ scheduleWatchHistoryRepublish() {}
- const validKeys = new Set(entry.items.map((item) => pointerKey(item)));
- for (const key of entry.resolved.keys()) {
- if (!validKeys.has(key)) {
- entry.resolved.delete(key);
- }
- }
- for (const key of entry.resolvedVideos.keys()) {
- if (!validKeys.has(key)) {
- entry.resolvedVideos.delete(key);
- }
- }
- for (const key of [...entry.resolving]) {
- if (!validKeys.has(key)) {
- entry.resolving.delete(key);
- }
- }
- for (const key of [...entry.delivered]) {
- if (!validKeys.has(key)) {
- entry.delivered.delete(key);
- }
- }
- for (const key of [...entry.chunkNeedsSigner]) {
- if (!validKeys.has(key)) {
- entry.chunkNeedsSigner.delete(key);
- }
- }
+ ensureWatchHistoryBackgroundRefresh() {}
- const previousChunkPointers = previousEntry?.chunkPointers;
- const headPointer = previousChunkPointers?.head
- ? clonePointerItem(previousChunkPointers.head)
- : null;
- const chunkPointerList = Array.isArray(previousChunkPointers?.chunks)
- ? previousChunkPointers.chunks
- .map((pointer) => clonePointerItem(pointer))
- .filter(Boolean)
- : [];
-
- entry.chunkPointers = {
- head: headPointer,
- chunks: chunkPointerList,
- };
-
- entry.chunkEvents = new Map();
- if (previousEntry?.chunkEvents instanceof Map) {
- for (const [key, value] of previousEntry.chunkEvents.entries()) {
- const clonedEvent = cloneEventForCache(value);
- if (clonedEvent) {
- entry.chunkEvents.set(key, clonedEvent);
- }
- }
- }
-
- return entry;
+ async publishWatchHistorySnapshot() {
+ return { ok: false, error: "watch-history-disabled" };
}
- persistWatchHistoryEntry(actor, entry) {
- if (typeof localStorage === "undefined" || !actor) {
- return;
- }
-
- const storage = this.getWatchHistoryStorage();
- storage.actors[actor] = {
- pointerEvent: entry.pointerEvent ? cloneEventForCache(entry.pointerEvent) : null,
- items: entry.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: Number.isFinite(item.watchedAt)
- ? Math.max(0, Math.floor(item.watchedAt))
- : 0,
- })),
- savedAt: entry.savedAt,
- };
-
- try {
- localStorage.setItem(
- WATCH_HISTORY_CACHE_STORAGE_KEY,
- JSON.stringify(storage)
- );
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to persist watch history cache:", error);
- }
- }
+ async updateWatchHistoryList() {
+ return { ok: false, error: "watch-history-disabled" };
}
- getWatchHistoryFingerprint(pubkeyOrSession) {
- let actor =
- typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
- ? pubkeyOrSession.trim()
- : "";
-
- if (!actor) {
- if (this.sessionActor?.pubkey) {
- actor = this.sessionActor.pubkey.trim();
- } else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
- actor = this.pubkey.trim();
- }
- }
-
- if (!actor) {
- return "";
- }
-
- const entry = this.watchHistoryCache.get(actor);
- if (!entry) {
- return "";
- }
-
- const pointerId =
- entry.pointerEvent && typeof entry.pointerEvent.id === "string"
- ? entry.pointerEvent.id
- : "";
- const pointerCreated =
- entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at)
- ? entry.pointerEvent.created_at
- : 0;
- const itemKeys = entry.items
- .map((item) => pointerKey(item))
- .filter(Boolean)
- .join("|");
-
- return `${pointerId}:${pointerCreated}:${itemKeys}`;
+ async removeWatchHistoryItem() {
+ return { ok: false, error: "watch-history-disabled" };
}
- resetWatchHistoryProgress(pubkeyOrSession = null) {
- let actor =
- typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
- ? pubkeyOrSession.trim()
- : "";
-
- if (!actor) {
- if (this.sessionActor?.pubkey) {
- actor = this.sessionActor.pubkey.trim();
- } else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
- actor = this.pubkey.trim();
- }
- }
-
- if (!actor) {
- return;
- }
-
- const entry = this.watchHistoryCache.get(actor);
- if (!entry) {
- return;
- }
-
- if (!(entry.delivered instanceof Set)) {
- entry.delivered = new Set();
- } else {
- entry.delivered.clear();
- }
-
- if (entry.resolving instanceof Set) {
- entry.resolving.clear();
- }
+ async fetchWatchHistory() {
+ return { pointerEvent: null, items: [] };
}
- async encryptWatchHistoryPayload(actorPubkey, payload) {
- const normalizedActor =
- typeof actorPubkey === "string" && actorPubkey.trim()
- ? actorPubkey.trim().toLowerCase()
- : "";
- if (!normalizedActor) {
- return { ok: false, error: "invalid-actor" };
- }
-
- let normalizedPayload = payload;
- if (!normalizedPayload || typeof normalizedPayload !== "object") {
- normalizedPayload = {};
- }
-
- const items = Array.isArray(normalizedPayload.items)
- ? normalizedPayload.items
- : [];
-
- const version = Number.isFinite(normalizedPayload.version)
- ? normalizedPayload.version
- : 1;
-
- const prepared = {
- version,
- items,
- };
-
- if (version >= 2) {
- prepared.snapshot =
- typeof normalizedPayload.snapshot === "string"
- ? normalizedPayload.snapshot
- : "";
- prepared.chunkIndex = Number.isFinite(normalizedPayload.chunkIndex)
- ? Math.max(0, Math.floor(normalizedPayload.chunkIndex))
- : 0;
- prepared.totalChunks = Number.isFinite(normalizedPayload.totalChunks)
- ? Math.max(1, Math.floor(normalizedPayload.totalChunks))
- : 1;
- }
-
- const plaintext = JSON.stringify(prepared);
-
- let ciphertext = "";
- const normalizedLogged =
- typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
-
- if (
- normalizedActor &&
- normalizedActor === normalizedLogged &&
- window?.nostr?.nip04?.encrypt
- ) {
- try {
- ciphertext = await window.nostr.nip04.encrypt(
- normalizedActor,
- plaintext
- );
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to encrypt watch history with extension:",
- error
- );
- }
- }
- }
-
- if (!ciphertext) {
- try {
- if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
- await this.ensureSessionActor(true);
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to ensure session actor while encrypting watch history:",
- error
- );
- }
- }
-
- const privateKey = this.sessionActor?.privateKey;
- if (privateKey && window?.NostrTools?.nip04?.encrypt) {
- try {
- ciphertext = await window.NostrTools.nip04.encrypt(
- privateKey,
- normalizedActor,
- plaintext
- );
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to encrypt watch history with session key:",
- error
- );
- }
- }
- }
- }
-
- if (!ciphertext) {
- return { ok: false, error: "encryption-unavailable" };
- }
-
- return { ok: true, ciphertext };
- }
-
- async decryptWatchHistoryEvent(pointerEvent, actorPubkey) {
- const fallbackItems = extractPointerItemsFromEvent(pointerEvent);
- const fallbackPayload = {
- version: 0,
- items: fallbackItems,
- snapshot: "",
- chunkIndex: 0,
- totalChunks: 1,
- };
-
- const ciphertextRaw =
- pointerEvent && typeof pointerEvent.content === "string"
- ? pointerEvent.content
- : "";
- const ciphertext = ciphertextRaw.trim();
-
- if (!isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext)) {
- return parseWatchHistoryContentWithFallback(
- ciphertext,
- fallbackItems,
- fallbackPayload
- );
- }
-
- const normalizedActor =
- typeof actorPubkey === "string" && actorPubkey.trim()
- ? actorPubkey.trim().toLowerCase()
- : "";
- if (!normalizedActor) {
- return fallbackPayload;
- }
-
- if (!ciphertext) {
- return fallbackPayload;
- }
-
- const normalizedLogged =
- typeof this.pubkey === "string" && this.pubkey.trim()
- ? this.pubkey.trim().toLowerCase()
- : "";
- const pointerAuthor =
- typeof pointerEvent?.pubkey === "string" && pointerEvent.pubkey.trim()
- ? pointerEvent.pubkey.trim().toLowerCase()
- : "";
- const hasExtensionDecrypt =
- typeof window?.nostr?.nip04?.decrypt === "function";
-
- const shouldDeferMissingSigner =
- normalizedActor &&
- normalizedActor === normalizedLogged &&
- pointerAuthor &&
- pointerAuthor === normalizedActor &&
- !hasExtensionDecrypt;
-
- if (shouldDeferMissingSigner) {
- fallbackPayload.needsSignerRetry = true;
- const warningMeta = {
- actor: normalizedActor,
- };
- if (typeof pointerEvent?.id === "string" && pointerEvent.id) {
- warningMeta.event = pointerEvent.id;
- }
- console.warn("[nostr] watch-history-decrypt-missing-signer", warningMeta);
- return fallbackPayload;
- }
-
- if (
- normalizedActor &&
- normalizedActor === normalizedLogged &&
- hasExtensionDecrypt
- ) {
- try {
- const plaintext = await window.nostr.nip04.decrypt(
- normalizedActor,
- ciphertext
- );
- return parseWatchHistoryContentWithFallback(
- typeof plaintext === "string" ? plaintext.trim() : "",
- fallbackItems,
- fallbackPayload
- );
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to decrypt watch history with extension:",
- error
- );
- }
- }
- }
-
- try {
- if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
- await this.ensureSessionActor(true);
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to ensure session actor while decrypting watch history:",
- error
- );
- }
- }
-
- const privateKey = this.sessionActor?.privateKey;
- if (privateKey && window?.NostrTools?.nip04?.decrypt) {
- try {
- const plaintext = await window.NostrTools.nip04.decrypt(
- privateKey,
- normalizedActor,
- ciphertext
- );
- return parseWatchHistoryContentWithFallback(
- typeof plaintext === "string" ? plaintext.trim() : "",
- fallbackItems,
- fallbackPayload
- );
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to decrypt watch history with session key:",
- error
- );
- }
- }
- }
-
- return fallbackPayload;
- }
-
- cancelWatchHistoryRepublish(actor) {
- const key =
- typeof actor === "string" && actor.trim()
- ? actor.trim().toLowerCase()
- : "";
- if (!key) {
- return;
- }
-
- const timer = this.watchHistoryRepublishTimers.get(key);
- if (timer) {
- clearTimeout(timer);
- this.watchHistoryRepublishTimers.delete(key);
- }
- }
-
- scheduleWatchHistoryRepublish(actor, items) {
- if (!this.pool) {
- return;
- }
-
- const key =
- typeof actor === "string" && actor.trim()
- ? actor.trim().toLowerCase()
- : "";
- if (!key || this.watchHistoryRepublishTimers.has(key)) {
- return;
- }
-
- const clonedItems = Array.isArray(items)
- ? items
- .map((item) => clonePointerItem(item))
- .filter((candidate) => !!candidate)
- : [];
-
- if (!clonedItems.length) {
- return;
- }
-
- const timer = setTimeout(() => {
- this.watchHistoryRepublishTimers.delete(key);
- this.publishWatchHistorySnapshot(actor, clonedItems, this.watchHistoryCache.get(actor) || null, {
- allowRetry: false,
- }).catch((error) => {
- if (isDevMode) {
- console.warn(
- `[nostr] Failed to republish watch history list for ${key}:`,
- error
- );
- }
- });
- }, 2000);
-
- this.watchHistoryRepublishTimers.set(key, timer);
- }
-
- ensureWatchHistoryBackgroundRefresh(actor) {
- const key =
- typeof actor === "string" && actor.trim()
- ? actor.trim().toLowerCase()
- : "";
- if (!key) {
- return;
- }
-
- if (!(this.watchHistoryRefreshPromises instanceof Map)) {
- this.watchHistoryRefreshPromises = new Map();
- }
-
- if (this.watchHistoryRefreshPromises.has(key)) {
- return;
- }
-
- const refreshPromise = this.fetchWatchHistory(actor, {
- forceRefresh: true,
- background: true,
- })
- .catch((error) => {
- if (isDevMode) {
- console.warn(
- "[nostr] Background watch history refresh failed:",
- error
- );
- }
- })
- .finally(() => {
- this.watchHistoryRefreshPromises.delete(key);
- });
-
- this.watchHistoryRefreshPromises.set(key, refreshPromise);
- }
-
- async publishWatchHistorySnapshot(
- actorPubkey,
- candidateItems,
- existingEntry = null,
- options = {}
- ) {
- if (!this.pool) {
- return { ok: false, error: "nostr-uninitialized" };
- }
-
- const normalizedActor =
- typeof actorPubkey === "string" && actorPubkey.trim()
- ? actorPubkey.trim()
- : "";
- if (!normalizedActor) {
- return { ok: false, error: "missing-actor" };
- }
-
- const allowRetry = options?.allowRetry !== false;
-
- const dedupe = new Map();
- const normalizedItems = [];
- const pushItem = (item) => {
- const pointer = normalizePointerInput(item);
- if (!pointer) {
- return;
- }
- const key = pointerKey(pointer);
- if (!key || dedupe.has(key)) {
- return;
- }
- dedupe.set(key, true);
- normalizedItems.push(pointer);
- };
-
- if (Array.isArray(candidateItems)) {
- candidateItems.forEach((item) => pushItem(item));
- } else if (candidateItems) {
- pushItem(candidateItems);
- }
-
- const trimmedItems =
- normalizedItems.length > WATCH_HISTORY_MAX_ITEMS
- ? normalizedItems.slice(0, WATCH_HISTORY_MAX_ITEMS)
- : normalizedItems;
-
- const payloadItems = trimmedItems.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: Number.isFinite(item.watchedAt)
- ? Math.max(0, Math.floor(item.watchedAt))
- : 0,
- }));
-
- const maxBytesCandidate = Number.isFinite(this.watchHistoryPayloadMaxBytes)
- ? this.watchHistoryPayloadMaxBytes
- : WATCH_HISTORY_PAYLOAD_MAX_BYTES;
- const maxPayloadBytes = Math.max(64, Math.floor(maxBytesCandidate));
- const snapshotId = `${Math.floor(Date.now() / 1000)}-${Math.random()
- .toString(36)
- .slice(2, 10)}`;
-
- const chunkInfo = chunkWatchHistoryPayloadItems(
- payloadItems,
- snapshotId,
- maxPayloadBytes
- );
- const chunkItemsList = chunkInfo.chunks || [];
- const skippedKeys = new Set(
- (chunkInfo.skipped || [])
- .map((item) => pointerKey(item))
- .filter((key) => !!key)
- );
-
- const persistedItems = trimmedItems.filter((item) => {
- const key = pointerKey(item);
- return key && !skippedKeys.has(key);
- });
-
- const baselineEntry =
- existingEntry || this.watchHistoryCache.get(normalizedActor) || null;
-
- const persistSnapshotToCache = () => {
- const pointerForCache = baselineEntry?.pointerEvent || null;
- const entry = this.createWatchHistoryEntry(
- pointerForCache,
- persistedItems,
- Date.now(),
- baselineEntry
- );
- this.watchHistoryCache.set(normalizedActor, entry);
- this.persistWatchHistoryEntry(normalizedActor, entry);
- return entry;
- };
-
- if (chunkInfo.skipped?.length && isDevMode) {
- console.warn(
- `[nostr] Skipped ${chunkInfo.skipped.length} oversized watch history entries while chunking.`
- );
- }
-
- const relays =
- Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
-
- const normalizedLogged =
- typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
- const normalizedActorLower = normalizedActor.toLowerCase();
- const useExtension =
- normalizedActorLower === normalizedLogged &&
- window?.nostr &&
- typeof window.nostr.signEvent === "function";
-
- if (!useExtension) {
- if (
- normalizedActorLower === normalizedLogged &&
- (!window?.nostr || typeof window.nostr.signEvent !== "function")
- ) {
- const cachedEntry = persistSnapshotToCache();
- if (allowRetry && persistedItems.length) {
- this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems);
- }
- return {
- ok: false,
- error: "signing-unavailable",
- snapshot: snapshotId,
- items: cachedEntry.items
- .map((item) => clonePointerItem(item))
- .filter(Boolean),
- };
- }
-
- try {
- if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
- await this.ensureSessionActor(true);
- }
- if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
- throw new Error("session-actor-mismatch");
- }
- } catch (error) {
- console.warn(
- "[nostr] Failed to prepare signing key for watch history chunks:",
- error
- );
- return { ok: false, error: "signing-failed", details: error };
- }
- }
-
- const totalChunks = chunkItemsList.length || 1;
- const chunkIdentifiers = chunkItemsList.map((_, index) =>
- deriveWatchHistoryChunkIdentifier(snapshotId, index)
- );
- const chunkAddresses = chunkIdentifiers.map(
- (identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}`
- );
-
- const nowSeconds = Math.floor(Date.now() / 1000);
- let lastCreatedAt = 0;
-
- const pointerCandidates = [];
- if (existingEntry?.pointerEvent) {
- pointerCandidates.push(existingEntry.pointerEvent);
- }
-
- const cachedPointerEvent = this.watchHistoryCache.get(normalizedActor)?.pointerEvent;
- if (cachedPointerEvent) {
- pointerCandidates.push(cachedPointerEvent);
- }
-
- for (const candidate of pointerCandidates) {
- if (!candidate || typeof candidate !== "object") {
- continue;
- }
- const createdAt = Number.isFinite(candidate.created_at)
- ? Math.floor(candidate.created_at)
- : null;
- if (createdAt !== null && createdAt > lastCreatedAt) {
- lastCreatedAt = createdAt;
- }
- }
-
- const baseTimestamp = Math.max(nowSeconds, lastCreatedAt + totalChunks);
- const preparedChunks = [];
-
- for (let index = 0; index < totalChunks; index++) {
- const chunkItems = Array.isArray(chunkItemsList[index])
- ? chunkItemsList[index]
- : [];
-
- const payload = {
- version: 2,
- snapshot: snapshotId,
- chunkIndex: index,
- totalChunks,
- items: chunkItems,
- };
-
- const encryptionResult = await this.encryptWatchHistoryPayload(
- normalizedActor,
- payload
- );
- if (!encryptionResult.ok) {
- if (isDevMode) {
- console.warn(
- "[nostr] Unable to encrypt watch history chunk:",
- encryptionResult.error
- );
- }
- return encryptionResult;
- }
-
- const pointerTags = chunkItems.map((item) => {
- const tag = [item.type, item.value];
- if (item.relay) {
- tag.push(item.relay);
- }
- return tag;
- });
-
- const chunkIdentifier =
- chunkIdentifiers[index] ?? deriveWatchHistoryChunkIdentifier(snapshotId, index);
-
- // Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots
- // even when multiple publishes land within the same wall-clock second.
- const event = buildWatchHistoryChunkEvent({
- pubkey: normalizedActor,
- created_at: baseTimestamp + (totalChunks - index - 1),
- chunkIdentifier,
- snapshotId,
- chunkIndex: index,
- totalChunks,
- pointerTags,
- chunkAddresses,
- content: encryptionResult.ciphertext,
- });
-
- let signedEvent;
- if (useExtension) {
- try {
- signedEvent = await window.nostr.signEvent(event);
- } catch (error) {
- console.warn(
- "[nostr] Failed to sign watch history chunk with extension:",
- error
- );
- return { ok: false, error: "signing-failed", details: error };
- }
- } else {
- try {
- signedEvent = signEventWithPrivateKey(
- event,
- this.sessionActor.privateKey
- );
- } catch (error) {
- console.warn(
- "[nostr] Failed to sign watch history chunk with session key:",
- error
- );
- return { ok: false, error: "signing-failed", details: error };
- }
- }
-
- preparedChunks.push({
- chunkIndex: index,
- event: signedEvent,
- });
- }
-
- const chunkResults = [];
- let overallSuccess = true;
-
- for (const chunk of preparedChunks) {
- const publishResults = await Promise.all(
- relays.map((url) => publishEventToRelay(this.pool, url, chunk.event))
- );
-
- const chunkSuccess = publishResults.some((result) => result.success);
- if (!chunkSuccess) {
- overallSuccess = false;
- console.warn(
- "[nostr] Failed to publish watch history chunk:",
- publishResults
- );
- }
-
- chunkResults.push({
- chunkIndex: chunk.chunkIndex,
- event: chunk.event,
- results: publishResults,
- success: chunkSuccess,
- });
- }
-
- const allChunksSucceeded = chunkResults.every((chunk) => chunk.success);
-
- let signedIndexEvent = null;
- let indexPublishResults = [];
- let indexSuccess = false;
-
- if (allChunksSucceeded) {
- const indexEvent = buildWatchHistoryIndexEvent({
- pubkey: normalizedActor,
- created_at: baseTimestamp + totalChunks,
- snapshotId,
- totalChunks,
- chunkAddresses,
- });
-
- try {
- if (useExtension) {
- signedIndexEvent = await window.nostr.signEvent(indexEvent);
- } else {
- signedIndexEvent = signEventWithPrivateKey(
- indexEvent,
- this.sessionActor.privateKey
- );
- }
- } catch (error) {
- console.warn("[nostr] Failed to sign watch history index event:", error);
- signedIndexEvent = null;
- }
-
- if (signedIndexEvent) {
- indexPublishResults = await Promise.all(
- relays.map((url) => publishEventToRelay(this.pool, url, signedIndexEvent))
- );
- indexSuccess = indexPublishResults.some((result) => result.success);
- if (!indexSuccess) {
- overallSuccess = false;
- console.warn(
- "[nostr] Failed to publish watch history index:",
- indexPublishResults
- );
- signedIndexEvent = null;
- }
- } else {
- overallSuccess = false;
- }
- } else {
- overallSuccess = false;
- }
-
- const publishAttemptSummaries = [];
- const acceptedRelaySet = new Set();
-
- const recordPublishAttempt = (label, results) => {
- if (!Array.isArray(results) || !results.length) {
- return;
- }
- const accepted = results
- .filter((result) => result && result.success)
- .map((result) => result.url)
- .filter((url) => typeof url === "string" && url);
- if (accepted.length) {
- accepted.forEach((url) => acceptedRelaySet.add(url));
- }
- publishAttemptSummaries.push({ label, accepted, results });
- };
-
- chunkResults.forEach((chunk) => {
- recordPublishAttempt(`chunk#${chunk.chunkIndex}`, chunk.results);
- });
- recordPublishAttempt("index", indexPublishResults);
-
- const failedAttempts = publishAttemptSummaries.filter(
- (attempt) => !attempt.accepted.length
- );
-
- if (acceptedRelaySet.size) {
- console.info(
- `[nostr] Watch history publish accepted by ${acceptedRelaySet.size} relay(s):`,
- Array.from(acceptedRelaySet).join(", ")
- );
- }
-
- if (failedAttempts.length) {
- console.warn(
- "[nostr] Watch history publish rejected by relays:",
- failedAttempts.map((attempt) => ({
- label: attempt.label,
- results: attempt.results,
- }))
- );
- } else if (!acceptedRelaySet.size && publishAttemptSummaries.length) {
- console.warn("[nostr] Watch history publish had no relay attempts.");
- }
-
- if (overallSuccess) {
- this.cancelWatchHistoryRepublish(normalizedActor);
- } else if (allowRetry && persistedItems.length) {
- this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems);
- }
-
- const latestItem = persistedItems[0] || null;
- const latestPointerKey = pointerKey(latestItem);
- const latestPointerSummary = latestPointerKey
- ? latestPointerKey.length > 32
- ? `${latestPointerKey.slice(0, 32)}…`
- : latestPointerKey
- : null;
- const actorPreview =
- normalizedActor.length > 16
- ? `${normalizedActor.slice(0, 8)}…${normalizedActor.slice(-4)}`
- : normalizedActor;
- const summary = {
- actor: actorPreview,
- itemCount: persistedItems.length,
- snapshot: snapshotId,
- latestPointer: latestPointerSummary,
- latestWatchedAt: Number.isFinite(latestItem?.watchedAt)
- ? new Date(Math.max(0, Math.floor(latestItem.watchedAt))).toISOString()
- : null,
- };
-
- if (overallSuccess) {
- console.info("[nostr] Watch history snapshot updated:", summary);
- } else {
- console.warn("[nostr] Watch history snapshot publish incomplete:", summary);
- }
-
- const pointerEvent =
- signedIndexEvent ||
- chunkResults.find((chunk) => chunk.success)?.event ||
- chunkResults[0]?.event ||
- null;
- const newEntry = this.createWatchHistoryEntry(
- pointerEvent,
- persistedItems,
- Date.now(),
- baselineEntry
- );
-
- if (!(newEntry.chunkEvents instanceof Map)) {
- newEntry.chunkEvents = new Map();
- } else {
- newEntry.chunkEvents.clear();
- }
-
- const chunkPointerItems = [];
- const chunkPointerKeys = new Set();
-
- chunkResults.forEach((chunk) => {
- const address = eventToAddressPointer(chunk.event);
- if (!address) {
- return;
- }
- const pointer = clonePointerItem({ type: "a", value: address, relay: null });
- if (!pointer) {
- return;
- }
- const key = pointerKey(pointer);
- if (!key || chunkPointerKeys.has(key)) {
- return;
- }
- chunkPointerKeys.add(key);
- chunkPointerItems.push(pointer);
- const clonedChunk = cloneEventForCache(chunk.event);
- newEntry.chunkEvents.set(key, clonedChunk || chunk.event);
- });
-
- let indexPointer = null;
- if (signedIndexEvent) {
- const indexAddress = eventToAddressPointer(signedIndexEvent);
- if (indexAddress) {
- const pointer = clonePointerItem({
- type: "a",
- value: indexAddress,
- relay: null,
- });
- if (pointer) {
- const key = pointerKey(pointer);
- if (key) {
- chunkPointerKeys.add(key);
- const clonedIndex = cloneEventForCache(signedIndexEvent);
- newEntry.chunkEvents.set(key, clonedIndex || signedIndexEvent);
- }
- indexPointer = pointer;
- }
- }
- }
-
- newEntry.chunkPointers = {
- head: indexPointer || chunkPointerItems[0] || null,
- index: indexPointer,
- chunks: chunkPointerItems,
- };
-
- this.watchHistoryCache.set(normalizedActor, newEntry);
- this.persistWatchHistoryEntry(normalizedActor, newEntry);
-
- const combinedEvents = [];
- const combinedResults = [];
- if (signedIndexEvent) {
- combinedEvents.push(signedIndexEvent);
- combinedResults.push(indexPublishResults);
- }
- chunkResults.forEach((chunk) => {
- combinedEvents.push(chunk.event);
- combinedResults.push(chunk.results);
- });
-
- return {
- ok: overallSuccess,
- event: pointerEvent,
- indexEvent: signedIndexEvent,
- events: combinedEvents,
- chunks: chunkResults,
- results: combinedResults,
- indexResults: indexPublishResults,
- snapshot: snapshotId,
- items: newEntry.items
- .map((item) => clonePointerItem(item))
- .filter(Boolean),
- };
- }
-
- async ensureSessionActor(forceSession = false) {
- const loggedInPubkey =
- typeof this.pubkey === "string" && this.pubkey.trim()
- ? this.pubkey.trim()
- : "";
-
- if (!forceSession && loggedInPubkey) {
- return loggedInPubkey;
- }
-
- if (
- this.sessionActor &&
- typeof this.sessionActor.pubkey === "string" &&
- this.sessionActor.pubkey
- ) {
- return this.sessionActor.pubkey;
- }
-
- if (typeof localStorage !== "undefined") {
- try {
- const raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
- if (raw) {
- const parsed = JSON.parse(raw);
- if (
- parsed &&
- typeof parsed === "object" &&
- typeof parsed.pubkey === "string" &&
- parsed.pubkey &&
- typeof parsed.privkey === "string" &&
- parsed.privkey
- ) {
- this.sessionActor = {
- pubkey: parsed.pubkey,
- privateKey: parsed.privkey,
- };
- return this.sessionActor.pubkey;
- }
- }
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to restore session actor:", error);
- }
- }
- }
-
- const nostrTools = window?.NostrTools || {};
- const keyGenerator =
- typeof nostrTools.generatePrivateKey === "function"
- ? nostrTools.generatePrivateKey
- : typeof nostrTools.generateSecretKey === "function"
- ? nostrTools.generateSecretKey
- : null;
- const pubkeyDeriver =
- typeof nostrTools.getPublicKey === "function"
- ? nostrTools.getPublicKey
- : null;
-
- if (!keyGenerator || !pubkeyDeriver) {
- if (isDevMode) {
- console.warn(
- "[nostr] Unable to generate session actor: missing NostrTools helpers."
- );
- }
- return "";
- }
-
- let privateKey = keyGenerator();
- if (privateKey && typeof privateKey !== "string") {
- const hasLength =
- typeof privateKey === "object" &&
- privateKey !== null &&
- typeof privateKey.length === "number";
- if (nostrTools.utils?.bytesToHex && hasLength) {
- try {
- privateKey = nostrTools.utils.bytesToHex(privateKey);
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to normalize generated private key:",
- error
- );
- }
- privateKey = "";
- }
- } else {
- privateKey = "";
- }
- }
-
- if (typeof privateKey !== "string" || !privateKey) {
- if (isDevMode) {
- console.warn(
- "[nostr] Unable to generate session actor: invalid private key output."
- );
- }
- return "";
- }
-
- let pubkey = pubkeyDeriver(privateKey);
- if (pubkey && typeof pubkey !== "string") {
- const hasLength =
- typeof pubkey === "object" &&
- pubkey !== null &&
- typeof pubkey.length === "number";
- if (nostrTools.utils?.bytesToHex && hasLength) {
- try {
- pubkey = nostrTools.utils.bytesToHex(pubkey);
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to normalize generated public key:",
- error
- );
- }
- pubkey = "";
- }
- } else {
- pubkey = "";
- }
- }
-
- if (typeof pubkey !== "string" || !pubkey) {
- if (isDevMode) {
- console.warn(
- "[nostr] Unable to generate session actor: invalid public key output."
- );
- }
- return "";
- }
-
- this.sessionActor = { pubkey, privateKey };
-
- if (typeof localStorage !== "undefined") {
- try {
- localStorage.setItem(
- SESSION_ACTOR_STORAGE_KEY,
- JSON.stringify({ pubkey, privkey: privateKey, savedAt: Date.now() })
- );
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to persist session actor:", error);
- }
- }
- }
-
- return pubkey;
+ async resolveWatchHistory() {
+ return [];
}
async publishViewEvent(videoPointer, options = {}) {
@@ -3018,8 +1564,6 @@ class NostrClient {
}
}
- const dedupeTag = generateViewEventDedupeTag(actorPubkey, pointer, createdAt);
-
const event = buildViewEvent({
pubkey: actorPubkey,
created_at: createdAt,
@@ -3027,7 +1571,6 @@ class NostrClient {
pointerTag,
includeSessionTag: usingSessionActor,
additionalTags,
- dedupeTag,
content,
});
@@ -3097,1883 +1640,6 @@ class NostrClient {
};
}
- async listVideoViewEvents(pointer, options = {}) {
- if (!this.pool) {
- return [];
- }
-
- const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
- pointer
- );
- const { since, until, limit, relays } = options || {};
-
- for (const filter of filters) {
- if (!filter || typeof filter !== "object") {
- continue;
- }
- if (Number.isFinite(since)) {
- filter.since = Math.floor(since);
- }
- if (Number.isFinite(until)) {
- filter.until = Math.floor(until);
- }
- if (Number.isFinite(limit) && limit > 0) {
- filter.limit = Math.floor(limit);
- }
- }
-
- const relayList = Array.isArray(relays) && relays.length
- ? relays
- : Array.isArray(this.relays) && this.relays.length
- ? this.relays
- : RELAY_URLS;
-
- let rawResults;
- try {
- rawResults = await this.pool.list(relayList, filters);
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to list video view events:", error);
- }
- return [];
- }
-
- const flattenResults = (input) => {
- if (!Array.isArray(input)) {
- return [];
- }
-
- const flat = [];
- for (const chunk of input) {
- if (Array.isArray(chunk)) {
- for (const item of chunk) {
- if (item && typeof item === "object") {
- flat.push(item);
- }
- }
- } else if (chunk && typeof chunk === "object") {
- flat.push(chunk);
- }
- }
- return flat;
- };
-
- const flattened = flattenResults(rawResults);
- const dedupe = new Map();
- const order = [];
-
- for (const event of flattened) {
- if (!isVideoViewEvent(event, pointerDescriptor)) {
- continue;
- }
-
- const eventId = typeof event.id === "string" ? event.id : null;
- if (!eventId) {
- order.push({ type: "raw", event });
- continue;
- }
-
- const existing = dedupe.get(eventId);
- if (!existing) {
- dedupe.set(eventId, event);
- order.push({ type: "id", key: eventId });
- continue;
- }
-
- const existingCreated = Number.isFinite(existing?.created_at)
- ? existing.created_at
- : 0;
- const incomingCreated = Number.isFinite(event.created_at)
- ? event.created_at
- : 0;
- if (incomingCreated > existingCreated) {
- dedupe.set(eventId, event);
- }
- }
-
- return order
- .map((entry) => {
- if (!entry) {
- return null;
- }
- if (entry.type === "raw") {
- return entry.event || null;
- }
- if (entry.type === "id") {
- return dedupe.get(entry.key) || null;
- }
- return null;
- })
- .filter(Boolean);
- }
-
- subscribeVideoViewEvents(pointer, options = {}) {
- if (!this.pool) {
- if (isDevMode) {
- console.warn("[nostr] Unable to subscribe to view events: pool missing.");
- }
- return () => {};
- }
-
- const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
- pointer
- );
-
- if (Number.isFinite(options?.since)) {
- for (const filter of filters) {
- if (filter && typeof filter === "object") {
- filter.since = Math.floor(options.since);
- }
- }
- }
-
- const relayList = Array.isArray(options?.relays) && options.relays.length
- ? options.relays
- : Array.isArray(this.relays) && this.relays.length
- ? this.relays
- : RELAY_URLS;
-
- const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
-
- let subscription;
- try {
- subscription = this.pool.sub(relayList, filters);
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to open video view subscription:", error);
- }
- return () => {};
- }
-
- if (onEvent) {
- subscription.on("event", (event) => {
- if (isVideoViewEvent(event, pointerDescriptor)) {
- try {
- onEvent(event);
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Video view event handler threw:", error);
- }
- }
- }
- });
- }
-
- const originalUnsub =
- typeof subscription.unsub === "function"
- ? subscription.unsub.bind(subscription)
- : null;
-
- let unsubscribed = false;
- return () => {
- if (unsubscribed) {
- return;
- }
- unsubscribed = true;
- if (originalUnsub) {
- try {
- originalUnsub();
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to unsubscribe from video view events:",
- error
- );
- }
- }
- }
- };
- }
-
- async countVideoViewEvents(pointer, options = {}) {
- const relayList =
- Array.isArray(options?.relays) && options.relays.length
- ? options.relays
- : undefined;
-
- const fallbackListOptions = (() => {
- const listOptions = {};
-
- if (relayList) {
- listOptions.relays = relayList;
- }
-
- if (Number.isFinite(options?.since)) {
- listOptions.since = Math.floor(options.since);
- } else {
- const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
- const horizonDays = Number.isFinite(horizonDaysRaw)
- ? Math.max(0, Math.floor(horizonDaysRaw))
- : 0;
- if (horizonDays > 0) {
- const secondsPerDay = 86_400;
- const nowSeconds = Math.floor(Date.now() / 1000);
- const fallbackSinceSeconds = Math.max(
- 0,
- nowSeconds - horizonDays * secondsPerDay
- );
- listOptions.since = Math.floor(fallbackSinceSeconds);
- }
- }
-
- if (Number.isFinite(options?.until)) {
- listOptions.until = Math.floor(options.until);
- }
-
- if (Number.isFinite(options?.limit) && options.limit > 0) {
- listOptions.limit = Math.floor(options.limit);
- }
-
- return listOptions;
- })();
-
- if (!this.pool) {
- const events = await this.listVideoViewEvents(pointer, {
- ...fallbackListOptions,
- });
- return {
- total: Array.isArray(events) ? events.length : 0,
- perRelay: [],
- best: null,
- fallback: true,
- };
- }
-
- const { filters } = createVideoViewEventFilters(pointer);
- const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
- if (!pointerFilter || typeof pointerFilter !== "object") {
- throw new Error("Invalid video pointer supplied for view lookup.");
- }
-
- const signal = options?.signal;
- const normalizeAbortError = () => {
- if (signal?.reason instanceof Error) {
- return signal.reason;
- }
- if (typeof DOMException === "function") {
- return new DOMException("Operation aborted", "AbortError");
- }
- const error = new Error("Operation aborted");
- error.name = "AbortError";
- return error;
- };
- if (signal?.aborted) {
- throw normalizeAbortError();
- }
-
- try {
- const result = await this.countEventsAcrossRelays([pointerFilter], {
- relays: relayList,
- timeoutMs: options?.timeoutMs,
- });
-
- if (result?.perRelay?.some((entry) => entry && entry.ok)) {
- return { ...result, fallback: false };
- }
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] COUNT view request failed:", error);
- }
- }
-
- if (signal?.aborted) {
- throw normalizeAbortError();
- }
-
- const abortPromise =
- signal &&
- typeof signal === "object" &&
- typeof signal.addEventListener === "function"
- ? new Promise((_, reject) => {
- signal.addEventListener(
- "abort",
- () => {
- reject(normalizeAbortError());
- },
- { once: true }
- );
- })
- : null;
-
- const listPromise = this.listVideoViewEvents(pointer, {
- ...fallbackListOptions,
- });
-
- const events = abortPromise
- ? await Promise.race([listPromise, abortPromise])
- : await listPromise;
-
- const uniqueCount = Array.isArray(events)
- ? (() => {
- const withIds = events
- .filter((event) => event && typeof event.id === "string")
- .map((event) => event.id);
- if (withIds.length === 0) {
- return events.length;
- }
- return new Set(withIds).size;
- })()
- : 0;
-
- return {
- total: uniqueCount,
- perRelay: [],
- best: null,
- fallback: true,
- };
- }
-
- async updateWatchHistoryList(pointer) {
- if (!this.pool) {
- return { ok: false, error: "nostr-uninitialized" };
- }
-
- const normalizedPointer = normalizePointerInput(pointer);
- if (!normalizedPointer) {
- return { ok: false, error: "invalid-pointer" };
- }
-
- if (Number.isFinite(normalizedPointer.watchedAt)) {
- normalizedPointer.watchedAt = Math.max(
- 0,
- Math.floor(normalizedPointer.watchedAt)
- );
- } else {
- normalizedPointer.watchedAt = Date.now();
- }
-
- const actorPubkey = await this.ensureSessionActor();
- if (!actorPubkey) {
- return { ok: false, error: "missing-actor" };
- }
-
- await this.fetchWatchHistory(actorPubkey);
-
- const existingEntry =
- this.watchHistoryCache.get(actorPubkey) ||
- this.createWatchHistoryEntry(null, [], Date.now());
-
- const candidates = [normalizedPointer, ...(existingEntry.items || [])];
- return this.publishWatchHistorySnapshot(actorPubkey, candidates, existingEntry);
- }
-
- async removeWatchHistoryItem(pointerOrKey) {
- if (!this.pool) {
- return { ok: false, error: "nostr-uninitialized" };
- }
-
- const actorPubkey = await this.ensureSessionActor();
- if (!actorPubkey) {
- return { ok: false, error: "missing-actor" };
- }
-
- await this.fetchWatchHistory(actorPubkey);
-
- const entry = this.watchHistoryCache.get(actorPubkey);
- if (!entry) {
- return { ok: false, error: "missing-entry" };
- }
-
- let targetKey = "";
- if (typeof pointerOrKey === "string" && pointerOrKey.trim()) {
- targetKey = pointerOrKey.trim().toLowerCase();
- } else if (pointerOrKey) {
- const normalizedPointer = normalizePointerInput(pointerOrKey);
- targetKey = pointerKey(normalizedPointer);
- }
-
- if (!targetKey) {
- return { ok: false, error: "invalid-pointer" };
- }
-
- const filteredItems = entry.items.filter((item) => {
- const key = pointerKey(item);
- return key && key !== targetKey;
- });
-
- if (filteredItems.length === entry.items.length) {
- return { ok: false, error: "pointer-not-found" };
- }
-
- const result = await this.publishWatchHistorySnapshot(
- actorPubkey,
- filteredItems,
- entry
- );
-
- if (!result.ok) {
- return result;
- }
-
- return { ...result, removedKey: targetKey };
- }
-
- async fetchWatchHistory(pubkeyOrSession, options = {}) {
- const forceRefresh = options?.forceRefresh === true;
- const background = options?.background === true;
- let actor =
- typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
- ? pubkeyOrSession.trim()
- : "";
-
- if (!actor) {
- actor = await this.ensureSessionActor();
- }
-
- if (!actor) {
- return { pointerEvent: null, items: [] };
- }
-
- const now = Date.now();
- const ttl = this.getWatchHistoryCacheTtlMs();
- const cached = this.watchHistoryCache.get(actor);
- const priorEntry = cached || null;
- if (!forceRefresh && cached) {
- if (now - cached.savedAt < ttl) {
- if (!background) {
- this.ensureWatchHistoryBackgroundRefresh(actor);
- }
- return {
- pointerEvent: cached.pointerEvent
- ? cloneEventForCache(cached.pointerEvent)
- : null,
- items: cached.items
- .map((item) => clonePointerItem(item))
- .filter(Boolean),
- };
- }
- this.watchHistoryCache.delete(actor);
- }
-
- let storage = null;
- let stored = null;
- if (typeof localStorage !== "undefined") {
- storage = this.getWatchHistoryStorage();
- stored = storage.actors?.[actor];
- if (
- !forceRefresh &&
- stored &&
- typeof stored.savedAt === "number" &&
- now - stored.savedAt < ttl
- ) {
- const entry = this.createWatchHistoryEntry(
- stored.pointerEvent,
- Array.isArray(stored.items) ? stored.items : [],
- stored.savedAt,
- this.watchHistoryCache.get(actor) || null
- );
- this.watchHistoryCache.set(actor, entry);
- if (!background) {
- this.ensureWatchHistoryBackgroundRefresh(actor);
- }
- return {
- pointerEvent: entry.pointerEvent
- ? cloneEventForCache(entry.pointerEvent)
- : null,
- items: entry.items
- .map((item) => clonePointerItem(item))
- .filter(Boolean),
- };
- }
- }
-
- const cachedSnapshotId = getWatchHistoryEntrySnapshotId(priorEntry);
- let snapshotChanged = false;
-
- if (!this.pool) {
- const fallbackEntry = this.createWatchHistoryEntry(
- null,
- [],
- Date.now(),
- priorEntry
- );
- this.watchHistoryCache.set(actor, fallbackEntry);
- return { pointerEvent: null, items: [] };
- }
-
- const relayList =
- Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
-
- const fetchLimitCandidate = Number.isFinite(this.watchHistoryFetchEventLimit)
- ? this.watchHistoryFetchEventLimit
- : WATCH_HISTORY_FETCH_EVENT_LIMIT;
- const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate));
-
- const indexIdentifier =
- typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" &&
- WATCH_HISTORY_LIST_IDENTIFIER.trim()
- ? WATCH_HISTORY_LIST_IDENTIFIER.trim()
- : "";
- const indexIdentifiers = indexIdentifier ? [indexIdentifier] : [];
-
- const legacyIdentifierSet = new Set();
- if (Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)) {
- WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS.forEach((value) => {
- if (typeof value === "string" && value.trim()) {
- const normalized = value.trim();
- if (normalized && normalized !== indexIdentifier) {
- legacyIdentifierSet.add(normalized);
- }
- }
- });
- }
-
- if (!indexIdentifiers.length && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
- indexIdentifiers.push(WATCH_HISTORY_LIST_IDENTIFIER);
- }
-
- const headEventMap = new Map();
- const registerHeadEvent = (evt) => {
- if (!evt || typeof evt !== "object") {
- return;
- }
- const id = typeof evt.id === "string" ? evt.id : null;
- if (!id) {
- return;
- }
- const createdAt = typeof evt.created_at === "number" ? evt.created_at : 0;
- const existing = headEventMap.get(id);
- if (
- !existing ||
- createdAt >
- (typeof existing?.created_at === "number" ? existing.created_at : 0)
- ) {
- headEventMap.set(id, evt);
- }
- };
-
- if (indexIdentifiers.length) {
- const indexFilter = {
- kinds: WATCH_HISTORY_READ_KINDS,
- authors: [actor],
- limit: fetchLimit,
- "#d": indexIdentifiers,
- };
-
- try {
- const events = await this.pool.list(relayList, [indexFilter]);
- if (Array.isArray(events)) {
- events.forEach(registerHeadEvent);
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to fetch watch history index events:",
- error
- );
- }
- }
- }
-
- if (legacyIdentifierSet.size) {
- const legacyFilter = {
- kinds: WATCH_HISTORY_READ_KINDS,
- authors: [actor],
- limit: fetchLimit,
- "#d": Array.from(legacyIdentifierSet),
- };
-
- try {
- const events = await this.pool.list(relayList, [legacyFilter]);
- if (Array.isArray(events)) {
- events.forEach(registerHeadEvent);
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to fetch legacy watch history pointers:",
- error
- );
- }
- }
- }
-
- const fetchedHeadEvents = Array.from(headEventMap.values());
-
- fetchedHeadEvents.sort((a, b) => {
- const aCreated = typeof a?.created_at === "number" ? a.created_at : 0;
- const bCreated = typeof b?.created_at === "number" ? b.created_at : 0;
- return bCreated - aCreated;
- });
-
- const chunkIdentifiers = new Set();
- const snapshotIds = new Set();
- const indexSnapshots = new Map();
-
- for (const event of fetchedHeadEvents) {
- if (!event || typeof event !== "object") {
- continue;
- }
-
- const tags = Array.isArray(event.tags) ? event.tags : [];
- let identifierTagValue = "";
- let snapshotTagValue = "";
- let totalChunksTagValue = null;
- const pointerIdentifiers = new Set();
- const pointerAddresses = new Set();
-
- for (const tag of tags) {
- if (!Array.isArray(tag) || tag.length < 2) {
- continue;
- }
- if (tag[0] === "d" && typeof tag[1] === "string") {
- identifierTagValue = tag[1];
- } else if (tag[0] === "snapshot" && typeof tag[1] === "string" && tag[1]) {
- snapshotTagValue = tag[1];
- } else if (tag[0] === "chunks") {
- const parsedTotal = Number.parseInt(tag[1], 10);
- if (Number.isFinite(parsedTotal)) {
- totalChunksTagValue = Math.max(0, parsedTotal);
- }
- } else if (tag[0] === "a") {
- const pointer = normalizePointerTag(tag);
- if (pointer?.type === "a" && typeof pointer.value === "string") {
- pointerAddresses.add(pointer.value);
- const [kindStr, pubkey, identifier] = pointer.value.split(":");
- const kind = Number.parseInt(kindStr, 10);
- if (
- Number.isFinite(kind) &&
- isWatchHistoryKind(kind) &&
- typeof pubkey === "string" &&
- pubkey === actor &&
- typeof identifier === "string" &&
- identifier &&
- !isIndexIdentifier(identifier)
- ) {
- pointerIdentifiers.add(identifier);
- }
- }
- }
- }
-
- const identifier = typeof identifierTagValue === "string" ? identifierTagValue : "";
- let snapshotId = snapshotTagValue;
- let totalChunks =
- Number.isFinite(totalChunksTagValue) && totalChunksTagValue !== null
- ? Math.max(0, Math.floor(totalChunksTagValue))
- : null;
-
- const isIndexEvent = isIndexIdentifier(identifier);
-
- if (isIndexEvent && typeof event.content === "string") {
- const trimmedContent = event.content.trim();
- if (trimmedContent) {
- const firstChar = trimmedContent[0];
- if (firstChar === "{" || firstChar === "[") {
- try {
- const parsed = JSON.parse(trimmedContent);
- if (
- !snapshotId &&
- typeof parsed?.snapshot === "string" &&
- parsed.snapshot
- ) {
- snapshotId = parsed.snapshot;
- }
- if (
- totalChunks === null &&
- Number.isFinite(Number.parseInt(parsed?.totalChunks, 10))
- ) {
- totalChunks = Math.max(
- 0,
- Math.floor(Number.parseInt(parsed.totalChunks, 10))
- );
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to parse watch history index payload:",
- error
- );
- }
- }
- } else if (!snapshotId) {
- snapshotId = trimmedContent;
- }
- }
- }
-
- if (!snapshotId) {
- snapshotId = typeof event.id === "string" && event.id ? event.id : "";
- }
-
- if (snapshotId) {
- snapshotIds.add(snapshotId);
- }
-
- pointerIdentifiers.forEach((value) => {
- if (value) {
- chunkIdentifiers.add(value);
- }
- });
-
- if (identifier && !isIndexEvent) {
- chunkIdentifiers.add(identifier);
- }
-
- if (isIndexEvent && snapshotId) {
- const existingMeta = indexSnapshots.get(snapshotId) || {
- event: null,
- totalChunks: null,
- chunkIdentifiers: new Set(),
- chunkAddresses: new Set(),
- };
-
- const existingCreatedAt =
- typeof existingMeta.event?.created_at === "number"
- ? existingMeta.event.created_at
- : 0;
- const candidateCreatedAt =
- typeof event.created_at === "number" ? event.created_at : 0;
- if (!existingMeta.event || candidateCreatedAt >= existingCreatedAt) {
- existingMeta.event = event;
- }
-
- if (Number.isFinite(totalChunks)) {
- if (
- !Number.isFinite(existingMeta.totalChunks) ||
- totalChunks > existingMeta.totalChunks
- ) {
- existingMeta.totalChunks = totalChunks;
- }
- }
-
- pointerIdentifiers.forEach((value) => {
- if (value) {
- existingMeta.chunkIdentifiers.add(value);
- }
- });
-
- pointerAddresses.forEach((value) => {
- if (value) {
- existingMeta.chunkAddresses.add(value);
- }
- });
-
- indexSnapshots.set(snapshotId, existingMeta);
- } else if (snapshotId) {
- const existingMeta = indexSnapshots.get(snapshotId);
- if (existingMeta) {
- pointerIdentifiers.forEach((value) => {
- if (value) {
- existingMeta.chunkIdentifiers.add(value);
- }
- });
- pointerAddresses.forEach((value) => {
- if (value) {
- existingMeta.chunkAddresses.add(value);
- }
- });
- indexSnapshots.set(snapshotId, existingMeta);
- }
- }
- }
-
- let latestIndexSnapshotMeta = null;
- for (const [snapshotId, meta] of indexSnapshots.entries()) {
- if (!meta?.event) {
- continue;
- }
- const createdAt =
- typeof meta.event.created_at === "number" ? meta.event.created_at : 0;
- if (
- !latestIndexSnapshotMeta ||
- createdAt > latestIndexSnapshotMeta.createdAt
- ) {
- latestIndexSnapshotMeta = {
- snapshotId,
- createdAt,
- };
- }
- }
-
- if (
- latestIndexSnapshotMeta?.snapshotId &&
- cachedSnapshotId &&
- cachedSnapshotId !== latestIndexSnapshotMeta.snapshotId
- ) {
- snapshotChanged = true;
- this.watchHistoryCache.delete(actor);
- }
-
- let newestSnapshotChunkTarget = 0;
- let newestSnapshotIdentifierTarget = 0;
- if (latestIndexSnapshotMeta?.snapshotId) {
- const latestSnapshotMeta = indexSnapshots.get(
- latestIndexSnapshotMeta.snapshotId
- );
- if (latestSnapshotMeta) {
- const totalChunksForLatest = Number.isFinite(
- latestSnapshotMeta.totalChunks
- )
- ? Math.max(0, Math.floor(latestSnapshotMeta.totalChunks))
- : 0;
- if (totalChunksForLatest > 0) {
- newestSnapshotChunkTarget = totalChunksForLatest + 1;
- }
- if (latestSnapshotMeta.chunkIdentifiers instanceof Set) {
- const identifierCount = latestSnapshotMeta.chunkIdentifiers.size;
- if (identifierCount > 0) {
- newestSnapshotIdentifierTarget = identifierCount + 1;
- }
- }
- }
- }
-
- const chunkFilters = [];
- const chunkFetchLimit = Math.max(
- fetchLimit,
- chunkIdentifiers.size ? chunkIdentifiers.size + 1 : 0,
- snapshotIds.size ? snapshotIds.size * 2 : 0,
- newestSnapshotChunkTarget,
- newestSnapshotIdentifierTarget
- );
-
- if (chunkIdentifiers.size) {
- chunkFilters.push({
- kinds: WATCH_HISTORY_READ_KINDS,
- authors: [actor],
- "#d": Array.from(chunkIdentifiers),
- limit: chunkFetchLimit,
- });
- }
-
- if (snapshotIds.size) {
- chunkFilters.push({
- kinds: WATCH_HISTORY_READ_KINDS,
- authors: [actor],
- "#snapshot": Array.from(snapshotIds),
- limit: chunkFetchLimit,
- });
- }
-
- let fetchedChunkEvents = [];
- if (chunkFilters.length) {
- try {
- const events = await this.pool.list(relayList, chunkFilters);
- if (Array.isArray(events)) {
- fetchedChunkEvents = events;
- }
- } catch (error) {
- if (isDevMode) {
- console.warn(
- "[nostr] Failed to fetch watch history chunks:",
- error
- );
- }
- }
- }
-
- const eventDedupe = new Map();
- const registerEvent = (evt) => {
- if (!evt || typeof evt !== "object") {
- return;
- }
- const id = typeof evt.id === "string" ? evt.id : null;
- if (!id) {
- return;
- }
- const existing = eventDedupe.get(id);
- if (
- !existing ||
- (typeof evt.created_at === "number" ? evt.created_at : 0) >
- (typeof existing.created_at === "number" ? existing.created_at : 0)
- ) {
- eventDedupe.set(id, evt);
- }
- };
-
- fetchedHeadEvents.forEach(registerEvent);
- fetchedChunkEvents.forEach(registerEvent);
-
- const fetchedEvents = Array.from(eventDedupe.values());
-
- fetchedEvents.sort((a, b) => {
- const aCreated = typeof a?.created_at === "number" ? a.created_at : 0;
- const bCreated = typeof b?.created_at === "number" ? b.created_at : 0;
- return bCreated - aCreated;
- });
-
- const snapshotMap = new Map();
-
- for (const event of fetchedEvents) {
- if (!event || typeof event !== "object") {
- continue;
- }
-
- const tags = Array.isArray(event.tags) ? event.tags : [];
- let taggedSnapshotId = "";
- let taggedChunkIndex = null;
- let taggedTotalChunks = null;
- let identifierTagValue = "";
-
- for (const tag of tags) {
- if (!Array.isArray(tag) || tag.length < 2) {
- continue;
- }
- if (tag[0] === "d" && typeof tag[1] === "string") {
- identifierTagValue = tag[1];
- }
- if (tag[0] === "snapshot" && typeof tag[1] === "string") {
- taggedSnapshotId = tag[1];
- } else if (tag[0] === "chunk") {
- const index = Number.parseInt(tag[1], 10);
- const total = Number.parseInt(tag[2], 10);
- if (Number.isFinite(index)) {
- taggedChunkIndex = Math.max(0, index);
- }
- if (Number.isFinite(total) && total > 0) {
- taggedTotalChunks = total;
- }
- }
- }
-
- const payload = await this.decryptWatchHistoryEvent(event, actor);
- let snapshotId =
- (typeof payload.snapshot === "string" && payload.snapshot) ||
- taggedSnapshotId;
- if (!snapshotId) {
- const createdAt =
- typeof event.created_at === "number" ? event.created_at : 0;
- const fallbackId =
- typeof event.id === "string" && event.id
- ? event.id
- : `${createdAt}:${Math.random().toString(36).slice(2, 10)}`;
- snapshotId = fallbackId;
- }
-
- let chunkIndex = Number.isFinite(payload.chunkIndex)
- ? Math.max(0, Math.floor(payload.chunkIndex))
- : 0;
- let totalChunks = Number.isFinite(payload.totalChunks)
- ? Math.max(1, Math.floor(payload.totalChunks))
- : 1;
-
- if (Number.isFinite(taggedChunkIndex)) {
- chunkIndex = Math.max(0, Math.floor(taggedChunkIndex));
- }
- if (Number.isFinite(taggedTotalChunks)) {
- totalChunks = Math.max(1, Math.floor(taggedTotalChunks));
- }
-
- const bucket = snapshotMap.get(snapshotId) || {
- snapshotId,
- chunks: new Map(),
- expectedChunks: totalChunks,
- latestCreatedAt:
- typeof event.created_at === "number" ? event.created_at : 0,
- version: payload.version || 0,
- indexEvent: null,
- chunkAddresses: new Set(),
- expectedChunkIdentifiers: new Set(),
- chunkIdentifiers: new Set(),
- needsSignerRetry: new Set(),
- decryptedChunkCount: 0,
- hadFallbackChunk: false,
- };
- if (!(bucket.needsSignerRetry instanceof Set)) {
- bucket.needsSignerRetry = new Set();
- }
- if (!(bucket.chunkIdentifiers instanceof Set)) {
- bucket.chunkIdentifiers = new Set();
- }
- if (!Number.isFinite(bucket.decryptedChunkCount)) {
- bucket.decryptedChunkCount = 0;
- }
-
- const chunkPayload = {
- ...payload,
- snapshot: snapshotId,
- chunkIndex,
- totalChunks,
- needsSignerRetry: !!payload?.needsSignerRetry,
- chunkIdentifier: identifierTagValue || payload?.chunkIdentifier || null,
- };
-
- bucket.chunks.set(chunkIndex, {
- event,
- payload: chunkPayload,
- created_at: typeof event.created_at === "number" ? event.created_at : 0,
- });
- if (identifierTagValue) {
- bucket.chunkIdentifiers.add(identifierTagValue);
- }
- const payloadVersion = Number.isFinite(chunkPayload.version)
- ? chunkPayload.version
- : 0;
- const payloadItems = Array.isArray(chunkPayload.items)
- ? chunkPayload.items
- : [];
- const payloadHasWatchedAt = payloadItems.every((item) =>
- item && Number.isFinite(item.watchedAt)
- );
- const payloadMatchesSnapshot =
- typeof chunkPayload.snapshot === "string"
- ? chunkPayload.snapshot === snapshotId
- : true;
- const chunkComplete =
- payloadVersion >= 2 &&
- payloadMatchesSnapshot &&
- !chunkPayload.needsSignerRetry &&
- payloadHasWatchedAt;
- if (chunkComplete) {
- bucket.decryptedChunkCount += 1;
- } else {
- bucket.hadFallbackChunk = true;
- }
- bucket.expectedChunks = Math.max(bucket.expectedChunks, totalChunks);
- const createdAt =
- typeof event.created_at === "number" ? event.created_at : 0;
- if (createdAt > bucket.latestCreatedAt) {
- bucket.latestCreatedAt = createdAt;
- }
- bucket.version = Math.max(bucket.version, payload.version || 0);
- if (payload?.needsSignerRetry) {
- bucket.needsSignerRetry.add(chunkIndex);
- } else {
- bucket.needsSignerRetry.delete(chunkIndex);
- }
- const chunkAddress = eventToAddressPointer(event);
- if (chunkAddress) {
- bucket.chunkAddresses.add(chunkAddress);
- }
- if (identifierTagValue) {
- bucket.expectedChunkIdentifiers.add(identifierTagValue);
- }
- snapshotMap.set(snapshotId, bucket);
- }
-
- for (const [snapshotId, meta] of indexSnapshots.entries()) {
- const bucket = snapshotMap.get(snapshotId) || {
- snapshotId,
- chunks: new Map(),
- expectedChunks: 0,
- latestCreatedAt: 0,
- version: 0,
- indexEvent: null,
- chunkAddresses: new Set(),
- expectedChunkIdentifiers: new Set(),
- chunkIdentifiers: new Set(),
- needsSignerRetry: new Set(),
- decryptedChunkCount: 0,
- hadFallbackChunk: false,
- };
- if (!(bucket.needsSignerRetry instanceof Set)) {
- bucket.needsSignerRetry = new Set();
- }
- if (!(bucket.chunkIdentifiers instanceof Set)) {
- bucket.chunkIdentifiers = new Set();
- }
- if (!Number.isFinite(bucket.decryptedChunkCount)) {
- bucket.decryptedChunkCount = 0;
- }
-
- if (meta.event) {
- const createdAt =
- typeof meta.event.created_at === "number" ? meta.event.created_at : 0;
- if (createdAt > bucket.latestCreatedAt) {
- bucket.latestCreatedAt = createdAt;
- }
- bucket.indexEvent = meta.event;
- }
-
- if (Number.isFinite(meta.totalChunks)) {
- const normalized = Math.max(1, Math.floor(meta.totalChunks));
- bucket.expectedChunks = Math.max(bucket.expectedChunks, normalized);
- }
-
- if (meta.chunkAddresses instanceof Set) {
- meta.chunkAddresses.forEach((value) => {
- if (typeof value === "string" && value) {
- bucket.chunkAddresses.add(value);
- }
- });
- }
-
- if (meta.chunkIdentifiers instanceof Set) {
- meta.chunkIdentifiers.forEach((value) => {
- if (typeof value === "string" && value) {
- bucket.expectedChunkIdentifiers.add(value);
- }
- });
- }
-
- snapshotMap.set(snapshotId, bucket);
- }
-
- const snapshotBuckets = Array.from(snapshotMap.values()).sort(
- (a, b) => b.latestCreatedAt - a.latestCreatedAt
- );
-
- let selectedSnapshot = null;
- let fallbackSnapshot = null;
-
- for (const bucket of snapshotBuckets) {
- const chunkIndices = Array.from(bucket.chunks.keys()).sort(
- (a, b) => a - b
- );
- const seenKeys = new Set();
- const combinedItems = [];
- let pointerCandidate = bucket.indexEvent || null;
-
- for (const index of chunkIndices) {
- const chunk = bucket.chunks.get(index);
- if (!chunk) {
- continue;
- }
- if (!pointerCandidate && chunk.payload.chunkIndex === 0) {
- pointerCandidate = chunk.event;
- }
- const items = Array.isArray(chunk.payload.items)
- ? chunk.payload.items
- : [];
- for (const pointer of items) {
- const key = pointerKey(pointer);
- if (!key || seenKeys.has(key)) {
- continue;
- }
- seenKeys.add(key);
- combinedItems.push(pointer);
- }
- }
-
- if (!pointerCandidate && chunkIndices.length) {
- const firstChunk = bucket.chunks.get(chunkIndices[0]);
- pointerCandidate = firstChunk?.event || null;
- }
-
- const snapshot = {
- snapshotId: bucket.snapshotId,
- items: combinedItems,
- pointerEvent: pointerCandidate || null,
- version: bucket.version,
- chunkCount: chunkIndices.length,
- expectedChunks: Math.max(bucket.expectedChunks, chunkIndices.length || 1),
- chunkAddresses: bucket.chunkAddresses instanceof Set
- ? new Set(bucket.chunkAddresses)
- : new Set(),
- expectedChunkIdentifiers: bucket.expectedChunkIdentifiers instanceof Set
- ? new Set(bucket.expectedChunkIdentifiers)
- : new Set(),
- chunkIdentifiers:
- bucket.chunkIdentifiers instanceof Set
- ? new Set(bucket.chunkIdentifiers)
- : new Set(),
- needsSignerRetry: bucket.needsSignerRetry instanceof Set
- ? new Set(bucket.needsSignerRetry)
- : new Set(),
- decryptedChunkCount: Number.isFinite(bucket.decryptedChunkCount)
- ? Math.max(0, Math.floor(bucket.decryptedChunkCount))
- : 0,
- hadFallbackChunk: bucket.hadFallbackChunk === true,
- };
-
- if (!fallbackSnapshot) {
- fallbackSnapshot = snapshot;
- }
-
- if (snapshot.chunkCount >= snapshot.expectedChunks) {
- selectedSnapshot = snapshot;
- break;
- }
-
- if (!selectedSnapshot) {
- selectedSnapshot = snapshot;
- }
- }
-
- const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null;
-
- const previousEntry = snapshotChanged ? null : priorEntry;
- const storedItems =
- !snapshotChanged && Array.isArray(stored?.items) ? stored.items : [];
- const storedPointerEvent =
- !snapshotChanged && stored?.pointerEvent ? stored.pointerEvent : null;
-
- const previousItems = Array.isArray(previousEntry?.items)
- ? previousEntry.items
- : [];
- const fallbackCandidate = previousItems.length
- ? {
- source: "memory-cache",
- items: previousItems,
- pointer: previousEntry?.pointerEvent || null,
- }
- : storedItems.length
- ? {
- source: "storage-cache",
- items: storedItems,
- pointer: storedPointerEvent || null,
- }
- : null;
-
- const remoteItems = Array.isArray(effectiveSnapshot?.items)
- ? effectiveSnapshot.items
- : [];
-
- const expectedChunks = Number.isFinite(effectiveSnapshot?.expectedChunks)
- ? Math.max(1, Math.floor(effectiveSnapshot.expectedChunks))
- : 1;
- const chunkCount = Number.isFinite(effectiveSnapshot?.chunkCount)
- ? Math.max(0, Math.floor(effectiveSnapshot.chunkCount))
- : 0;
- const chunkIdentifiersSet =
- effectiveSnapshot?.chunkIdentifiers instanceof Set
- ? effectiveSnapshot.chunkIdentifiers
- : new Set();
- const expectedChunkIdentifiersSet =
- effectiveSnapshot?.expectedChunkIdentifiers instanceof Set
- ? effectiveSnapshot.expectedChunkIdentifiers
- : new Set();
- const missingIdentifierList = Array.from(expectedChunkIdentifiersSet).filter(
- (identifier) => !chunkIdentifiersSet.has(identifier)
- );
- const needsSignerRetryCount =
- effectiveSnapshot?.needsSignerRetry instanceof Set
- ? effectiveSnapshot.needsSignerRetry.size
- : 0;
- const decryptedChunkCount = Number.isFinite(
- effectiveSnapshot?.decryptedChunkCount
- )
- ? Math.max(0, Math.floor(effectiveSnapshot.decryptedChunkCount))
- : 0;
- const expectedDecryptedTarget = Math.min(
- expectedChunks,
- chunkCount || expectedChunks
- );
- const remoteVersion = effectiveSnapshot?.version ?? 0;
- const remoteComplete =
- !!effectiveSnapshot &&
- chunkCount >= expectedChunks &&
- missingIdentifierList.length === 0 &&
- needsSignerRetryCount === 0 &&
- effectiveSnapshot.hadFallbackChunk !== true &&
- decryptedChunkCount >= expectedDecryptedTarget;
-
- let items = remoteItems;
- let pointerForEntry = effectiveSnapshot?.pointerEvent || null;
- let usedFallback = false;
- let remoteUsed = false;
- let remoteIssues = null;
- let fallbackSource = null;
-
- if (effectiveSnapshot) {
- if (remoteComplete) {
- remoteUsed = true;
- } else {
- remoteIssues = {
- snapshotId: effectiveSnapshot.snapshotId || "",
- chunkCount,
- expectedChunks,
- decryptedChunks: decryptedChunkCount,
- missingChunkIdentifiers: missingIdentifierList.length,
- needsSignerRetry: needsSignerRetryCount,
- hadFallbackChunk: effectiveSnapshot.hadFallbackChunk === true,
- version: remoteVersion,
- };
-
- if (fallbackCandidate && fallbackCandidate.items.length) {
- items = fallbackCandidate.items;
- pointerForEntry = fallbackCandidate.pointer || null;
- usedFallback = true;
- fallbackSource = fallbackCandidate.source;
- } else if (!remoteItems.length && remoteVersion === 0) {
- remoteUsed = true;
- } else {
- remoteUsed = true;
- }
- }
- } else if (fallbackCandidate && fallbackCandidate.items.length) {
- items = fallbackCandidate.items;
- pointerForEntry = fallbackCandidate.pointer || null;
- usedFallback = true;
- fallbackSource = fallbackCandidate.source;
- }
-
- if (!pointerForEntry && fallbackCandidate) {
- pointerForEntry = fallbackCandidate.pointer || null;
- }
-
- if (!remoteUsed && effectiveSnapshot && !usedFallback && !items.length) {
- remoteUsed = true;
- }
-
- const actorPreview =
- typeof actor === "string" && actor.length > 16
- ? `${actor.slice(0, 8)}…${actor.slice(-4)}`
- : actor;
-
- const logContext = {
- actor: actorPreview || null,
- snapshot: effectiveSnapshot?.snapshotId || null,
- itemCount: Array.isArray(items) ? items.length : 0,
- chunkCount,
- expectedChunks,
- decryptedChunks: decryptedChunkCount,
- missingChunkIdentifiers: missingIdentifierList.length,
- needsSignerRetry: needsSignerRetryCount,
- hadFallbackChunk: effectiveSnapshot?.hadFallbackChunk === true,
- fallbackSource: fallbackSource || null,
- };
-
- if (effectiveSnapshot && remoteUsed && remoteComplete) {
- console.info("[nostr] Watch history snapshot rehydrated:", {
- ...logContext,
- remoteComplete: true,
- });
- } else if (effectiveSnapshot && !remoteComplete && usedFallback) {
- console.warn("[nostr] Watch history snapshot incomplete; using cached copy:", {
- ...logContext,
- remoteComplete: false,
- issues: remoteIssues,
- });
- } else if (effectiveSnapshot && !remoteComplete && remoteUsed) {
- console.warn(
- "[nostr] Watch history snapshot incomplete with no fallback:",
- {
- ...logContext,
- remoteComplete: false,
- issues: remoteIssues,
- }
- );
- } else if (!effectiveSnapshot && usedFallback) {
- console.info("[nostr] Watch history snapshot unavailable; using cached copy:", {
- ...logContext,
- remoteComplete: false,
- });
- } else if (!effectiveSnapshot && !items.length) {
- console.info("[nostr] Watch history snapshot unavailable:", {
- actor: actorPreview || null,
- });
- }
-
- const entry = this.createWatchHistoryEntry(
- pointerForEntry,
- items,
- Date.now(),
- previousEntry
- );
-
- if (!(entry.chunkEvents instanceof Map)) {
- entry.chunkEvents = new Map();
- } else if (remoteUsed) {
- entry.chunkEvents.clear();
- }
-
- if (!(entry.chunkNeedsSigner instanceof Set)) {
- entry.chunkNeedsSigner = new Set();
- } else if (remoteUsed) {
- entry.chunkNeedsSigner.clear();
- }
-
- if (remoteUsed && effectiveSnapshot) {
- const chunkPointerItemsForEntry = [];
- const chunkPointerKeys = new Set();
- let indexPointerForEntry = null;
- let firstChunkPointer = null;
-
- const pointerEventForSnapshot = effectiveSnapshot.pointerEvent;
- if (pointerEventForSnapshot) {
- const indexAddress = eventToAddressPointer(pointerEventForSnapshot);
- if (indexAddress) {
- const pointer = clonePointerItem({
- type: "a",
- value: indexAddress,
- relay: null,
- });
- if (pointer) {
- const key = pointerKey(pointer);
- if (key) {
- chunkPointerKeys.add(key);
- const clonedIndex = cloneEventForCache(pointerEventForSnapshot);
- if (clonedIndex) {
- entry.chunkEvents.set(key, clonedIndex);
- }
- indexPointerForEntry = pointer;
- }
- }
- }
- }
-
- if (effectiveSnapshot.snapshotId) {
- const bucketForEntry = snapshotMap.get(effectiveSnapshot.snapshotId);
- if (bucketForEntry) {
- const chunkEntries = Array.from(bucketForEntry.chunks.values()).sort(
- (a, b) => a.payload.chunkIndex - b.payload.chunkIndex
- );
- for (const chunk of chunkEntries) {
- if (!chunk?.event) {
- continue;
- }
- const addressValue = eventToAddressPointer(chunk.event);
- if (!addressValue) {
- continue;
- }
- const pointer = clonePointerItem({
- type: "a",
- value: addressValue,
- relay: null,
- });
- if (!pointer) {
- continue;
- }
- const key = pointerKey(pointer);
- if (!key || chunkPointerKeys.has(key)) {
- continue;
- }
- const clonedChunkEvent = cloneEventForCache(chunk.event);
- const chunkPayloadIndex = Number.isFinite(chunk.payload?.chunkIndex)
- ? Math.max(0, Math.floor(chunk.payload.chunkIndex))
- : null;
- const flaggedBySnapshot =
- chunkPayloadIndex !== null &&
- effectiveSnapshot.needsSignerRetry instanceof Set &&
- effectiveSnapshot.needsSignerRetry.has(chunkPayloadIndex);
- const needsSignerRetry =
- !!chunk.payload?.needsSignerRetry || flaggedBySnapshot;
- if (needsSignerRetry) {
- entry.chunkNeedsSigner.add(key);
- entry.resolved.delete(key);
- } else if (clonedChunkEvent) {
- entry.chunkEvents.set(key, clonedChunkEvent);
- } else {
- entry.chunkEvents.set(key, chunk.event);
- }
- chunkPointerKeys.add(key);
- chunkPointerItemsForEntry.push(pointer);
- if (!firstChunkPointer && chunkPayloadIndex === 0) {
- firstChunkPointer = pointer;
- }
- }
- }
- }
-
- if (effectiveSnapshot.chunkAddresses instanceof Set) {
- effectiveSnapshot.chunkAddresses.forEach((address) => {
- const pointer = clonePointerItem({
- type: "a",
- value: address,
- relay: null,
- });
- if (!pointer) {
- return;
- }
- const key = pointerKey(pointer);
- if (!key || chunkPointerKeys.has(key)) {
- return;
- }
- chunkPointerKeys.add(key);
- chunkPointerItemsForEntry.push(pointer);
- });
- }
-
- const headPointer = indexPointerForEntry || firstChunkPointer || null;
- entry.chunkPointers = {
- head: headPointer,
- index: indexPointerForEntry,
- chunks: chunkPointerItemsForEntry,
- };
-
- if (headPointer) {
- const headPointerKey = pointerKey(headPointer);
- if (headPointerKey) {
- chunkPointerKeys.add(headPointerKey);
- const indexPointerKey = indexPointerForEntry
- ? pointerKey(indexPointerForEntry)
- : "";
- if (
- entry.pointerEvent &&
- indexPointerKey &&
- headPointerKey === indexPointerKey
- ) {
- entry.chunkEvents.set(
- headPointerKey,
- cloneEventForCache(entry.pointerEvent)
- );
- }
- }
- }
-
- for (const key of Array.from(entry.resolved.keys())) {
- if (!key || !key.startsWith("a:")) {
- continue;
- }
- const pointerValue = key.slice(2);
- if (!pointerValue) {
- continue;
- }
- const pointerKind = Number.parseInt(pointerValue.split(":")[0], 10);
- if (!Number.isFinite(pointerKind) || !isWatchHistoryKind(pointerKind)) {
- continue;
- }
- if (!chunkPointerKeys.has(key)) {
- entry.resolved.delete(key);
- }
- }
-
- for (const key of chunkPointerKeys) {
- const eventForKey = entry.chunkEvents.get(key);
- if (eventForKey) {
- entry.resolved.set(key, cloneEventForCache(eventForKey));
- }
- }
- }
- this.watchHistoryCache.set(actor, entry);
- this.persistWatchHistoryEntry(actor, entry);
-
- if (
- (!effectiveSnapshot || !effectiveSnapshot.pointerEvent) &&
- usedFallback &&
- entry.items.length
- ) {
- this.scheduleWatchHistoryRepublish(actor, entry.items);
- }
-
- return {
- pointerEvent: entry.pointerEvent
- ? cloneEventForCache(entry.pointerEvent)
- : null,
- items: entry.items
- .map((item) => clonePointerItem(item))
- .filter(Boolean),
- };
- }
-
- async resolveWatchHistory(batchSize = 20) {
- if (!this.pool) {
- return [];
- }
-
- const actor = await this.ensureSessionActor();
- if (!actor) {
- return [];
- }
-
- await this.fetchWatchHistory(actor);
- const entry = this.watchHistoryCache.get(actor);
- if (!entry) {
- return [];
- }
-
- if (!(entry.resolvedVideos instanceof Map)) {
- entry.resolvedVideos = new Map();
- }
- if (!(entry.delivered instanceof Set)) {
- entry.delivered = new Set();
- }
-
- const parsedBatchSize = Number(batchSize);
- const normalizedBatchSize =
- Number.isFinite(parsedBatchSize) && parsedBatchSize > 0
- ? Math.floor(parsedBatchSize)
- : 20;
- const effectiveBatchSize = WATCH_HISTORY_BATCH_RESOLVE
- ? Math.max(1, normalizedBatchSize)
- : 1;
-
- const classifyWatchHistoryPointer = (pointer, ownerPubkey) => {
- if (!pointer || pointer.type !== "a") {
- return "none";
- }
- const value =
- typeof pointer.value === "string" ? pointer.value.trim() : "";
- if (!value) {
- return "none";
- }
- const parts = value.split(":");
- if (parts.length < 3) {
- return "none";
- }
- const [kindStr, pubkeyRaw, ...identifierParts] = parts;
- const kind = Number.parseInt(kindStr, 10);
- if (!Number.isFinite(kind) || !isWatchHistoryKind(kind)) {
- return "none";
- }
- const identifier = identifierParts.join(":");
- const normalizedIdentifier =
- typeof identifier === "string" ? identifier.trim() : "";
- if (!normalizedIdentifier) {
- return "none";
- }
- if (isIndexIdentifier(normalizedIdentifier)) {
- return "head";
- }
- if (isLegacyHeadIdentifier(normalizedIdentifier)) {
- return "head";
- }
- if (!isWatchHistoryChunkIdentifier(normalizedIdentifier)) {
- return "none";
- }
- const normalizedOwner =
- typeof ownerPubkey === "string" && ownerPubkey.trim()
- ? ownerPubkey.trim().toLowerCase()
- : "";
- const normalizedPubkey =
- typeof pubkeyRaw === "string" && pubkeyRaw.trim()
- ? pubkeyRaw.trim().toLowerCase()
- : "";
- if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) {
- return "chunk";
- }
- return "none";
- };
-
- const addPointerToMap = (pointer, map) => {
- const normalized = clonePointerItem(pointer);
- if (!normalized) {
- return null;
- }
- const key = pointerKey(normalized);
- if (!key) {
- return null;
- }
- map.set(key, normalized);
- return normalized;
- };
-
- const headPointerMap = new Map();
- const chunkPointerMap = new Map();
- const videoCandidates = [];
-
- if (entry.chunkPointers?.head) {
- addPointerToMap(entry.chunkPointers.head, headPointerMap);
- }
- if (Array.isArray(entry.chunkPointers?.chunks)) {
- for (const chunkPointer of entry.chunkPointers.chunks) {
- addPointerToMap(chunkPointer, chunkPointerMap);
- }
- }
-
- for (const item of entry.items) {
- const pointer = clonePointerItem(item);
- if (!pointer) {
- continue;
- }
- const pointerType = classifyWatchHistoryPointer(pointer, actor);
- if (pointerType === "head") {
- addPointerToMap(pointer, headPointerMap);
- continue;
- }
- if (pointerType === "chunk") {
- addPointerToMap(pointer, chunkPointerMap);
- continue;
- }
- videoCandidates.push(pointer);
- }
-
- const pointerEventClone = entry.pointerEvent
- ? cloneEventForCache(entry.pointerEvent)
- : null;
- for (const pointer of headPointerMap.values()) {
- const key = pointerKey(pointer);
- if (!key) {
- continue;
- }
- if (pointerEventClone) {
- entry.resolved.set(key, pointerEventClone);
- if (entry.chunkEvents instanceof Map) {
- entry.chunkEvents.set(key, pointerEventClone);
- }
- } else if (!entry.resolved.has(key)) {
- entry.resolved.set(key, null);
- }
- entry.resolving.delete(key);
- }
-
- const available = videoCandidates.filter((item) => {
- const key = pointerKey(item);
- if (!key) {
- return false;
- }
- if (entry.delivered.has(key)) {
- return false;
- }
- if (entry.resolving.has(key)) {
- return false;
- }
- if (entry.resolvedVideos.has(key)) {
- const cachedVideo = entry.resolvedVideos.get(key);
- if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
- return false;
- }
- }
- return true;
- });
-
- const chunkAvailable = [];
- for (const [key, pointer] of chunkPointerMap.entries()) {
- if (!key) {
- continue;
- }
- if (entry.resolved.has(key)) {
- continue;
- }
- if (entry.resolving.has(key)) {
- continue;
- }
- const cachedChunk = entry.chunkEvents?.get?.(key);
- if (cachedChunk) {
- const cloned = cloneEventForCache(cachedChunk);
- if (cloned) {
- entry.chunkEvents.set(key, cloned);
- entry.resolved.set(key, cloned);
- } else {
- entry.resolved.set(key, cachedChunk);
- }
- continue;
- }
- chunkAvailable.push(pointer);
- }
-
- const videoBatch = available.slice(0, effectiveBatchSize);
- const batch = [...videoBatch, ...chunkAvailable];
-
- const relaySet = new Set(
- Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS
- );
- const filters = [];
-
- if (batch.length) {
- batch.forEach((item) => {
- const key = pointerKey(item);
- if (key) {
- entry.resolving.add(key);
- }
- });
-
- for (const pointer of batch) {
- if (pointer.relay) {
- relaySet.add(pointer.relay);
- }
- if (pointer.type === "e") {
- filters.push({ ids: [pointer.value] });
- } else if (pointer.type === "a") {
- const [kindStr, pubkey, identifier] = pointer.value.split(":");
- const kind = Number(kindStr);
- if (Number.isFinite(kind) && pubkey) {
- const filter = { kinds: [kind], authors: [pubkey] };
- if (identifier) {
- filter["#d"] = [identifier];
- }
- filters.push(filter);
- }
- }
- }
- }
-
- let events = [];
- if (filters.length) {
- try {
- events = await this.pool.list(Array.from(relaySet), filters);
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to resolve watch history batch:", error);
- }
- }
- } else if (batch.length) {
- batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
- }
-
- const pointerMatches = new Map();
-
- for (const evt of events || []) {
- if (!evt || typeof evt !== "object") {
- continue;
- }
- if (typeof evt.id === "string" && evt.id) {
- pointerMatches.set(
- pointerKey({ type: "e", value: evt.id }),
- evt
- );
- }
- const addressPointer = eventToAddressPointer(evt);
- if (addressPointer) {
- pointerMatches.set(
- pointerKey({ type: "a", value: addressPointer }),
- evt
- );
- }
- }
-
- for (const pointer of batch) {
- const key = pointerKey(pointer);
- if (!key) {
- continue;
- }
- const pointerType = classifyWatchHistoryPointer(pointer, actor);
- const event = pointerMatches.get(key);
- if (pointerType === "chunk" || pointerType === "head") {
- if (event) {
- const cloned = cloneEventForCache(event) || event;
- if (!entry.chunkEvents) {
- entry.chunkEvents = new Map();
- }
- entry.chunkEvents.set(key, cloned);
- entry.resolved.set(key, cloned);
- }
- entry.resolving.delete(key);
- continue;
- }
-
- if (event) {
- try {
- const video = convertEventToVideo(event);
- if (!video.invalid) {
- this.allEvents.set(event.id, video);
- if (!video.deleted) {
- const activeKey = getActiveKey(video);
- const existing = this.activeMap.get(activeKey);
- if (!existing || video.created_at > existing.created_at) {
- this.activeMap.set(activeKey, video);
- }
- }
- entry.resolvedVideos.set(key, video);
- }
- } catch (err) {
- if (isDevMode) {
- console.warn("[nostr] Failed to convert watch history event:", err);
- }
- }
- }
- entry.resolving.delete(key);
- }
-
- const deliverableVideos = [];
- for (const pointer of videoCandidates) {
- if (deliverableVideos.length >= effectiveBatchSize) {
- break;
- }
- const key = pointerKey(pointer);
- if (!key || entry.delivered.has(key)) {
- continue;
- }
- const cachedVideo = entry.resolvedVideos.get(key);
- if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
- const pointerClone = clonePointerItem(pointer);
- const watchedAt = Number.isFinite(pointerClone?.watchedAt)
- ? pointerClone.watchedAt
- : 0;
- const videoWithHistory = {
- ...cachedVideo,
- watchHistory: {
- key,
- watchedAt,
- pointer:
- pointerClone || {
- type: pointer.type,
- value: pointer.value,
- relay: pointer.relay || null,
- watchedAt,
- },
- },
- };
- deliverableVideos.push(videoWithHistory);
- entry.delivered.add(key);
- }
- }
-
- return deliverableVideos;
- }
-
async recordVideoView(videoPointer, options = {}) {
const pointer = normalizePointerInput(videoPointer);
if (!pointer) {
diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs
index 3b87f52f..ac78e4a4 100644
--- a/tests/view-counter.test.mjs
+++ b/tests/view-counter.test.mjs
@@ -530,7 +530,6 @@ async function testRecordVideoViewEmitsJsonPayload() {
const originalEnsureSessionActor = nostrClient.ensureSessionActor;
const originalSessionActor = nostrClient.sessionActor;
const originalPubkey = nostrClient.pubkey;
- const originalUpdateHistory = nostrClient.updateWatchHistoryList;
const originalWarn = console.warn;
const originalNostrTools = window.NostrTools;
@@ -582,11 +581,6 @@ async function testRecordVideoViewEmitsJsonPayload() {
};
nostrClient.pubkey = "";
- nostrClient.updateWatchHistoryList = async () => {
- throw new Error(
- "recordVideoView should not invoke updateWatchHistoryList during view publishes"
- );
- };
try {
const result = await nostrClient.recordVideoView(pointer, {
@@ -633,7 +627,6 @@ async function testRecordVideoViewEmitsJsonPayload() {
nostrClient.ensureSessionActor = originalEnsureSessionActor;
nostrClient.sessionActor = originalSessionActor;
nostrClient.pubkey = originalPubkey;
- nostrClient.updateWatchHistoryList = originalUpdateHistory;
console.warn = originalWarn;
window.NostrTools = originalNostrTools;
}
diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs
deleted file mode 100644
index 8d95e0d5..00000000
--- a/tests/watch-history.test.mjs
+++ /dev/null
@@ -1,1662 +0,0 @@
-import "./test-helpers/setup-localstorage.mjs";
-import assert from "node:assert/strict";
-
-const {
- WATCH_HISTORY_LIST_IDENTIFIER,
- WATCH_HISTORY_KIND,
- WATCH_HISTORY_FETCH_EVENT_LIMIT,
-} = await import("../js/config.js");
-const {
- NOTE_TYPES,
- getNostrEventSchema,
- setNostrEventSchemaOverrides,
- buildWatchHistoryIndexEvent,
- buildWatchHistoryChunkEvent,
-} = await import("../js/nostrEventSchemas.js");
-const { compareByWatchedAtDesc, getWatchedAtScore } = await import(
- "../js/watchHistorySort.js"
-);
-
-if (typeof globalThis.window === "undefined") {
- globalThis.window = {};
-}
-
-if (!globalThis.window.NostrTools) {
- globalThis.window.NostrTools = {};
-}
-
-globalThis.window.NostrTools.getEventHash = (event) => {
- const payload = `${event.kind}:${event.pubkey}:${event.created_at}`;
- return `hash-${Buffer.from(payload).toString("hex")}`;
-};
-
-globalThis.window.NostrTools.signEvent = (event, privateKey) =>
- `sig-${privateKey.slice(0, 8)}-${event.created_at}`;
-
-delete globalThis.window.NostrTools.nip04;
-delete globalThis.window.nostr;
-
-const { nostrClient } = await import("../js/nostr.js");
-
-function createPublishingClient(actorPubkey, options = {}) {
- const client = new nostrClient.constructor();
- const publishedEvents = [];
- const payloads = [];
- const scheduledRepublishes = [];
- const cancelledRepublishes = [];
- const persistedEntries = [];
- const onPublish =
- typeof options.onPublish === "function" ? options.onPublish : null;
-
- client.pool = {
- publish(urls, event) {
- publishedEvents.push(event);
- const publishDecision = onPublish ? onPublish(event) : null;
- const shouldSucceed = publishDecision?.ok !== false;
- return {
- on(eventName, handler) {
- setTimeout(() => {
- if (shouldSucceed) {
- if (eventName === "ok" || eventName === "seen") {
- handler();
- }
- } else if (eventName === "failed") {
- const providedError = publishDecision?.error;
- const error =
- providedError instanceof Error
- ? providedError
- : new Error(
- providedError
- ? String(providedError)
- : "mock publish failure"
- );
- handler(error);
- }
- }, 0);
- },
- };
- },
- list: async () => [],
- };
- client.relays = ["wss://unit.test"];
- client.pubkey = "";
- client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
- client.ensureSessionActor = async () => actorPubkey;
- client.encryptWatchHistoryPayload = async (_actor, payload) => {
- payloads.push(payload);
- return { ok: true, ciphertext: JSON.stringify(payload) };
- };
- client.persistWatchHistoryEntry = (_actor, entry) => {
- persistedEntries.push(entry);
- };
- client.cancelWatchHistoryRepublish = (actor) => {
- cancelledRepublishes.push(actor);
- };
- client.scheduleWatchHistoryRepublish = (actor, items) => {
- scheduledRepublishes.push({ actor, items });
- };
- return {
- client,
- publishedEvents,
- payloads,
- scheduledRepublishes,
- cancelledRepublishes,
- persistedEntries,
- };
-}
-
-function createDecryptClient(actorPubkey) {
- const client = new nostrClient.constructor();
- client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
- client.ensureSessionActor = async () => actorPubkey;
- return client;
-}
-
-const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
-const LEGACY_WATCH_HISTORY_KIND = 30078;
-function deriveChunkPrefix(identifier) {
- if (typeof identifier !== "string") {
- return "watch-history:v2";
- }
-
- const trimmed = identifier.trim().replace(/\/+$/, "");
- if (!trimmed) {
- return "watch-history:v2";
- }
-
- if (/[:/]index$/i.test(trimmed)) {
- const base = trimmed.replace(/[:/]index$/i, "").replace(/\/+$/, "");
- return base || "watch-history:v2";
- }
-
- if (/:v\d+$/i.test(trimmed)) {
- return trimmed;
- }
-
- return `${trimmed}:v2`;
-}
-
-const WATCH_HISTORY_CHUNK_PREFIX = deriveChunkPrefix(
- WATCH_HISTORY_LIST_IDENTIFIER
-);
-
-const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
-assert(indexSchema, "index schema should be available");
-
-const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
-assert(chunkSchema, "chunk schema should be available");
-
-const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`;
-const sampleChunkAddresses = [
- `${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`,
- `${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`,
-];
-
-const sampleIndexEvent = buildWatchHistoryIndexEvent({
- pubkey: ACTOR,
- created_at: 111,
- snapshotId: "sample",
- totalChunks: 2,
- chunkAddresses: sampleChunkAddresses,
-});
-
-const sampleIndexIdentifierTag = sampleIndexEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
-);
-assert(sampleIndexIdentifierTag, "index event should include identifier tag");
-assert.equal(
- sampleIndexIdentifierTag[1],
- WATCH_HISTORY_LIST_IDENTIFIER,
- "index event should use canonical watch history identifier"
-);
-
-const sampleChunksTag = sampleIndexEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "chunks"
-);
-assert(sampleChunksTag, "index event should include chunks tag");
-assert.equal(
- Number.parseInt(sampleChunksTag[1], 10),
- 2,
- "chunks tag should reflect total chunks"
-);
-
-const sampleIndexAddresses = sampleIndexEvent.tags
- .filter((tag) => Array.isArray(tag) && tag[0] === "a")
- .map((tag) => tag[1]);
-assert.deepEqual(
- sampleIndexAddresses,
- sampleChunkAddresses,
- "index event should list chunk addresses"
-);
-
-const sampleChunkEvent = buildWatchHistoryChunkEvent({
- pubkey: ACTOR,
- created_at: 222,
- chunkIdentifier: sampleChunkIdentifier,
- snapshotId: "sample",
- chunkIndex: 0,
- totalChunks: 2,
- pointerTags: [["e", "sample-event"]],
- content: "ciphertext",
-});
-
-const chunkIdentifierTag = sampleChunkEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
-);
-assert(chunkIdentifierTag, "chunk event should include identifier tag");
-assert.equal(
- chunkIdentifierTag[1],
- sampleChunkIdentifier,
- "chunk event should use provided identifier"
-);
-assert(
- sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"),
- "chunk event should include snapshot tag"
-);
-assert(
- sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"),
- "chunk event should include chunk tag"
-);
-assert(
- sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"),
- "chunk event should include encrypted tag"
-);
-
-setNostrEventSchemaOverrides({
- [NOTE_TYPES.WATCH_HISTORY_INDEX]: {
- identifierTag: { name: "d", value: "custom:index" },
- },
- [NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
- identifierTag: { name: "d", value: "custom:chunk" },
- },
-});
-
-const overriddenIndexEvent = buildWatchHistoryIndexEvent({
- pubkey: ACTOR,
- created_at: 333,
- snapshotId: "override",
- totalChunks: 1,
-});
-assert(
- overriddenIndexEvent.tags.some(
- (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index"
- ),
- "index builder should honor overrides"
-);
-
-const overriddenChunkEvent = buildWatchHistoryChunkEvent({
- pubkey: ACTOR,
- created_at: 444,
- snapshotId: "override",
- chunkIndex: 0,
- totalChunks: 1,
- pointerTags: [],
- content: "cipher",
-});
-assert(
- overriddenChunkEvent.tags.some(
- (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk"
- ),
- "chunk builder should honor identifier override"
-);
-
-setNostrEventSchemaOverrides({});
-
-const { client: publishingClient, publishedEvents, payloads } =
- createPublishingClient(ACTOR);
-
-publishingClient.watchHistoryPayloadMaxBytes = 400;
-publishingClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
-
-const baseWatchedAt = 1_700_000_000_000;
-const longPointers = Array.from({ length: 8 }, (_, index) => {
- const relay = index % 3 === 0 ? "wss://relay.unit" : null;
- const watchedAt = baseWatchedAt + index * 60_000;
- if (index % 2 === 0) {
- return {
- type: "e",
- value: `event-${index}-${"x".repeat(90)}`,
- relay,
- watchedAt,
- };
- }
- return {
- type: "a",
- value: `${WATCH_HISTORY_KIND}:${ACTOR}:history-${index}-${"y".repeat(60)}`,
- relay,
- watchedAt,
- };
-});
-
-const publishResult = await publishingClient.publishWatchHistorySnapshot(
- ACTOR,
- longPointers
-);
-
-assert.equal(publishResult.ok, true, "publish should report success");
-assert(publishResult.events.length > 1, "snapshot should include index and chunk events");
-assert.equal(
- publishResult.events.length,
- payloads.length + 1,
- "index event should accompany each chunk payload"
-);
-
-payloads.forEach((payload) => {
- const size = JSON.stringify(payload).length;
- assert(
- size <= publishingClient.watchHistoryPayloadMaxBytes,
- `payload size ${size} should respect cap`
- );
- assert.equal(payload.version, 2, "chunk payload should use version 2");
-});
-
-const snapshotIds = new Set(
- publishResult.events.map((event) =>
- event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
- )
-);
-assert.equal(snapshotIds.size, 1, "all events should share a snapshot id");
-
-const indexEvent = publishResult.indexEvent;
-assert(indexEvent, "publish result should include index event");
-
-const indexIdentifierTag = indexEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
-);
-assert(indexIdentifierTag, "index event should include identifier tag");
-assert.equal(
- indexIdentifierTag[1],
- WATCH_HISTORY_LIST_IDENTIFIER,
- "index event should use canonical identifier"
-);
-
-const indexChunksTag = indexEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "chunks"
-);
-assert(indexChunksTag, "index event should include chunks tag");
-assert.equal(
- Number.parseInt(indexChunksTag[1], 10),
- publishResult.chunks.length,
- "index event should report chunk count"
-);
-
-const chunkEvents = publishResult.chunks.map((chunk) => chunk.event);
-const chunkIdentifiers = chunkEvents.map((event) =>
- event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
-);
-
-assert.equal(
- chunkIdentifiers.length,
- chunkEvents.length,
- "each chunk should expose a d tag"
-);
-
-const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
-assert.equal(
- uniqueChunkIdentifiers.size,
- chunkEvents.length,
- "each chunk should have a unique d tag"
-);
-
-chunkIdentifiers.forEach((identifier, index) => {
- assert(identifier, `chunk ${index} should include identifier`);
- assert(
- identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX),
- `chunk ${index} should use the chunk prefix`
- );
- assert.notEqual(
- identifier,
- WATCH_HISTORY_LIST_IDENTIFIER,
- "chunk identifiers should differ from index identifier"
- );
-});
-
-const indexAddresses = indexEvent.tags
- .filter((tag) => Array.isArray(tag) && tag[0] === "a")
- .map((tag) => tag[1]);
-
-const expectedChunkAddresses = chunkEvents.map((event) => {
- const identifier = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
- )?.[1];
- assert(identifier, "chunk should include identifier tag");
- return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`;
-});
-
-expectedChunkAddresses.forEach((address) => {
- assert(
- indexAddresses.includes(address),
- `index event should reference ${address}`
- );
-});
-
-const CHUNK_FAILURE_ACTOR = `${ACTOR}-chunk-failure`;
-const {
- client: chunkFailureClient,
- publishedEvents: chunkFailureEvents,
- payloads: chunkFailurePayloads,
- scheduledRepublishes: chunkFailureRepublishes,
- cancelledRepublishes: chunkFailureCancels,
-} = createPublishingClient(CHUNK_FAILURE_ACTOR, {
- onPublish(event) {
- const chunkTag = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "chunk"
- );
- if (chunkTag) {
- const chunkIndex = Number.parseInt(chunkTag[1], 10);
- const total = Number.parseInt(chunkTag[2], 10);
- if (
- Number.isInteger(chunkIndex) &&
- Number.isInteger(total) &&
- (total > 1 ? chunkIndex === total - 1 : chunkIndex === 0)
- ) {
- return { ok: false, error: new Error("chunk publish failed") };
- }
- }
- return { ok: true };
- },
-});
-
-chunkFailureClient.watchHistoryPayloadMaxBytes =
- publishingClient.watchHistoryPayloadMaxBytes;
-chunkFailureClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
-
-const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot(
- CHUNK_FAILURE_ACTOR,
- longPointers
-);
-
-assert.equal(
- chunkFailureResult.ok,
- false,
- "chunk failure should report overall failure"
-);
-assert.equal(
- chunkFailureResult.indexEvent,
- null,
- "chunk failure should omit index event"
-);
-assert.equal(
- chunkFailureResult.events.length,
- chunkFailureResult.chunks.length,
- "chunk failure should not include index event in combined events"
-);
-assert.equal(
- chunkFailureResult.indexResults.length,
- 0,
- "chunk failure should skip index publish attempts"
-);
-assert(
- chunkFailureResult.chunks.some((chunk) => chunk.success === false),
- "chunk failure results should flag the failed chunk"
-);
-assert.equal(
- chunkFailureRepublishes.length,
- 1,
- "chunk failure should schedule a retry"
-);
-assert.equal(
- chunkFailureCancels.length,
- 0,
- "chunk failure should not cancel retries"
-);
-assert.equal(
- chunkFailureRepublishes[0].actor,
- CHUNK_FAILURE_ACTOR,
- "chunk failure retry should target the affected actor"
-);
-assert(
- chunkFailureEvents.every((event) =>
- event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk")
- ),
- "chunk failure should avoid publishing index events"
-);
-assert.equal(
- chunkFailurePayloads.length,
- chunkFailureResult.chunks.length,
- "chunk failure should still prepare payloads for each chunk"
-);
-
-const chunkFailureCache = chunkFailureClient.watchHistoryCache.get(
- CHUNK_FAILURE_ACTOR
-);
-
-assert(chunkFailureCache, "chunk failure should cache a watch history entry");
-assert.equal(
- chunkFailureCache.chunkPointers.index,
- null,
- "chunk failure cache should not retain an index pointer"
-);
-assert(
- Array.from(chunkFailureCache.chunkEvents.values()).every((event) =>
- event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk")
- ),
- "chunk failure cache should only persist chunk events"
-);
-assert.deepEqual(
- chunkFailureRepublishes[0].items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- chunkFailureResult.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- "republish scheduler should receive the remaining items"
-);
-
-const LOGGED_ACTOR = `${ACTOR}-logged`;
-const {
- client: noSignerClient,
- publishedEvents: noSignerPublishedEvents,
- scheduledRepublishes: noSignerRepublishes,
- persistedEntries: noSignerPersistedEntries,
-} = createPublishingClient(LOGGED_ACTOR);
-
-noSignerClient.pubkey = LOGGED_ACTOR;
-
-const noSignerItems = [
- {
- type: "e",
- value: `${LOGGED_ACTOR}-event`,
- watchedAt: baseWatchedAt + 123,
- },
- {
- type: "a",
- value: `${WATCH_HISTORY_KIND}:${LOGGED_ACTOR}:history`,
- watchedAt: baseWatchedAt + 456,
- },
-];
-
-const noSignerResult = await noSignerClient.publishWatchHistorySnapshot(
- LOGGED_ACTOR,
- noSignerItems
-);
-
-assert.equal(
- noSignerResult.ok,
- false,
- "logged actor without signer should report failure"
-);
-assert.equal(
- noSignerResult.error,
- "signing-unavailable",
- "missing signer failure should flag availability"
-);
-assert.equal(
- noSignerPublishedEvents.length,
- 0,
- "no events should publish when signer unavailable"
-);
-assert.equal(
- noSignerRepublishes.length,
- 1,
- "missing signer should schedule a retry"
-);
-assert.equal(
- noSignerRepublishes[0].actor,
- LOGGED_ACTOR,
- "retry should target the logged actor"
-);
-
-const cachedLoggedEntry = noSignerClient.watchHistoryCache.get(LOGGED_ACTOR);
-assert(cachedLoggedEntry, "missing signer should keep snapshot cached");
-assert.equal(
- cachedLoggedEntry.items.length,
- noSignerItems.length,
- "cached entry should retain merged items"
-);
-assert.equal(
- noSignerPersistedEntries.length,
- 1,
- "missing signer should persist cached payload"
-);
-assert.deepEqual(
- cachedLoggedEntry.items.map((item) => ({
- type: item.type,
- value: item.value,
- })),
- noSignerResult.items.map((item) => ({
- type: item.type,
- value: item.value,
- })),
- "returned items should mirror cached payload"
-);
-
-const scheduledLoggedKeys = new Set(
- noSignerRepublishes[0].items.map((item) => `${item.type}:${item.value}`)
-);
-const cachedLoggedKeys = new Set(
- cachedLoggedEntry.items.map((item) => `${item.type}:${item.value}`)
-);
-assert.deepEqual(
- scheduledLoggedKeys,
- cachedLoggedKeys,
- "scheduled retry should include cached items"
-);
-
-const NO_SEEN_ACTOR = `${ACTOR}-no-seen`;
-const {
- client: noSeenClient,
- publishedEvents: noSeenPublishedEvents,
-} = createPublishingClient(NO_SEEN_ACTOR);
-
-noSeenClient.pool.publish = (_urls, event) => {
- noSeenPublishedEvents.push(event);
- return {
- on(eventName, handler) {
- if (eventName === "seen") {
- throw new TypeError("seen unsupported");
- }
- if (eventName === "ok") {
- setTimeout(handler, 0);
- } else if (eventName === "failed") {
- // Never invoke failed handlers in this mock; they should remain idle.
- }
- },
- };
-};
-
-const noSeenResult = await noSeenClient.publishWatchHistorySnapshot(
- NO_SEEN_ACTOR,
- [{ type: "e", value: "no-seen-pointer", relay: null }]
-);
-
-assert.equal(
- noSeenResult.ok,
- true,
- "publish should succeed when relay handle rejects seen handlers"
-);
-assert(
- noSeenResult.events.length > 0,
- "publish should still return the emitted events when seen is unsupported"
-);
-
-delete globalThis.window.nostr;
-
-if (!globalThis.window.NostrTools.nip04) {
- globalThis.window.NostrTools.nip04 = {};
-}
-
-globalThis.window.NostrTools.nip04.decrypt = async (
- _priv,
- _pub,
- ciphertext
-) => ciphertext;
-
-const decryptClient = createDecryptClient(ACTOR);
-decryptClient.relays = ["wss://unit.test"];
-decryptClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
-decryptClient.pool = {
- list: async () => publishedEvents,
-};
-
-const fetched = await decryptClient.fetchWatchHistory(ACTOR);
-
-assert.equal(
- fetched.items.length,
- longPointers.length,
- "fetch should reassemble all chunked items"
-);
-
-assert.deepEqual(
- fetched.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- longPointers.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- "chunked fetch should preserve pointer order and timestamps"
-);
-
-if (typeof localStorage !== "undefined") {
- localStorage.clear();
-}
-
-const headEvent = publishResult.event;
-
-const legacyPointerIdentifiers = new Set();
-const legacyIndexEvent = JSON.parse(JSON.stringify(headEvent));
-legacyIndexEvent.kind = LEGACY_WATCH_HISTORY_KIND;
-legacyIndexEvent.tags = legacyIndexEvent.tags.map((tag) => {
- if (
- Array.isArray(tag) &&
- tag[0] === "a" &&
- typeof tag[1] === "string" &&
- !legacyPointerIdentifiers.size
- ) {
- const [kindStr, pubkey, ...identifierParts] = tag[1].split(":");
- if (kindStr === String(WATCH_HISTORY_KIND) && identifierParts.length) {
- const identifier = identifierParts.join(":");
- legacyPointerIdentifiers.add(identifier);
- const legacyAddress = `${LEGACY_WATCH_HISTORY_KIND}:${pubkey}:${identifier}`;
- const clone = tag.slice();
- clone[1] = legacyAddress;
- return clone;
- }
- }
- return tag;
-});
-const legacyChunkIdentifier = legacyPointerIdentifiers.size
- ? Array.from(legacyPointerIdentifiers)[0]
- : null;
-
-const snapshotId = headEvent.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "snapshot"
-)?.[1];
-
-const freshClient = createDecryptClient(ACTOR);
-freshClient.relays = ["wss://unit.test"];
-freshClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
-
-const observedFilters = [];
-freshClient.pool = {
- list: async (_relays, filters) => {
- observedFilters.push(filters);
-
- const firstFilter = filters[0] || {};
- const dFilter = Array.isArray(firstFilter["#d"])
- ? firstFilter["#d"]
- : [];
-
- if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
- return [legacyIndexEvent];
- }
-
- const requestedIdentifiers = new Set();
- const requestedSnapshots = new Set();
-
- for (const filter of filters) {
- if (Array.isArray(filter?.["#d"])) {
- for (const identifier of filter["#d"]) {
- requestedIdentifiers.add(identifier);
- }
- }
- if (Array.isArray(filter?.["#snapshot"])) {
- for (const value of filter["#snapshot"]) {
- requestedSnapshots.add(value);
- }
- }
- }
-
- const responses = [];
-
- for (const event of publishResult.events) {
- if (event.id === headEvent.id) {
- continue;
- }
- const cloned = JSON.parse(JSON.stringify(event));
- if (legacyChunkIdentifier) {
- const identifierTag = cloned.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
- );
- if (identifierTag?.[1] === legacyChunkIdentifier) {
- cloned.kind = LEGACY_WATCH_HISTORY_KIND;
- }
- }
- const identifier = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
- )?.[1];
- const eventSnapshot = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "snapshot"
- )?.[1];
-
- if (identifier && requestedIdentifiers.has(identifier)) {
- responses.push(cloned);
- continue;
- }
- if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) {
- responses.push(cloned);
- }
- }
-
- return responses;
- },
-};
-
-const rebuilt = await freshClient.fetchWatchHistory(ACTOR);
-
-assert.equal(
- rebuilt.items.length,
- longPointers.length,
- "fresh client should rebuild full watch history list"
-);
-
-assert.deepEqual(
- rebuilt.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- longPointers.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- watchedAt: item.watchedAt,
- })),
- "rebuilt snapshot should preserve timestamps"
-);
-
-assert(
- observedFilters.some((filters) =>
- filters.some((filter) => {
- const kinds = Array.isArray(filter?.kinds) ? filter.kinds : [];
- return (
- kinds.includes(WATCH_HISTORY_KIND) &&
- kinds.includes(LEGACY_WATCH_HISTORY_KIND)
- );
- })
- ),
- "fetchWatchHistory should request both new and legacy kinds during migration"
-);
-
-assert(
- observedFilters.length >= 2,
- "fetch should include a follow-up query for chunk events"
-);
-
-const followUpFilters = observedFilters[observedFilters.length - 1];
-const identifiersRequested = new Set();
-let snapshotRequested = false;
-
-for (const filter of followUpFilters) {
- if (Array.isArray(filter?.["#d"])) {
- for (const identifier of filter["#d"]) {
- identifiersRequested.add(identifier);
- }
- }
- if (Array.isArray(filter?.["#snapshot"])) {
- snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId);
- }
-}
-
-assert(
- chunkEvents.every((event) => {
- const identifier = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "d"
- )?.[1];
- return (
- identifiersRequested.has(identifier) ||
- (snapshotId && snapshotRequested)
- );
- }),
- "follow-up query should target chunk identifiers or snapshot"
-);
-
-const OVERFLOW_ACTOR = `${ACTOR}-overflow`;
-const overflowSnapshotId = "overflow-snapshot";
-const overflowTotalChunks = 18;
-const overflowBaseCreatedAt = 1_700_555_000;
-
-const overflowPointers = Array.from({ length: overflowTotalChunks }, (_, index) => {
- const relay = index % 3 === 0 ? "wss://overflow.unit" : null;
- return {
- type: index % 2 === 0 ? "e" : "a",
- value:
- index % 2 === 0
- ? `overflow-event-${index}`
- : `${WATCH_HISTORY_KIND}:${OVERFLOW_ACTOR}:overflow-${index}`,
- relay,
- watchedAt: baseWatchedAt + index * 45_000,
- };
-});
-
-const overflowChunkEvents = overflowPointers.map((pointer, index) => {
- const chunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/${overflowSnapshotId}/${index}`;
- const pointerTag = [pointer.type, pointer.value];
- if (pointer.relay) {
- pointerTag.push(pointer.relay);
- }
- const payload = {
- version: 2,
- snapshot: overflowSnapshotId,
- chunkIndex: index,
- totalChunks: overflowTotalChunks,
- items: [pointer],
- };
- const event = buildWatchHistoryChunkEvent({
- pubkey: OVERFLOW_ACTOR,
- created_at: overflowBaseCreatedAt + (overflowTotalChunks - index),
- chunkIdentifier,
- snapshotId: overflowSnapshotId,
- chunkIndex: index,
- totalChunks: overflowTotalChunks,
- pointerTags: [pointerTag],
- chunkAddresses: [],
- content: JSON.stringify(payload),
- });
- event.id = window.NostrTools.getEventHash(event);
- event.sig = `sig-overflow-chunk-${index}`;
- return event;
-});
-
-const overflowIndexEvent = buildWatchHistoryIndexEvent({
- pubkey: OVERFLOW_ACTOR,
- created_at: overflowBaseCreatedAt + overflowTotalChunks + 5,
- snapshotId: overflowSnapshotId,
- totalChunks: overflowTotalChunks,
- chunkAddresses: [],
-});
-overflowIndexEvent.id = window.NostrTools.getEventHash(overflowIndexEvent);
-overflowIndexEvent.sig = "sig-overflow-index";
-
-const overflowClient = createDecryptClient(OVERFLOW_ACTOR);
-overflowClient.relays = ["wss://overflow.test"];
-// Simulate deployments that kept the fetch limit near the historical default.
-overflowClient.watchHistoryFetchEventLimit = 12;
-
-const overflowChunkLimits = [];
-overflowClient.pool = {
- list: async (_relays, filters) => {
- const firstFilter = filters[0] || {};
- const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : [];
- if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
- return [overflowIndexEvent];
- }
-
- filters.forEach((filter) => {
- if (Number.isFinite(filter?.limit)) {
- overflowChunkLimits.push(filter.limit);
- }
- });
-
- const requestedSnapshots = new Set();
- filters.forEach((filter) => {
- if (Array.isArray(filter?.["#snapshot"])) {
- filter["#snapshot"].forEach((value) => {
- if (typeof value === "string" && value) {
- requestedSnapshots.add(value);
- }
- });
- }
- });
-
- if (!requestedSnapshots.size) {
- return [];
- }
-
- return overflowChunkEvents.filter((event) => {
- const snapshotTag = event.tags.find(
- (tag) => Array.isArray(tag) && tag[0] === "snapshot"
- );
- return snapshotTag && requestedSnapshots.has(snapshotTag[1]);
- });
- },
-};
-
-const overflowFetched = await overflowClient.fetchWatchHistory(OVERFLOW_ACTOR);
-
-assert.equal(
- overflowFetched.items.length,
- overflowTotalChunks,
- "fetch should hydrate snapshots larger than the legacy limit"
-);
-
-const maxOverflowLimit = overflowChunkLimits.length
- ? Math.max(...overflowChunkLimits)
- : 0;
-
-assert(
- maxOverflowLimit >= overflowTotalChunks + 1,
- "chunk fetch should cover all chunks plus the newest index event"
-);
-
-const MONOTONIC_ACTOR = `${ACTOR}-monotonic`;
-const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } =
- createPublishingClient(MONOTONIC_ACTOR);
-
-const monotonicOriginalDateNow = Date.now;
-try {
- const monotonicBaseSeconds = 1_700_123_456;
- Date.now = () => monotonicBaseSeconds * 1000;
-
- const firstMonotonicPointers = [
- { type: "e", value: "event-monotonic-1", relay: null },
- { type: "e", value: "event-monotonic-2", relay: null },
- ];
-
- const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
- MONOTONIC_ACTOR,
- firstMonotonicPointers
- );
-
- const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry(
- firstSnapshot.event,
- firstMonotonicPointers,
- Date.now()
- );
- monotonicPublishingClient.watchHistoryCache.set(
- MONOTONIC_ACTOR,
- baselineEntry
- );
-
- const secondMonotonicPointers = [
- { type: "e", value: "event-monotonic-3", relay: null },
- { type: "e", value: "event-monotonic-1", relay: null },
- ];
-
- const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
- MONOTONIC_ACTOR,
- secondMonotonicPointers,
- baselineEntry
- );
-
- assert(
- secondSnapshot.event.created_at > firstSnapshot.event.created_at,
- "new snapshot should advance created_at when reusing the same second"
- );
-
- const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR);
- monotonicDecryptClient.pool = { list: async () => monotonicEvents };
-
- const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory(
- MONOTONIC_ACTOR
- );
-
- assert.deepEqual(
- monotonicFetched.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- })),
- [
- { type: "e", value: "event-monotonic-3", relay: null },
- { type: "e", value: "event-monotonic-1", relay: null },
- ],
- "newer snapshot should win pointer ordering"
- );
-} finally {
- Date.now = monotonicOriginalDateNow;
-}
-
-localStorage.clear();
-
-const UPDATE_ACTOR = `${ACTOR}-update`;
-const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
-const updateOriginalDateNow = Date.now;
-try {
- const updateTimestamp = 1_701_111_111_111;
- Date.now = () => updateTimestamp;
-
- const updateResult = await updateClient.updateWatchHistoryList({
- type: "e",
- value: "update-pointer",
- relay: null,
- });
-
- assert.equal(
- updateResult.ok,
- true,
- "updateWatchHistoryList should publish when adding a new pointer"
- );
- const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
- assert(updateEntry, "updateWatchHistoryList should cache the new entry");
- assert.equal(
- updateEntry.items[0].watchedAt,
- updateTimestamp,
- "cached entry should record watchedAt from Date.now()"
- );
- assert.equal(
- updateResult.items[0].watchedAt,
- updateTimestamp,
- "returned publish result should include the watchedAt timestamp"
- );
-} finally {
- Date.now = updateOriginalDateNow;
-}
-
-const REMOVE_ACTOR = `${ACTOR}-remove`;
-const {
- client: removalClient,
- payloads: removalPayloads,
-} = createPublishingClient(REMOVE_ACTOR);
-
-const removalPointers = [
- {
- type: "e",
- value: "remove-first",
- relay: null,
- watchedAt: 1_701_222_000_000,
- },
- {
- type: "e",
- value: "remove-second",
- relay: null,
- watchedAt: 1_701_222_100_000,
- },
-];
-
-await removalClient.publishWatchHistorySnapshot(
- REMOVE_ACTOR,
- removalPointers
-);
-
-const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
-const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
-
-assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
-assert.equal(
- removalResult.removedKey,
- removalKey,
- "removeWatchHistoryItem should echo the removed pointer key"
-);
-
-const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
-assert.equal(
- latestRemovalPayload.items.length,
- 1,
- "removal republish should trim the pointer list"
-);
-assert.equal(
- latestRemovalPayload.items[0].value,
- removalPointers[0].value,
- "republished payload should only include remaining pointer"
-);
-assert.equal(
- latestRemovalPayload.items[0].watchedAt,
- removalPointers[0].watchedAt,
- "republished payload should preserve watchedAt"
-);
-
-const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
-assert.equal(
- removalEntry.items.length,
- 1,
- "watch history cache should drop the removed pointer"
-);
-assert.equal(
- removalEntry.items[0].value,
- removalPointers[0].value,
- "remaining cached pointer should match the survivor"
-);
-assert.equal(
- removalEntry.items[0].watchedAt,
- removalPointers[0].watchedAt,
- "remaining cached pointer should retain watchedAt"
-);
-
-assert.equal(
- removalResult.items.map((item) => item.value).join(","),
- removalPointers.slice(0, 1).map((item) => item.value).join(","),
- "removeWatchHistoryItem result should reflect updated items"
-);
-assert.equal(
- removalResult.items[0].watchedAt,
- removalPointers[0].watchedAt,
- "removeWatchHistoryItem result should preserve watchedAt"
-);
-
-const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
-const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
-const videoPointer = {
- type: "a",
- value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
- relay: null,
-};
-
-const pointerClient = createDecryptClient(ACTOR);
-const pointerEntry = pointerClient.createWatchHistoryEntry(
- null,
- [videoPointer],
- Date.now()
-);
-pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
-pointerClient.fetchWatchHistory = async () => {};
-
-const observedResolveFilters = [];
-const videoEvent = {
- id: "video-pointer-event",
- kind: WATCH_HISTORY_KIND,
- pubkey: VIDEO_POINTER_AUTHOR,
- created_at: 1_700_555_000,
- tags: [["d", VIDEO_POINTER_IDENTIFIER]],
- content: JSON.stringify({
- version: 3,
- title: "Pointer Video",
- url: "https://cdn.example.com/video.mp4",
- }),
-};
-
-pointerClient.pool = {
- list: async (_relays, filters) => {
- observedResolveFilters.push(filters);
- return [videoEvent];
- },
-};
-
-const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
-
-const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
-
-assert.equal(
- resolvedFromPointer.length,
- 1,
- "resolveWatchHistory should return results for 'a' style video pointers"
-);
-assert.equal(
- resolvedFromPointer[0].id,
- videoEvent.id,
- "resolved video should match the pointer event id"
-);
-assert.equal(
- resolvedFromPointer[0].title,
- "Pointer Video",
- "resolved video should parse title from the event payload"
-);
-
-assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
-assert.equal(
- resolvedFromPointer[0].watchHistory.key,
- pointerKey,
- "watchHistory metadata should expose the pointer key"
-);
-assert.equal(
- resolvedFromPointer[0].watchHistory.pointer.value,
- videoPointer.value,
- "watchHistory metadata should include the pointer payload"
-);
-assert.equal(
- resolvedFromPointer[0].watchHistory.pointer.watchedAt,
- pointerEntry.items[0].watchedAt,
- "watchHistory metadata should preserve watchedAt"
-);
-
-const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
-assert(
- cachedEntry.resolvedVideos.has(pointerKey),
- "resolved cache should retain the video"
-);
-assert.equal(
- cachedEntry.resolvedVideos.get(pointerKey)?.id,
- videoEvent.id,
- "resolved cache should store the converted video result"
-);
-assert(
- cachedEntry.delivered.has(pointerKey),
- "delivery set should mark the pointer as delivered"
-);
-
-assert(
- observedResolveFilters.some((filters) =>
- filters.some((filter) =>
- Array.isArray(filter?.["#d"]) &&
- filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
- )
- ),
- "resolveWatchHistory should request the video identifier instead of treating it as a chunk"
-);
-
-const secondResolve = await pointerClient.resolveWatchHistory(1);
-assert.equal(
- secondResolve.length,
- 0,
- "subsequent resolve calls should avoid refetching already delivered videos"
-);
-
-pointerClient.resetWatchHistoryProgress(ACTOR);
-assert.equal(
- cachedEntry.delivered.size,
- 0,
- "resetWatchHistoryProgress should clear delivered pointers"
-);
-
-pointerClient.pool = {
- list: async () => {
- throw new Error("cached resolve should not trigger network fetch");
- },
-};
-
-const cachedResolve = await pointerClient.resolveWatchHistory(1);
-assert.equal(
- cachedResolve.length,
- 1,
- "reset should allow cached videos to be delivered again"
-);
-assert.equal(
- cachedResolve[0].id,
- videoEvent.id,
- "cached resolve should reuse the stored video payload"
-);
-assert(
- cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
- "cached resolve should retain watchHistory metadata"
-);
-
-const originalDateNow = Date.now;
-try {
- const baseTime = 1_700_000_000_000;
- const ttlActor = `${ACTOR}-ttl`;
- const pointer = { type: "e", value: "event-ttl", relay: null };
-
- const cachingClient = createDecryptClient(ttlActor);
- cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000;
-
- Date.now = () => baseTime;
-
- const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime);
- cachingClient.watchHistoryCache.set(ttlActor, entry);
- cachingClient.persistWatchHistoryEntry(ttlActor, entry);
-
- const reloadClient = createDecryptClient(ttlActor);
- reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs;
-
- Date.now = () => baseTime + 60 * 60 * 1000;
-
- const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor);
- assert.equal(
- cachedSnapshot.items.length,
- 1,
- "cached snapshot should survive within extended TTL"
- );
- assert.equal(
- cachedSnapshot.items[0].value,
- pointer.value,
- "cached snapshot should preserve pointer"
- );
-
- localStorage.clear();
-
- const expiryClient = createDecryptClient(ttlActor);
- expiryClient.watchHistoryCacheTtlMs = 1_000;
-
- Date.now = () => baseTime;
-
- const expiringEntry = expiryClient.createWatchHistoryEntry(
- null,
- [pointer],
- baseTime
- );
- expiryClient.watchHistoryCache.set(ttlActor, expiringEntry);
- expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry);
-
- Date.now = () => baseTime + 1_500;
-
- const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor);
- assert.equal(
- expiredSnapshot.items.length,
- 0,
- "expired snapshot should be dropped"
- );
- const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor);
- assert.equal(
- cachedFallback?.items?.length ?? 0,
- 0,
- "expired snapshot should not preserve stale items in memory"
- );
- const storage = expiryClient.getWatchHistoryStorage();
- assert.equal(
- Object.prototype.hasOwnProperty.call(storage.actors, ttlActor),
- false,
- "expired snapshot should be removed from localStorage"
- );
-} finally {
- Date.now = originalDateNow;
-}
-
-const plaintextPayload = {
- version: 2,
- snapshot: "plaintext-snapshot",
- chunkIndex: 0,
- totalChunks: 1,
- items: [
- {
- type: "e",
- value: "plaintext-pointer",
- relay: "wss://relay.unit",
- watchedAt: 1_700_000_123_000,
- },
- ],
-};
-
-const plaintextChunkEvent = {
- kind: WATCH_HISTORY_KIND,
- pubkey: ACTOR,
- created_at: 333,
- tags: [
- ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`],
- ["snapshot", plaintextPayload.snapshot],
- ["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)],
- ["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay],
- ],
- content: JSON.stringify(plaintextPayload),
-};
-
-const originalWindowNostr = globalThis.window.nostr;
-if (!globalThis.window.NostrTools) {
- globalThis.window.NostrTools = {};
-}
-if (!globalThis.window.NostrTools.nip04) {
- globalThis.window.NostrTools.nip04 = {};
-}
-const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
-
-try {
- globalThis.window.nostr = {
- nip04: {
- decrypt: () => {
- throw new Error("plaintext chunk should not trigger extension decrypt");
- },
- },
- };
- globalThis.window.NostrTools.nip04.decrypt = () => {
- throw new Error("plaintext chunk should not trigger session decrypt");
- };
-
- const plaintextClient = createDecryptClient(ACTOR);
- const plaintextResult = await plaintextClient.decryptWatchHistoryEvent(
- plaintextChunkEvent,
- ACTOR
- );
-
- assert.deepEqual(
- plaintextResult.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay,
- watchedAt: item.watchedAt,
- })),
- plaintextPayload.items,
- "plaintext payload should be parsed without invoking nip04 helpers",
- );
- assert.equal(
- plaintextResult.snapshot,
- plaintextPayload.snapshot,
- "plaintext payload should preserve snapshot metadata",
- );
- assert.equal(
- plaintextResult.chunkIndex,
- plaintextPayload.chunkIndex,
- "plaintext payload should preserve chunk index",
- );
- assert.equal(
- plaintextResult.totalChunks,
- plaintextPayload.totalChunks,
- "plaintext payload should preserve chunk count",
- );
-} finally {
- globalThis.window.nostr = originalWindowNostr;
- if (originalSessionDecrypt === undefined) {
- delete globalThis.window.NostrTools.nip04.decrypt;
- } else {
- globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt;
- }
-}
-
-const encryptedPayload = {
- version: 2,
- snapshot: "encrypted-snapshot",
- chunkIndex: 0,
- totalChunks: 1,
- items: [
- {
- type: "e",
- value: "encrypted-pointer",
- relay: "wss://relay.unit",
- watchedAt: 1_700_000_456_000,
- },
- ],
-};
-
-const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz";
-
-const encryptedChunkEvent = {
- kind: WATCH_HISTORY_KIND,
- pubkey: ACTOR,
- created_at: 444,
- tags: [
- ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`],
- ["encrypted", "nip04"],
- ["snapshot", encryptedPayload.snapshot],
- ["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)],
- ["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay],
- ],
- content: encryptedCiphertext,
-};
-
-const encryptedClient = createDecryptClient(ACTOR);
-encryptedClient.pubkey = ACTOR;
-
-const priorWindowNostr = globalThis.window.nostr;
-const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
-let extensionDecryptCalls = 0;
-let sessionDecryptCalls = 0;
-
-try {
- globalThis.window.nostr = {
- nip04: {
- decrypt: async (pubkey, ciphertext) => {
- extensionDecryptCalls += 1;
- assert.equal(pubkey, ACTOR, "extension decrypt should target the actor");
- assert.equal(
- ciphertext,
- encryptedCiphertext,
- "extension decrypt should receive the chunk ciphertext",
- );
- return JSON.stringify(encryptedPayload);
- },
- },
- };
-
- globalThis.window.NostrTools.nip04.decrypt = async () => {
- sessionDecryptCalls += 1;
- return JSON.stringify(encryptedPayload);
- };
-
- const decryptedResult = await encryptedClient.decryptWatchHistoryEvent(
- encryptedChunkEvent,
- ACTOR
- );
-
- assert.equal(
- extensionDecryptCalls,
- 1,
- "encrypted chunks should invoke extension nip04 decrypt",
- );
- assert.equal(
- sessionDecryptCalls,
- 0,
- "successful extension decrypt should skip session fallback",
- );
- assert.deepEqual(
- decryptedResult.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay,
- watchedAt: item.watchedAt,
- })),
- encryptedPayload.items,
- "encrypted payload should be reconstructed from decrypted content",
- );
- assert.equal(
- decryptedResult.snapshot,
- encryptedPayload.snapshot,
- "encrypted payload should preserve snapshot metadata",
- );
-} finally {
- globalThis.window.nostr = priorWindowNostr;
- if (priorSessionDecrypt === undefined) {
- delete globalThis.window.NostrTools.nip04.decrypt;
- } else {
- globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt;
- }
-}
-
-const missingSignerClient = createDecryptClient(LOGGED_ACTOR);
-missingSignerClient.pubkey = LOGGED_ACTOR;
-missingSignerClient.sessionActor = null;
-
-let missingSignerEnsureCalls = 0;
-missingSignerClient.ensureSessionActor = async () => {
- missingSignerEnsureCalls += 1;
- return LOGGED_ACTOR;
-};
-
-const priorMissingSignerNostr = globalThis.window.nostr;
-const priorMissingSignerSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
-let missingSignerSessionDecryptCalls = 0;
-
-try {
- delete globalThis.window.nostr;
- globalThis.window.NostrTools.nip04.decrypt = async () => {
- missingSignerSessionDecryptCalls += 1;
- return JSON.stringify(encryptedPayload);
- };
-
- const missingSignerChunkEvent = {
- ...encryptedChunkEvent,
- pubkey: LOGGED_ACTOR,
- id: "missing-signer-event",
- };
-
- const missingSignerResult = await missingSignerClient.decryptWatchHistoryEvent(
- missingSignerChunkEvent,
- LOGGED_ACTOR
- );
-
- assert.equal(
- missingSignerEnsureCalls,
- 0,
- "missing signer should not trigger session actor ensure"
- );
- assert.equal(
- missingSignerSessionDecryptCalls,
- 0,
- "missing signer should skip session decrypt fallback"
- );
- assert.equal(
- missingSignerResult.needsSignerRetry,
- true,
- "missing signer fallback should mark retry flag"
- );
- assert.deepEqual(
- missingSignerResult.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- })),
- encryptedPayload.items.map((item) => ({
- type: item.type,
- value: item.value,
- relay: item.relay || null,
- })),
- "missing signer fallback should preserve pointer metadata"
- );
-} finally {
- if (priorMissingSignerNostr === undefined) {
- delete globalThis.window.nostr;
- } else {
- globalThis.window.nostr = priorMissingSignerNostr;
- }
- if (priorMissingSignerSessionDecrypt === undefined) {
- delete globalThis.window.NostrTools.nip04.decrypt;
- } else {
- globalThis.window.NostrTools.nip04.decrypt = priorMissingSignerSessionDecrypt;
- }
-}
-
-const unsortedVideos = [
- { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } },
- { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } },
- { id: "charlie", created_at: 1_699_999_000 },
-];
-
-const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc);
-
-assert.deepEqual(
- sortedVideos.map((video) => video.id),
- ["bravo", "alpha", "charlie"],
- "videos should be ordered by watchedAt descending with created_at fallback",
-);
-
-const tieVideos = [
- { id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 },
- { id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 },
- { id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 },
-];
-
-tieVideos.sort(compareByWatchedAtDesc);
-
-assert.equal(
- getWatchedAtScore(tieVideos[0]),
- getWatchedAtScore(tieVideos[1]),
- "ties should preserve identical watchedAt scores",
-);
-
-assert.deepEqual(
- tieVideos.map((video) => video.id),
- ["delta", "foxtrot", "echo"],
- "created_at should break watchedAt ties with stable fallback",
-);
-
-console.log("watch history tests passed");
From 57655aa304cc2f910ca14a3d6e18c5fdc57fd822 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 2 Oct 2025 11:51:22 -0400
Subject: [PATCH 2/3] fix: restore view counter support
---
js/nostr.js | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 326 insertions(+)
diff --git a/js/nostr.js b/js/nostr.js
index 328a40e6..8fc9dd1b 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -1462,6 +1462,332 @@ class NostrClient {
return [];
}
+ async listVideoViewEvents(pointer, options = {}) {
+ if (!this.pool) {
+ return [];
+ }
+
+ const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
+ pointer
+ );
+ const { since, until, limit, relays } = options || {};
+
+ for (const filter of filters) {
+ if (!filter || typeof filter !== "object") {
+ continue;
+ }
+ if (Number.isFinite(since)) {
+ filter.since = Math.floor(since);
+ }
+ if (Number.isFinite(until)) {
+ filter.until = Math.floor(until);
+ }
+ if (Number.isFinite(limit) && limit > 0) {
+ filter.limit = Math.floor(limit);
+ }
+ }
+
+ const relayList = Array.isArray(relays) && relays.length
+ ? relays
+ : Array.isArray(this.relays) && this.relays.length
+ ? this.relays
+ : RELAY_URLS;
+
+ let rawResults;
+ try {
+ rawResults = await this.pool.list(relayList, filters);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to list video view events:", error);
+ }
+ return [];
+ }
+
+ const flattenResults = (input) => {
+ if (!Array.isArray(input)) {
+ return [];
+ }
+
+ const flat = [];
+ for (const chunk of input) {
+ if (Array.isArray(chunk)) {
+ for (const item of chunk) {
+ if (item && typeof item === "object") {
+ flat.push(item);
+ }
+ }
+ } else if (chunk && typeof chunk === "object") {
+ flat.push(chunk);
+ }
+ }
+ return flat;
+ };
+
+ const flattened = flattenResults(rawResults);
+ const dedupe = new Map();
+ const order = [];
+
+ for (const event of flattened) {
+ if (!isVideoViewEvent(event, pointerDescriptor)) {
+ continue;
+ }
+
+ const eventId = typeof event.id === "string" ? event.id : null;
+ if (!eventId) {
+ order.push({ type: "raw", event });
+ continue;
+ }
+
+ const existing = dedupe.get(eventId);
+ if (!existing) {
+ dedupe.set(eventId, event);
+ order.push({ type: "id", key: eventId });
+ continue;
+ }
+
+ const existingCreated = Number.isFinite(existing?.created_at)
+ ? existing.created_at
+ : 0;
+ const incomingCreated = Number.isFinite(event.created_at)
+ ? event.created_at
+ : 0;
+ if (incomingCreated > existingCreated) {
+ dedupe.set(eventId, event);
+ }
+ }
+
+ return order
+ .map((entry) => {
+ if (!entry) {
+ return null;
+ }
+ if (entry.type === "raw") {
+ return entry.event || null;
+ }
+ if (entry.type === "id") {
+ return dedupe.get(entry.key) || null;
+ }
+ return null;
+ })
+ .filter(Boolean);
+ }
+
+ subscribeVideoViewEvents(pointer, options = {}) {
+ if (!this.pool) {
+ if (isDevMode) {
+ console.warn("[nostr] Unable to subscribe to view events: pool missing.");
+ }
+ return () => {};
+ }
+
+ const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
+ pointer
+ );
+
+ if (Number.isFinite(options?.since)) {
+ for (const filter of filters) {
+ if (filter && typeof filter === "object") {
+ filter.since = Math.floor(options.since);
+ }
+ }
+ }
+
+ const relayList = Array.isArray(options?.relays) && options.relays.length
+ ? options.relays
+ : Array.isArray(this.relays) && this.relays.length
+ ? this.relays
+ : RELAY_URLS;
+
+ const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
+
+ let subscription;
+ try {
+ subscription = this.pool.sub(relayList, filters);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to open video view subscription:", error);
+ }
+ return () => {};
+ }
+
+ if (onEvent) {
+ subscription.on("event", (event) => {
+ if (isVideoViewEvent(event, pointerDescriptor)) {
+ try {
+ onEvent(event);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Video view event handler threw:", error);
+ }
+ }
+ }
+ });
+ }
+
+ const originalUnsub =
+ typeof subscription.unsub === "function"
+ ? subscription.unsub.bind(subscription)
+ : null;
+
+ let unsubscribed = false;
+ return () => {
+ if (unsubscribed) {
+ return;
+ }
+ unsubscribed = true;
+ if (originalUnsub) {
+ try {
+ originalUnsub();
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[nostr] Failed to unsubscribe from video view events:",
+ error
+ );
+ }
+ }
+ }
+ };
+ }
+
+ async countVideoViewEvents(pointer, options = {}) {
+ const relayList =
+ Array.isArray(options?.relays) && options.relays.length
+ ? options.relays
+ : undefined;
+
+ const fallbackListOptions = (() => {
+ const listOptions = {};
+
+ if (relayList) {
+ listOptions.relays = relayList;
+ }
+
+ if (Number.isFinite(options?.since)) {
+ listOptions.since = Math.floor(options.since);
+ } else {
+ const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
+ const horizonDays = Number.isFinite(horizonDaysRaw)
+ ? Math.max(0, Math.floor(horizonDaysRaw))
+ : 0;
+ if (horizonDays > 0) {
+ const secondsPerDay = 86_400;
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const fallbackSinceSeconds = Math.max(
+ 0,
+ nowSeconds - horizonDays * secondsPerDay
+ );
+ listOptions.since = Math.floor(fallbackSinceSeconds);
+ }
+ }
+
+ if (Number.isFinite(options?.until)) {
+ listOptions.until = Math.floor(options.until);
+ }
+
+ if (Number.isFinite(options?.limit) && options.limit > 0) {
+ listOptions.limit = Math.floor(options.limit);
+ }
+
+ return listOptions;
+ })();
+
+ if (!this.pool) {
+ const events = await this.listVideoViewEvents(pointer, {
+ ...fallbackListOptions,
+ });
+ return {
+ total: Array.isArray(events) ? events.length : 0,
+ perRelay: [],
+ best: null,
+ fallback: true,
+ };
+ }
+
+ const { filters } = createVideoViewEventFilters(pointer);
+ const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
+ if (!pointerFilter || typeof pointerFilter !== "object") {
+ throw new Error("Invalid video pointer supplied for view lookup.");
+ }
+
+ const signal = options?.signal;
+ const normalizeAbortError = () => {
+ if (signal?.reason instanceof Error) {
+ return signal.reason;
+ }
+ if (typeof DOMException === "function") {
+ return new DOMException("Operation aborted", "AbortError");
+ }
+ const error = new Error("Operation aborted");
+ error.name = "AbortError";
+ return error;
+ };
+ if (signal?.aborted) {
+ throw normalizeAbortError();
+ }
+
+ try {
+ const result = await this.countEventsAcrossRelays([pointerFilter], {
+ relays: relayList,
+ timeoutMs: options?.timeoutMs,
+ });
+
+ if (result?.perRelay?.some((entry) => entry && entry.ok)) {
+ return { ...result, fallback: false };
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] COUNT view request failed:", error);
+ }
+ }
+
+ if (signal?.aborted) {
+ throw normalizeAbortError();
+ }
+
+ const abortPromise =
+ signal &&
+ typeof signal === "object" &&
+ typeof signal.addEventListener === "function"
+ ? new Promise((_, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ reject(normalizeAbortError());
+ },
+ { once: true }
+ );
+ })
+ : null;
+
+ const listPromise = this.listVideoViewEvents(pointer, {
+ ...fallbackListOptions,
+ });
+
+ const events = abortPromise
+ ? await Promise.race([listPromise, abortPromise])
+ : await listPromise;
+
+ const uniqueCount = Array.isArray(events)
+ ? (() => {
+ const withIds = events
+ .filter((event) => event && typeof event.id === "string")
+ .map((event) => event.id);
+ if (withIds.length === 0) {
+ return events.length;
+ }
+ return new Set(withIds).size;
+ })()
+ : 0;
+
+ return {
+ total: uniqueCount,
+ perRelay: [],
+ best: null,
+ fallback: true,
+ };
+ }
+
async publishViewEvent(videoPointer, options = {}) {
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
From 0320492c54d0cf4f55517088efbb3f4b332ecd93 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 2 Oct 2025 12:01:09 -0400
Subject: [PATCH 3/3] fix: restore session actor for view events
---
js/nostr.js | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 226 insertions(+)
diff --git a/js/nostr.js b/js/nostr.js
index 8fc9dd1b..c48c9989 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -1279,6 +1279,232 @@ class NostrClient {
this.countUnsupportedRelays = new Set();
}
+ restoreSessionActorFromStorage() {
+ if (typeof localStorage === "undefined") {
+ return null;
+ }
+
+ let raw = null;
+ try {
+ raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to read session actor from storage:", error);
+ }
+ return null;
+ }
+
+ if (!raw || typeof raw !== "string") {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(raw);
+ const pubkey =
+ typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
+ const privateKey =
+ typeof parsed?.privateKey === "string"
+ ? parsed.privateKey.trim()
+ : "";
+
+ if (!pubkey || !privateKey) {
+ return null;
+ }
+
+ return {
+ pubkey,
+ privateKey,
+ createdAt: Number.isFinite(parsed?.createdAt)
+ ? parsed.createdAt
+ : Date.now(),
+ };
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to parse stored session actor:", error);
+ }
+ try {
+ localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
+ } catch (cleanupError) {
+ if (isDevMode) {
+ console.warn(
+ "[nostr] Failed to clear corrupt session actor entry:",
+ cleanupError
+ );
+ }
+ }
+ }
+
+ return null;
+ }
+
+ persistSessionActor(actor) {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+
+ if (
+ !actor ||
+ typeof actor.pubkey !== "string" ||
+ !actor.pubkey ||
+ typeof actor.privateKey !== "string" ||
+ !actor.privateKey
+ ) {
+ return;
+ }
+
+ const payload = {
+ pubkey: actor.pubkey,
+ privateKey: actor.privateKey,
+ createdAt: Number.isFinite(actor.createdAt)
+ ? actor.createdAt
+ : Date.now(),
+ };
+
+ try {
+ localStorage.setItem(
+ SESSION_ACTOR_STORAGE_KEY,
+ JSON.stringify(payload)
+ );
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to persist session actor:", error);
+ }
+ }
+ }
+
+ clearStoredSessionActor() {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+ try {
+ localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to clear stored session actor:", error);
+ }
+ }
+ }
+
+ mintSessionActor() {
+ const tools = window?.NostrTools || null;
+ if (!tools) {
+ if (isDevMode) {
+ console.warn("[nostr] Cannot mint session actor without NostrTools.");
+ }
+ return null;
+ }
+
+ const getPublicKey =
+ typeof tools.getPublicKey === "function" ? tools.getPublicKey : null;
+ if (!getPublicKey) {
+ if (isDevMode) {
+ console.warn(
+ "[nostr] Cannot mint session actor: missing getPublicKey helper."
+ );
+ }
+ return null;
+ }
+
+ let privateKey = "";
+ try {
+ if (typeof tools.generatePrivateKey === "function") {
+ privateKey = tools.generatePrivateKey();
+ } else if (window?.crypto?.getRandomValues) {
+ const randomBytes = new Uint8Array(32);
+ window.crypto.getRandomValues(randomBytes);
+ privateKey = Array.from(randomBytes)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to mint session private key:", error);
+ }
+ privateKey = "";
+ }
+
+ if (!privateKey || typeof privateKey !== "string") {
+ return null;
+ }
+
+ const normalizedPrivateKey = privateKey.trim();
+ if (!normalizedPrivateKey) {
+ return null;
+ }
+
+ let pubkey = "";
+ try {
+ pubkey = getPublicKey(normalizedPrivateKey);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[nostr] Failed to derive session pubkey:", error);
+ }
+ return null;
+ }
+
+ const normalizedPubkey =
+ typeof pubkey === "string" ? pubkey.trim() : "";
+ if (!normalizedPubkey) {
+ return null;
+ }
+
+ return {
+ pubkey: normalizedPubkey,
+ privateKey: normalizedPrivateKey,
+ createdAt: Date.now(),
+ };
+ }
+
+ async ensureSessionActor(forceRenew = false) {
+ const normalizedLogged =
+ typeof this.pubkey === "string" && this.pubkey
+ ? this.pubkey.toLowerCase()
+ : "";
+ const extension = window?.nostr;
+ const canSignWithExtension =
+ !!normalizedLogged &&
+ extension &&
+ typeof extension.signEvent === "function";
+
+ if (!forceRenew && canSignWithExtension) {
+ return normalizedLogged;
+ }
+
+ if (forceRenew) {
+ this.sessionActor = null;
+ this.clearStoredSessionActor();
+ } else if (
+ this.sessionActor &&
+ typeof this.sessionActor.pubkey === "string" &&
+ this.sessionActor.pubkey &&
+ typeof this.sessionActor.privateKey === "string" &&
+ this.sessionActor.privateKey
+ ) {
+ return this.sessionActor.pubkey;
+ }
+
+ if (!forceRenew) {
+ const restored = this.restoreSessionActorFromStorage();
+ if (restored) {
+ this.sessionActor = restored;
+ return restored.pubkey;
+ }
+ }
+
+ const minted = this.mintSessionActor();
+ if (minted) {
+ this.sessionActor = minted;
+ this.persistSessionActor(minted);
+ return minted.pubkey;
+ }
+
+ if (canSignWithExtension) {
+ return normalizedLogged;
+ }
+
+ return null;
+ }
+
makeCountUnsupportedError(relayUrl) {
const normalizedUrl =
typeof relayUrl === "string" && relayUrl.trim()