Date: Tue, 30 Sep 2025 16:16:56 -0400
Subject: [PATCH 22/31] Guard NIP-07 login calls with shared timeout
---
js/nostr.js | 45 ++++++++++++++++++++++++---------------------
1 file changed, 24 insertions(+), 21 deletions(-)
diff --git a/js/nostr.js b/js/nostr.js
index 7651157f..9c92585f 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -31,6 +31,8 @@ const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const 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";
@@ -52,6 +54,24 @@ function logErrorOnce(message, eventContent = null) {
}
}
+function withNip07Timeout(operation) {
+ let timeoutId;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(new Error(NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE));
+ }, NIP07_LOGIN_TIMEOUT_MS);
+ });
+
+ return Promise.race([
+ Promise.resolve().then(operation),
+ timeoutPromise,
+ ]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ });
+}
+
function pointerKey(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
@@ -2895,7 +2915,7 @@ class NostrClient {
console.log("Requesting permissions from NIP-07 extension...");
}
try {
- await extension.enable();
+ await withNip07Timeout(() => extension.enable());
} catch (enableErr) {
throw new Error(
enableErr && enableErr.message
@@ -2907,8 +2927,8 @@ class NostrClient {
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
- const selection = await extension.selectAccounts(
- expectPubkey ? [expectPubkey] : undefined
+ const selection = await withNip07Timeout(() =>
+ extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined)
);
const didCancelSelection =
@@ -2928,24 +2948,7 @@ class NostrClient {
throw new Error(message);
}
}
-
- let timeoutId;
- const pubkey = await Promise.race([
- extension.getPublicKey(),
- new Promise((_, reject) => {
- timeoutId = setTimeout(() => {
- reject(
- new Error(
- "Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."
- )
- );
- }, NIP07_LOGIN_TIMEOUT_MS);
- }),
- ]).finally(() => {
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- });
+ const pubkey = await withNip07Timeout(() => extension.getPublicKey());
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
From 8d7d7a86cfafa1ca0cdb4cb78e2a4138f05deb81 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 16:22:50 -0400
Subject: [PATCH 23/31] Fix NIP-07 timeout helper to preserve user gestures
---
js/nostr.js | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/js/nostr.js b/js/nostr.js
index 9c92585f..c0043c40 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -62,10 +62,19 @@ function withNip07Timeout(operation) {
}, NIP07_LOGIN_TIMEOUT_MS);
});
- return Promise.race([
- Promise.resolve().then(operation),
- timeoutPromise,
- ]).finally(() => {
+ let operationResult;
+ try {
+ operationResult = operation();
+ } catch (err) {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ throw err;
+ }
+
+ const operationPromise = Promise.resolve(operationResult);
+
+ return Promise.race([operationPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
From 5e81bc9aefb7620eac4f5a372a5d64049b04bc8e Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 19:52:25 -0400
Subject: [PATCH 24/31] Fix watch history pointer classification
---
js/nostr.js | 39 ++++++++++++++++---
tests/watch-history.test.mjs | 75 ++++++++++++++++++++++++++++++++++++
2 files changed, 108 insertions(+), 6 deletions(-)
diff --git a/js/nostr.js b/js/nostr.js
index c0043c40..02e1c9fe 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -2579,19 +2579,46 @@ class NostrClient {
? Math.max(1, normalizedBatchSize)
: 1;
- const classifyWatchHistoryPointer = (pointer) => {
+ const classifyWatchHistoryPointer = (pointer, ownerPubkey) => {
if (!pointer || pointer.type !== "a") {
return "none";
}
- const [kindStr, pubkey, identifier] = pointer.value.split(":");
+ 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) || kind !== WATCH_HISTORY_KIND) {
return "none";
}
- if (identifier === WATCH_HISTORY_LIST_IDENTIFIER) {
+ const identifier = identifierParts.join(":");
+ const normalizedIdentifier =
+ typeof identifier === "string" ? identifier.trim() : "";
+ if (!normalizedIdentifier) {
+ return "none";
+ }
+ const normalizedIdentifierLower = normalizedIdentifier.toLowerCase();
+ if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) {
return "head";
}
- if (typeof identifier === "string" && identifier) {
+ if (!normalizedIdentifierLower.startsWith("watch-history:")) {
+ 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";
@@ -2628,7 +2655,7 @@ class NostrClient {
if (!pointer) {
continue;
}
- const pointerType = classifyWatchHistoryPointer(pointer);
+ const pointerType = classifyWatchHistoryPointer(pointer, actor);
if (pointerType === "head") {
addPointerToMap(pointer, headPointerMap);
continue;
@@ -2776,7 +2803,7 @@ class NostrClient {
if (!key) {
continue;
}
- const pointerType = classifyWatchHistoryPointer(pointer);
+ const pointerType = classifyWatchHistoryPointer(pointer, actor);
const event = pointerMatches.get(key);
if (pointerType === "chunk" || pointerType === "head") {
if (event) {
diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs
index caefe962..ab464639 100644
--- a/tests/watch-history.test.mjs
+++ b/tests/watch-history.test.mjs
@@ -398,6 +398,81 @@ try {
localStorage.clear();
+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);
+
+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"
+);
+
+const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
+const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
+assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video");
+assert.equal(
+ cachedEntry.resolved.get(pointerKey)?.id,
+ videoEvent.id,
+ "resolved cache should store the converted video result"
+);
+
+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 originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;
From 6039ad2fd7123a1e6ddf83c468c8d9dbd53bf614 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 20:09:23 -0400
Subject: [PATCH 25/31] Improve watch history caching and reuse
---
js/historyView.js | 4 +
js/nostr.js | 158 ++++++++++++++++++++++++++---------
tests/watch-history.test.mjs | 43 +++++++++-
3 files changed, 163 insertions(+), 42 deletions(-)
diff --git a/js/historyView.js b/js/historyView.js
index 6484fb30..1c422cb8 100644
--- a/js/historyView.js
+++ b/js/historyView.js
@@ -950,6 +950,10 @@ export function createWatchHistoryRenderer(config = {}) {
state.actor = beforeResult.actor.trim();
}
+ if (state.actor) {
+ nostrClient.resetWatchHistoryProgress(state.actor);
+ }
+
let fingerprint;
if (typeof fingerprintOverride !== "undefined") {
fingerprint = fingerprintOverride;
diff --git a/js/nostr.js b/js/nostr.js
index 02e1c9fe..18e66c04 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -1102,9 +1102,15 @@ class NostrClient {
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(),
};
const validKeys = new Set(entry.items.map((item) => pointerKey(item)));
@@ -1113,11 +1119,21 @@ class NostrClient {
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);
+ }
+ }
const previousChunkPointers = previousEntry?.chunkPointers;
const headPointer = previousChunkPointers?.head
@@ -1214,6 +1230,40 @@ class NostrClient {
return `${pointerId}:${pointerCreated}:${itemKeys}`;
}
+ 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 encryptWatchHistoryPayload(actorPubkey, payload) {
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
@@ -2518,7 +2568,10 @@ class NostrClient {
}
for (const key of Array.from(entry.resolved.keys())) {
- if (!key || !key.startsWith("a:")) {
+ if (
+ !key ||
+ !key.startsWith(`a:${WATCH_HISTORY_KIND}:`)
+ ) {
continue;
}
if (!chunkPointerKeys.has(key)) {
@@ -2570,6 +2623,13 @@ class NostrClient {
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
@@ -2691,12 +2751,18 @@ class NostrClient {
if (!key) {
return false;
}
- if (entry.resolved.has(key)) {
+ 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;
});
@@ -2727,56 +2793,53 @@ class NostrClient {
const videoBatch = available.slice(0, effectiveBatchSize);
const batch = [...videoBatch, ...chunkAvailable];
- if (!batch.length) {
- return [];
- }
-
- batch.forEach((item) => {
- const key = pointerKey(item);
- if (key) {
- entry.resolving.add(key);
- }
- });
const relaySet = new Set(
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS
);
const filters = [];
- 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];
+ 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);
}
- filters.push(filter);
}
}
}
- if (!filters.length) {
- batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
- return [];
- }
-
let events = [];
- try {
- events = await this.pool.list(Array.from(relaySet), filters);
- } catch (error) {
- if (isDevMode) {
- console.warn("[nostr] Failed to resolve watch history batch:", error);
+ 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 resolvedVideos = [];
const pointerMatches = new Map();
for (const evt of events || []) {
@@ -2830,8 +2893,7 @@ class NostrClient {
this.activeMap.set(activeKey, video);
}
}
- entry.resolved.set(key, video);
- resolvedVideos.push(video);
+ entry.resolvedVideos.set(key, video);
}
} catch (err) {
if (isDevMode) {
@@ -2842,7 +2904,23 @@ class NostrClient {
entry.resolving.delete(key);
}
- return resolvedVideos;
+ 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) {
+ deliverableVideos.push(cachedVideo);
+ entry.delivered.add(key);
+ }
+ }
+
+ return deliverableVideos;
}
async recordVideoView(videoPointer, options = {}) {
diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs
index ab464639..5140e3ab 100644
--- a/tests/watch-history.test.mjs
+++ b/tests/watch-history.test.mjs
@@ -456,12 +456,19 @@ assert.equal(
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
-assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video");
+assert(
+ cachedEntry.resolvedVideos.has(pointerKey),
+ "resolved cache should retain the video"
+);
assert.equal(
- cachedEntry.resolved.get(pointerKey)?.id,
+ 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) =>
@@ -473,6 +480,38 @@ assert(
"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"
+);
+
const originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;
From ad3f9cb20004374fdf8daa7e19be16c174e0f69f Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 20:59:36 -0400
Subject: [PATCH 26/31] Add watchedAt metadata to watch history pointers
---
js/nostr.js | 108 ++++++++++++++++++++++-
tests/watch-history.test.mjs | 164 ++++++++++++++++++++++++++++++++++-
2 files changed, 267 insertions(+), 5 deletions(-)
diff --git a/js/nostr.js b/js/nostr.js
index 18e66c04..1a55107f 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -112,8 +112,18 @@ function clonePointerItem(pointer) {
typeof pointer.relay === "string" && pointer.relay.trim()
? pointer.relay.trim()
: null;
+ const watchedAtRaw = Number.isFinite(pointer.watchedAt)
+ ? pointer.watchedAt
+ : null;
+ const watchedAt =
+ watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : null;
- return { type, value, relay };
+ const cloned = { type, value, relay };
+ if (watchedAt !== null) {
+ cloned.watchedAt = watchedAt;
+ }
+
+ return cloned;
}
function normalizePointerTag(tag) {
@@ -1091,7 +1101,12 @@ class NostrClient {
continue;
}
seen.add(key);
- normalizedItems.push(pointer);
+ const watchedAtRaw = Number.isFinite(pointer.watchedAt)
+ ? pointer.watchedAt
+ : null;
+ const watchedAt =
+ watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
+ normalizedItems.push({ ...pointer, watchedAt });
}
}
@@ -1175,6 +1190,9 @@ class NostrClient {
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,
};
@@ -1570,6 +1588,9 @@ class NostrClient {
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)
@@ -2069,6 +2090,15 @@ class NostrClient {
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" };
@@ -2084,6 +2114,57 @@ class NostrClient {
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) {
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
@@ -2915,7 +2996,25 @@ class NostrClient {
}
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
- deliverableVideos.push(cachedVideo);
+ 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);
}
}
@@ -4080,3 +4179,6 @@ export const recordVideoView = (...args) =>
export const updateWatchHistoryList = (...args) =>
nostrClient.updateWatchHistoryList(...args);
+
+export const removeWatchHistoryItem = (...args) =>
+ nostrClient.removeWatchHistoryItem(...args);
diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs
index 5140e3ab..a7e25b40 100644
--- a/tests/watch-history.test.mjs
+++ b/tests/watch-history.test.mjs
@@ -42,6 +42,7 @@ function createPublishingClient(actorPubkey) {
},
};
},
+ list: async () => [],
};
client.relays = ["wss://unit.test"];
client.pubkey = "";
@@ -72,19 +73,23 @@ const { client: publishingClient, publishedEvents, payloads } =
publishingClient.watchHistoryPayloadMaxBytes = 400;
publishingClient.watchHistoryFetchEventLimit = 10;
+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: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`,
relay,
+ watchedAt,
};
});
@@ -208,13 +213,15 @@ assert.deepEqual(
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"
+ "chunked fetch should preserve pointer order and timestamps"
);
if (typeof localStorage !== "undefined") {
@@ -295,6 +302,22 @@ assert.equal(
"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.length >= 2,
"fetch should include a follow-up query for chunk events"
@@ -398,6 +421,121 @@ try {
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 = {
@@ -438,6 +576,8 @@ pointerClient.pool = {
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
+const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
+
assert.equal(
resolvedFromPointer.length,
1,
@@ -454,7 +594,23 @@ assert.equal(
"resolved video should parse title from the event payload"
);
-const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
+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),
@@ -511,6 +667,10 @@ assert.equal(
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 {
From 7af3a536c3ca6a41fa047dc29690967d57a27a68 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 21:11:02 -0400
Subject: [PATCH 27/31] Revamp watch history cards
---
css/style.css | 268 ++++++++++++++++++++++++++++--
js/historyView.js | 412 +++++++++++++++++++++++++++++++---------------
2 files changed, 538 insertions(+), 142 deletions(-)
diff --git a/css/style.css b/css/style.css
index 3d23f26b..cdabeb25 100644
--- a/css/style.css
+++ b/css/style.css
@@ -104,31 +104,28 @@ header img {
padding: 1.5rem 0;
}
-/* Dedicated watch-history grids mirror the home feed layout */
+/* Dedicated watch-history layout stacks cards vertically */
.watch-history-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
- gap: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
width: 100%;
padding: 1.5rem 0;
}
-/* Profile modal gets a tighter column and removes outer padding */
+/* Profile modal uses the same vertical stack but without outer padding */
.watch-history-grid.watch-history-grid--modal {
- grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr));
- gap: 1.5rem;
padding: 0;
+ gap: 1.25rem;
}
@media (max-width: 640px) {
.watch-history-grid {
- grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
- gap: 1.5rem;
+ gap: 1.25rem;
}
.watch-history-grid.watch-history-grid--modal {
- grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr));
- gap: 1.25rem;
+ gap: 1.125rem;
}
}
@@ -159,6 +156,255 @@ header img {
animation: video-card-fade-in 220ms ease-out forwards;
}
+/* Watch history cards */
+.watch-history-card {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ align-items: stretch;
+ background-color: var(--color-card);
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ border: 0.0625rem solid transparent;
+ box-shadow: var(--shadow-md);
+ transition: box-shadow 0.2s ease, transform 0.2s ease;
+}
+
+.watch-history-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.watch-history-card--private {
+ border-color: rgba(234, 179, 8, 0.75);
+}
+
+.watch-history-card__primary {
+ display: flex;
+ gap: 1rem;
+ flex: 1 1 18rem;
+ min-width: 0;
+}
+
+.watch-history-card__thumbnail {
+ display: block;
+ flex: 0 0 14rem;
+ max-width: 14rem;
+ text-decoration: none;
+}
+
+.watch-history-card__thumbnailInner {
+ width: 100%;
+ height: 100%;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ background-color: #0f172a;
+}
+
+.watch-history-card__thumbnailInner img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.watch-history-card__details {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.watch-history-card__title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #ffffff;
+ line-height: 1.4;
+ cursor: pointer;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.watch-history-card__title:hover,
+.watch-history-card__title:focus {
+ color: #60a5fa;
+}
+
+.watch-history-card__created {
+ margin: 0;
+ font-size: 0.875rem;
+ color: #9ca3af;
+}
+
+.watch-history-card__meta {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1rem;
+ flex: 0 0 16rem;
+ min-width: 14rem;
+}
+
+.watch-history-card__watched {
+ margin: 0;
+ font-size: 0.875rem;
+ color: #d1d5db;
+}
+
+.watch-history-card__creator {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.watch-history-card__creatorAvatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 9999px;
+ border: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #1f2937;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.watch-history-card__creatorAvatar:hover,
+.watch-history-card__creatorAvatar:focus {
+ box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
+ outline: none;
+}
+
+.watch-history-card__creatorAvatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 9999px;
+}
+
+.watch-history-card__creatorName {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: #e5e7eb;
+ text-align: left;
+ cursor: pointer;
+}
+
+.watch-history-card__creatorName:hover,
+.watch-history-card__creatorName:focus {
+ color: #60a5fa;
+ outline: none;
+}
+
+.watch-history-card__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.watch-history-card__action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border-radius: 9999px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border: 0.0625rem solid transparent;
+ background-color: #1f2937;
+ color: #e5e7eb;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.2s ease, color 0.2s ease,
+ border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.watch-history-card__action:hover,
+.watch-history-card__action:focus {
+ background-color: #111827;
+ color: #ffffff;
+ border-color: #374151;
+ outline: none;
+}
+
+.watch-history-card__action--primary {
+ background-color: #2563eb;
+ color: #ffffff;
+}
+
+.watch-history-card__action--primary:hover,
+.watch-history-card__action--primary:focus {
+ background-color: #1d4ed8;
+ border-color: #1d4ed8;
+}
+
+.watch-history-card__action--danger {
+ background-color: #7f1d1d;
+ color: #ffffff;
+}
+
+.watch-history-card__action--danger:hover,
+.watch-history-card__action--danger:focus {
+ background-color: #991b1b;
+ border-color: #991b1b;
+}
+
+.watch-history-card__menus {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+@media (max-width: 1024px) {
+ .watch-history-card__meta {
+ flex: 1 1 18rem;
+ }
+}
+
+@media (max-width: 768px) {
+ .watch-history-card {
+ gap: 1.25rem;
+ padding: 1rem;
+ }
+
+ .watch-history-card__thumbnail {
+ flex-basis: 11rem;
+ max-width: 11rem;
+ }
+}
+
+@media (max-width: 640px) {
+ .watch-history-card {
+ flex-direction: column;
+ }
+
+ .watch-history-card__primary,
+ .watch-history-card__meta {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .watch-history-card__thumbnail {
+ width: 100%;
+ max-width: 100%;
+ }
+
+ .watch-history-card__meta {
+ gap: 0.75rem;
+ }
+}
+
@keyframes video-card-fade-in {
from {
opacity: 0;
diff --git a/js/historyView.js b/js/historyView.js
index 1c422cb8..a55f3a73 100644
--- a/js/historyView.js
+++ b/js/historyView.js
@@ -3,8 +3,6 @@
import { nostrClient } from "./nostr.js";
import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js";
import { subscriptions } from "./subscriptions.js";
-import { attachHealthBadges } from "./gridHealth.js";
-import { attachUrlHealthBadges } from "./urlHealthObserver.js";
const DEFAULT_BATCH_SIZE = 20;
const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1;
@@ -103,12 +101,31 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const canEdit = window.app?.pubkey === video.pubkey;
const highlightClass =
- video.isPrivate && canEdit ? "border-2 border-yellow-500" : "border-none";
+ 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()}`;
+ }
+ }
+ }
+
let hasOlder = false;
if (canEdit && video.videoRootId && window.app?.hasOlderVersion) {
hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray);
@@ -203,7 +220,7 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
`;
const cardControls = `
-
+
`;
@@ -219,77 +236,98 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const playbackMagnet = magnetCandidate;
const magnetProvided = magnetCandidate.length > 0;
- const magnetSupported =
- window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false;
- const urlBadgeHtml = trimmedUrl
- ? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ?? ""
- : "";
- const torrentHealthBadgeHtml =
- magnetProvided && magnetSupported
- ? window.app?.getTorrentHealthBadgeMarkup?.({
- includeMargin: false,
- }) ?? ""
- : "";
- const connectionBadgesHtml = urlBadgeHtml || torrentHealthBadgeHtml
- ? `
-
- ${urlBadgeHtml}${torrentHealthBadgeHtml}
-
- `
- : "";
+ const watchHistoryKey = video?.watchHistory?.key || "";
const cardHtml = `
-
-
-
-

-
-
-
@@ -480,66 +380,6 @@ function renderWatchHistoryGrid(videos, containerOrElement) {
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
- window.app?.attachMoreMenuHandlers?.(container);
-
- const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
- gearButtons.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idx = btn.getAttribute("data-settings-dropdown");
- const dropdown = document.getElementById(`settingsDropdown-${idx}`);
- if (dropdown) dropdown.classList.toggle("hidden");
- });
- });
-
- const editButtons = container.querySelectorAll("[data-edit-index]");
- editButtons.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-edit-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dropdown) dropdown.classList.add("hidden");
- const eventId = btn.getAttribute("data-edit-event-id") || "";
- window.app?.handleEditVideo({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
-
- const revertButtons = container.querySelectorAll("[data-revert-index]");
- revertButtons.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-revert-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dropdown) dropdown.classList.add("hidden");
- const eventId = btn.getAttribute("data-revert-event-id") || "";
- window.app?.handleRevertVideo({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
-
- const deleteAllButtons = container.querySelectorAll("[data-delete-all-index]");
- deleteAllButtons.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-delete-all-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dd = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dd) dd.classList.add("hidden");
- const eventId = btn.getAttribute("data-delete-all-event-id") || "";
- window.app?.handleFullDeleteVideo({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
-
const authorPics = container.querySelectorAll(".author-pic");
const authorNames = container.querySelectorAll(".author-name");
const creatorButtons = container.querySelectorAll(
@@ -790,10 +630,18 @@ export function createWatchHistoryRenderer(config = {}) {
const nextMessage = typeof message === "string" ? message.trim() : "";
if (nextMessage) {
- statusEl.textContent = nextMessage;
+ if (nextMessage === WATCH_HISTORY_LOADING_STATUS) {
+ statusEl.innerHTML =
+ '
' +
+ WATCH_HISTORY_LOADING_STATUS +
+ "";
+ } else {
+ statusEl.textContent = nextMessage;
+ }
statusEl.classList.remove("hidden");
} else {
statusEl.textContent = "";
+ statusEl.innerHTML = "";
statusEl.classList.add("hidden");
}
};
@@ -945,6 +793,45 @@ export function createWatchHistoryRenderer(config = {}) {
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]"
@@ -971,6 +858,7 @@ export function createWatchHistoryRenderer(config = {}) {
removeTrigger.disabled = true;
removeTrigger.setAttribute("aria-busy", "true");
+ showRemoveSpinner(removeTrigger);
try {
const result = await nostrClient.removeWatchHistoryItem(targetKey);
@@ -1008,7 +896,10 @@ export function createWatchHistoryRenderer(config = {}) {
"Failed to remove this video from watch history."
);
} finally {
- removeTrigger.removeAttribute("aria-busy");
+ if (removeTrigger.isConnected) {
+ restoreRemoveButton(removeTrigger);
+ removeTrigger.removeAttribute("aria-busy");
+ }
}
return;
From d32994e50f348cdce1f7a3c3fe2ca414ca79c0f9 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 21:43:17 -0400
Subject: [PATCH 30/31] Mark session actor view events
---
js/nostr.js | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/js/nostr.js b/js/nostr.js
index 1a55107f..b3111727 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -2005,6 +2005,13 @@ class NostrClient {
? Math.floor(options.created_at)
: Math.floor(Date.now() / 1000);
+ const normalizedActor =
+ typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
+ const normalizedLogged =
+ typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
+ const usingSessionActor =
+ normalizedActor && normalizedActor !== normalizedLogged;
+
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter(
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
@@ -2017,6 +2024,12 @@ class NostrClient {
...additionalTags,
];
+ if (
+ usingSessionActor &&
+ !tags.some((tag) => tag[0] === "session" && tag[1] === "true")
+ ) {
+ tags.push(["session", "true"]);
+ }
const content =
typeof options.content === "string" ? options.content : "";
@@ -2029,9 +2042,6 @@ class NostrClient {
};
let signedEvent = null;
- const normalizedActor = actorPubkey.toLowerCase();
- const normalizedLogged =
- typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
if (
normalizedActor &&
From 9682875ed939c9042f95ef420fe53313f5d33651 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 22:03:40 -0400
Subject: [PATCH 31/31] Add Nostr COUNT helper and surface discussion counts
---
README.md | 7 ++
js/app.js | 204 ++++++++++++++++++++++++++++++
js/nostr.js | 357 ++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 568 insertions(+)
diff --git a/README.md b/README.md
index 4e8dee7c..efba1995 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,13 @@ latest styles.
- **Magnet helpers**:
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
+### Relay compatibility
+
+Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
+`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
+or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
+placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
+
### Adding Features
1. **Fork the repository** and create a new branch for your feature.
diff --git a/js/app.js b/js/app.js
index d286a7cc..55af3fef 100644
--- a/js/app.js
+++ b/js/app.js
@@ -97,6 +97,8 @@ const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
const ADMIN_DM_IMAGE_URL =
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
const BITVID_WEBSITE_URL = "https://bitvid.network/";
+const MAX_DISCUSSION_COUNT_VIDEOS = 24;
+const VIDEO_EVENT_KIND = 30078;
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
/**
@@ -525,6 +527,8 @@ class bitvidApp {
// Lazy-loading helper for images
this.mediaLoader = new MediaLoader();
this.loadedThumbnails = new Map();
+ this.videoDiscussionCountCache = new Map();
+ this.inFlightDiscussionCounts = new Map();
this.activeIntervals = [];
this.urlPlaybackWatchdogCleanup = null;
@@ -7747,6 +7751,16 @@ class bitvidApp {
`
: "";
+ const showDiscussionCount = video.enableComments !== false;
+ const discussionCountHtml = showDiscussionCount
+ ? `
+
+ —
+ notes
+
+ `
+ : "";
+
const rawThumbnail =
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
const escapedThumbnail = rawThumbnail
@@ -7833,6 +7847,7 @@ class bitvidApp {
${cardControls}
${connectionBadgesHtml}
+ ${discussionCountHtml}
${torrentWarningHtml}
@@ -8135,6 +8150,195 @@ class bitvidApp {
this.goToProfile(pubkey);
});
});
+
+ this.refreshVideoDiscussionCounts(dedupedVideos);
+ }
+
+ refreshVideoDiscussionCounts(videos = []) {
+ if (
+ !Array.isArray(videos) ||
+ !videos.length ||
+ !this.videoList ||
+ !nostrClient?.pool
+ ) {
+ return;
+ }
+
+ const eligible = videos
+ .filter(
+ (video) =>
+ video &&
+ typeof video.id === "string" &&
+ video.id &&
+ video.enableComments !== false
+ )
+ .slice(0, MAX_DISCUSSION_COUNT_VIDEOS);
+
+ eligible.forEach((video) => {
+ const container = this.videoList.querySelector(
+ `[data-discussion-count="${video.id}"]`
+ );
+ if (!container) {
+ return;
+ }
+
+ const cached = this.videoDiscussionCountCache.get(video.id);
+ if (typeof cached === "number") {
+ this.updateDiscussionCountElement(container, cached);
+ return;
+ }
+
+ const filters = this.buildDiscussionCountFilters(video);
+ if (!filters.length) {
+ this.markDiscussionCountError(container, { unsupported: true });
+ return;
+ }
+
+ const existingPromise = this.inFlightDiscussionCounts.get(video.id);
+ if (existingPromise) {
+ this.setDiscussionCountPending(container);
+ return;
+ }
+
+ this.setDiscussionCountPending(container);
+
+ const request = nostrClient
+ .countEventsAcrossRelays(filters)
+ .then((result) => {
+ const perRelay = Array.isArray(result?.perRelay)
+ ? result.perRelay.filter((entry) => entry && entry.ok)
+ : [];
+
+ if (!perRelay.length) {
+ this.markDiscussionCountError(container, { unsupported: true });
+ return result;
+ }
+
+ const total = Number(result?.total);
+ const normalized =
+ Number.isFinite(total) && total >= 0 ? total : 0;
+ this.videoDiscussionCountCache.set(video.id, normalized);
+ this.updateDiscussionCountElement(container, normalized);
+ return result;
+ })
+ .catch((error) => {
+ if (isDevMode) {
+ console.warn(
+ `[counts] Failed to fetch discussion count for ${video.id}:`,
+ error
+ );
+ }
+ this.markDiscussionCountError(container);
+ throw error;
+ })
+ .finally(() => {
+ this.inFlightDiscussionCounts.delete(video.id);
+ });
+
+ this.inFlightDiscussionCounts.set(video.id, request);
+ });
+ }
+
+ buildDiscussionCountFilters(video) {
+ if (!video || typeof video !== "object") {
+ return [];
+ }
+
+ const filters = [];
+ const eventId =
+ typeof video.id === "string" ? video.id.trim() : "";
+ if (eventId) {
+ filters.push({ kinds: [1], "#e": [eventId] });
+ }
+
+ const address = this.getVideoAddressPointer(video);
+ if (address) {
+ filters.push({ kinds: [1], "#a": [address] });
+ }
+
+ return filters;
+ }
+
+ getVideoAddressPointer(video) {
+ if (!video || typeof video !== "object") {
+ return "";
+ }
+
+ const tags = Array.isArray(video.tags) ? video.tags : [];
+ const dTag = tags.find(
+ (tag) =>
+ Array.isArray(tag) &&
+ tag.length >= 2 &&
+ tag[0] === "d" &&
+ typeof tag[1] === "string" &&
+ tag[1].trim()
+ );
+
+ if (!dTag) {
+ return "";
+ }
+
+ const pubkey =
+ typeof video.pubkey === "string" ? video.pubkey.trim() : "";
+ if (!pubkey) {
+ return "";
+ }
+
+ const identifier = dTag[1].trim();
+ if (!identifier) {
+ return "";
+ }
+
+ const kind =
+ Number.isFinite(video.kind) && video.kind > 0
+ ? Math.floor(video.kind)
+ : VIDEO_EVENT_KIND;
+
+ return `${kind}:${pubkey}:${identifier}`;
+ }
+
+ setDiscussionCountPending(element) {
+ if (!element) {
+ return;
+ }
+ element.dataset.countState = "pending";
+ const valueEl = element.querySelector("[data-discussion-count-value]");
+ if (valueEl) {
+ valueEl.textContent = "…";
+ }
+ element.removeAttribute("title");
+ }
+
+ updateDiscussionCountElement(element, count) {
+ if (!element) {
+ return;
+ }
+ const valueEl = element.querySelector("[data-discussion-count-value]");
+ if (!valueEl) {
+ return;
+ }
+ const numeric = Number(count);
+ const safeValue =
+ Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
+ element.dataset.countState = "ready";
+ valueEl.textContent = safeValue.toLocaleString();
+ element.removeAttribute("title");
+ }
+
+ markDiscussionCountError(element, { unsupported = false } = {}) {
+ if (!element) {
+ return;
+ }
+ const valueEl = element.querySelector("[data-discussion-count-value]");
+ if (valueEl) {
+ valueEl.textContent = "—";
+ }
+ element.dataset.countState = unsupported ? "unsupported" : "error";
+ if (unsupported) {
+ element.title = "Relay does not support NIP-45 COUNT queries.";
+ } else {
+ element.removeAttribute("title");
+ }
}
bindThumbnailFallbacks(container) {
diff --git a/js/nostr.js b/js/nostr.js
index b3111727..a12fc112 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -81,6 +81,58 @@ function withNip07Timeout(operation) {
});
}
+function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
+ const resolvedTimeout = Number(timeoutMs);
+ const effectiveTimeout =
+ Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
+ ? Math.floor(resolvedTimeout)
+ : 4000;
+
+ let timeoutId = null;
+ let settled = false;
+
+ return new Promise((resolve, reject) => {
+ timeoutId = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (typeof onTimeout === "function") {
+ try {
+ onTimeout();
+ } catch (cleanupError) {
+ if (isDevMode) {
+ console.warn("[nostr] COUNT timeout cleanup failed:", cleanupError);
+ }
+ }
+ }
+ reject(new Error(message));
+ }, effectiveTimeout);
+
+ Promise.resolve(promise)
+ .then((value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ resolve(value);
+ })
+ .catch((error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ reject(error);
+ });
+ });
+}
+
function pointerKey(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
@@ -901,6 +953,7 @@ class NostrClient {
this.watchHistoryStorage = null;
this.watchHistoryRepublishTimers = new Map();
this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS;
+ this.countRequestCounter = 0;
}
restoreLocalData() {
@@ -4047,6 +4100,310 @@ class NostrClient {
return null;
}
+ getRequestTimeoutMs(timeoutMs) {
+ const candidate = Number(timeoutMs);
+ if (Number.isFinite(candidate) && candidate > 0) {
+ return Math.floor(candidate);
+ }
+ const poolTimeout = Number(this.pool?.getTimeout);
+ if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
+ return Math.floor(poolTimeout);
+ }
+ return 3400;
+ }
+
+ normalizeCountFilter(filter) {
+ if (!filter || typeof filter !== "object") {
+ return null;
+ }
+
+ const normalized = {};
+
+ const toStringArray = (value) => {
+ if (value === undefined || value === null) {
+ return [];
+ }
+ const source = Array.isArray(value) ? value : [value];
+ const collected = [];
+ for (const item of source) {
+ if (typeof item !== "string") {
+ continue;
+ }
+ const trimmed = item.trim();
+ if (!trimmed || collected.includes(trimmed)) {
+ continue;
+ }
+ collected.push(trimmed);
+ }
+ return collected;
+ };
+
+ if (filter.kinds !== undefined) {
+ const kindsSource = Array.isArray(filter.kinds)
+ ? filter.kinds
+ : [filter.kinds];
+ const normalizedKinds = [];
+ const seenKinds = new Set();
+ for (const candidate of kindsSource) {
+ const parsed = Number(candidate);
+ if (!Number.isFinite(parsed)) {
+ continue;
+ }
+ const normalizedValue = Math.floor(parsed);
+ if (seenKinds.has(normalizedValue)) {
+ continue;
+ }
+ seenKinds.add(normalizedValue);
+ normalizedKinds.push(normalizedValue);
+ }
+ if (normalizedKinds.length) {
+ normalized.kinds = normalizedKinds;
+ }
+ }
+
+ const ids = toStringArray(filter.ids);
+ if (ids.length) {
+ normalized.ids = ids;
+ }
+
+ const authors = toStringArray(filter.authors);
+ if (authors.length) {
+ normalized.authors = authors;
+ }
+
+ for (const [key, value] of Object.entries(filter)) {
+ if (!key.startsWith("#")) {
+ continue;
+ }
+ const tagValues = toStringArray(value);
+ if (tagValues.length) {
+ normalized[key] = tagValues;
+ }
+ }
+
+ if (filter.since !== undefined) {
+ const parsedSince = Number(filter.since);
+ if (Number.isFinite(parsedSince)) {
+ normalized.since = Math.floor(parsedSince);
+ }
+ }
+
+ if (filter.until !== undefined) {
+ const parsedUntil = Number(filter.until);
+ if (Number.isFinite(parsedUntil)) {
+ normalized.until = Math.floor(parsedUntil);
+ }
+ }
+
+ if (filter.limit !== undefined) {
+ const parsedLimit = Number(filter.limit);
+ if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
+ normalized.limit = Math.floor(parsedLimit);
+ }
+ }
+
+ return Object.keys(normalized).length ? normalized : null;
+ }
+
+ normalizeCountFilters(filters) {
+ if (!filters) {
+ return [];
+ }
+
+ const list = Array.isArray(filters) ? filters : [filters];
+ const normalized = [];
+
+ for (const candidate of list) {
+ const normalizedFilter = this.normalizeCountFilter(candidate);
+ if (normalizedFilter) {
+ normalized.push(normalizedFilter);
+ }
+ }
+
+ return normalized;
+ }
+
+ generateCountRequestId(prefix = "count") {
+ this.countRequestCounter += 1;
+ if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
+ this.countRequestCounter = 1;
+ }
+ const normalizedPrefix =
+ typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
+ const timestamp = Date.now().toString(36);
+ const counter = this.countRequestCounter.toString(36);
+ const random = Math.random().toString(36).slice(2, 8);
+ return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
+ }
+
+ extractCountValue(payload) {
+ if (typeof payload === "number") {
+ const value = Math.floor(payload);
+ return value >= 0 ? value : 0;
+ }
+
+ if (payload && typeof payload === "object") {
+ const candidate =
+ typeof payload.count === "number"
+ ? payload.count
+ : Number(payload.count);
+ if (Number.isFinite(candidate)) {
+ const value = Math.floor(candidate);
+ return value >= 0 ? value : 0;
+ }
+ }
+
+ const parsed = Number(payload);
+ if (Number.isFinite(parsed)) {
+ const value = Math.floor(parsed);
+ return value >= 0 ? value : 0;
+ }
+
+ return 0;
+ }
+
+ async sendRawCountFrame(relayUrl, filters, options = {}) {
+ if (!this.pool) {
+ throw new Error(
+ "Nostr pool not initialized. Call nostrClient.init() before requesting counts."
+ );
+ }
+
+ const normalizedUrl =
+ typeof relayUrl === "string" ? relayUrl.trim() : "";
+ if (!normalizedUrl) {
+ throw new Error("Invalid relay URL for COUNT request.");
+ }
+
+ const normalizedFilters = this.normalizeCountFilters(filters);
+ if (!normalizedFilters.length) {
+ throw new Error("At least one filter is required for a COUNT request.");
+ }
+
+ const requestId =
+ typeof options.subId === "string" && options.subId.trim()
+ ? options.subId.trim()
+ : this.generateCountRequestId();
+
+ let relay;
+ try {
+ relay = await this.pool.ensureRelay(normalizedUrl);
+ } catch (error) {
+ throw new Error(`Failed to connect to relay ${normalizedUrl}`);
+ }
+
+ if (!relay) {
+ throw new Error(
+ `Relay ${normalizedUrl} is unavailable for COUNT requests.`
+ );
+ }
+
+ const frame = ["COUNT", requestId, ...normalizedFilters];
+ let countPromise;
+
+ if (
+ relay.openCountRequests instanceof Map &&
+ typeof relay.send === "function"
+ ) {
+ countPromise = new Promise((resolve, reject) => {
+ const cleanup = () => {
+ if (relay.openCountRequests instanceof Map) {
+ relay.openCountRequests.delete(requestId);
+ }
+ };
+
+ relay.openCountRequests.set(requestId, {
+ resolve: (value) => {
+ cleanup();
+ resolve(value);
+ },
+ reject: (error) => {
+ cleanup();
+ reject(error);
+ },
+ });
+
+ let sendResult;
+ try {
+ sendResult = relay.send(JSON.stringify(frame));
+ } catch (error) {
+ cleanup();
+ reject(error);
+ return;
+ }
+
+ if (sendResult && typeof sendResult.catch === "function") {
+ sendResult.catch((error) => {
+ cleanup();
+ reject(error);
+ });
+ }
+ });
+ } else if (typeof relay.count === "function") {
+ countPromise = relay.count(normalizedFilters, { id: requestId });
+ } else {
+ throw new Error(
+ `[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
+ );
+ }
+
+ const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
+ const rawResult = await withRequestTimeout(
+ countPromise,
+ timeoutMs,
+ () => {
+ if (relay?.openCountRequests instanceof Map) {
+ relay.openCountRequests.delete(requestId);
+ }
+ },
+ `COUNT request timed out after ${timeoutMs}ms`
+ );
+
+ const countValue = this.extractCountValue(rawResult);
+ return ["COUNT", requestId, { count: countValue }];
+ }
+
+ async countEventsAcrossRelays(filters, options = {}) {
+ const normalizedFilters = this.normalizeCountFilters(filters);
+ if (!normalizedFilters.length) {
+ return { total: 0, perRelay: [] };
+ }
+
+ const relayList =
+ Array.isArray(options.relays) && options.relays.length
+ ? options.relays
+ : Array.isArray(this.relays) && this.relays.length
+ ? this.relays
+ : RELAY_URLS;
+
+ const perRelay = await Promise.all(
+ relayList.map(async (url) => {
+ try {
+ const frame = await this.sendRawCountFrame(url, normalizedFilters, {
+ timeoutMs: options.timeoutMs,
+ });
+ const count = this.extractCountValue(frame?.[2]);
+ return { url, ok: true, frame, count };
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(`[nostr] COUNT request failed on ${url}:`, error);
+ }
+ return { url, ok: false, error };
+ }
+ })
+ );
+
+ const total = perRelay.reduce((sum, entry) => {
+ if (!entry || !entry.ok) {
+ return sum;
+ }
+ const value = Number(entry.count);
+ return Number.isFinite(value) && value > 0 ? sum + value : sum;
+ }, 0);
+
+ return { total, perRelay };
+ }
+
/**
* Ensure we have every historical revision for a given video in memory and
* return the complete set sorted newest-first. We primarily group revisions