From 57d114aa526baaffe3278c7a2cc037521245b7a5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:28:29 -0400 Subject: [PATCH 01/31] Document watch history configuration defaults --- config/instance-config.js | 39 +++++++++++++++++++++++++++++++++++++++ js/config.js | 13 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/config/instance-config.js b/config/instance-config.js index 10d582b0..2ef81c37 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -44,3 +44,42 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode"; * default state when no preference has been stored in localStorage yet. */ export const DEFAULT_WHITELIST_MODE_ENABLED = true; + +/** + * Nostr kind used when persisting watch history events. + * + * BitVid’s roadmap standardizes on kind 30078 so that watch events, view logs, + * and media metadata stay in the same family of documents. Operators that want + * to experiment with a separate list kind (for example, a NIP-51 collection) + * can flip this number so long as their relays accept the chosen kind. + */ +export const WATCH_HISTORY_KIND = 30078; + +/** + * Identifier applied to the watch-history list when storing it on relays. + * + * This becomes the value of the `d` tag so clients can find the correct list + * without guessing. Customize the slug if you need isolation between multiple + * BitVid deployments that share relays, but keep it stable once clients begin + * syncing history. + */ +export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history"; + +/** + * Maximum number of watch-history entries to retain per user. + * + * The roadmap targets a rolling window of 1,500 items so the UI can highlight + * recently played videos without ballooning relay storage. Adjust this cap up + * or down depending on your retention and privacy policies; smaller values + * reduce storage pressure while larger ones surface a deeper backlog. + */ +export const WATCH_HISTORY_MAX_ITEMS = 1500; + +/** + * Whether clients should resolve watch-history entries in batches. + * + * When enabled, BitVid fetches video metadata in grouped queries instead of + * issuing one request per entry. Operators with relays that struggle under + * bursty loads can disable batching at the cost of additional round trips. + */ +export const WATCH_HISTORY_BATCH_RESOLVE = true; diff --git a/js/config.js b/js/config.js index 5fc79f62..fe95d495 100644 --- a/js/config.js +++ b/js/config.js @@ -4,6 +4,10 @@ import { ADMIN_SUPER_NPUB, ADMIN_WHITELIST_MODE_STORAGE_KEY, DEFAULT_WHITELIST_MODE_ENABLED, + WATCH_HISTORY_KIND, + WATCH_HISTORY_LIST_IDENTIFIER, + WATCH_HISTORY_MAX_ITEMS, + WATCH_HISTORY_BATCH_RESOLVE, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -42,3 +46,12 @@ export function setWhitelistMode(enabled) { } export { ADMIN_WHITELIST_MODE_STORAGE_KEY }; + +// ----------------------------------------------------------------------------- +// Watch history — roadmap defaults balance retention with relay load +// ----------------------------------------------------------------------------- + +export { WATCH_HISTORY_KIND }; +export { WATCH_HISTORY_LIST_IDENTIFIER }; +export { WATCH_HISTORY_MAX_ITEMS }; +export { WATCH_HISTORY_BATCH_RESOLVE }; From 511a14087ea409f410d808d4ffd3ae4db5f6bd8b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:55:01 -0400 Subject: [PATCH 02/31] Add watch history session utilities --- js/nostr.js | 998 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 997 insertions(+), 1 deletion(-) diff --git a/js/nostr.js b/js/nostr.js index 126658c0..9359a91a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1,6 +1,12 @@ // js/nostr.js -import { isDevMode } from "./config.js"; +import { + isDevMode, + WATCH_HISTORY_KIND, + WATCH_HISTORY_LIST_IDENTIFIER, + WATCH_HISTORY_MAX_ITEMS, + WATCH_HISTORY_BATCH_RESOLVE, +} from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable @@ -22,6 +28,9 @@ 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 SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; +const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1"; +const WATCH_HISTORY_CACHE_TTL_MS = EVENTS_CACHE_TTL_MS; // To limit error spam let errorLogCount = 0; @@ -41,6 +50,245 @@ function logErrorOnce(message, eventContent = null) { } } +function pointerKey(pointer) { + if (!pointer || typeof pointer !== "object") { + return ""; + } + + const type = pointer.type === "a" ? "a" : "e"; + const value = + typeof pointer.value === "string" ? pointer.value.trim().toLowerCase() : ""; + + if (!value) { + return ""; + } + + return `${type}:${value}`; +} + +function clonePointerItem(pointer) { + if (!pointer || typeof pointer !== "object") { + return null; + } + + const type = pointer.type === "a" ? "a" : "e"; + const value = typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return null; + } + + const relay = + typeof pointer.relay === "string" && pointer.relay.trim() + ? pointer.relay.trim() + : null; + + return { type, value, relay }; +} + +function normalizePointerTag(tag) { + if (!Array.isArray(tag) || tag.length < 2) { + return null; + } + + const type = tag[0] === "a" ? "a" : tag[0] === "e" ? "e" : ""; + if (!type) { + return null; + } + + const value = typeof tag[1] === "string" ? tag[1].trim() : ""; + if (!value) { + return null; + } + + const relay = + tag.length > 2 && typeof tag[2] === "string" && tag[2].trim() + ? tag[2].trim() + : null; + + return { type, value, relay }; +} + +function normalizePointerInput(pointer) { + if (!pointer) { + return null; + } + + if (Array.isArray(pointer)) { + return normalizePointerTag(pointer); + } + + if (typeof pointer === "object") { + if (typeof pointer.type === "string" && typeof pointer.value === "string") { + return clonePointerItem(pointer); + } + + if (Array.isArray(pointer.tag)) { + return normalizePointerTag(pointer.tag); + } + } + + if (typeof pointer !== "string") { + return null; + } + + const trimmed = pointer.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("naddr") || trimmed.startsWith("nevent")) { + try { + const decoder = window?.NostrTools?.nip19?.decode; + if (typeof decoder === "function") { + const decoded = decoder(trimmed); + if (decoded?.type === "naddr" && decoded.data) { + const { kind, pubkey, identifier, relays } = decoded.data; + if ( + typeof kind === "number" && + typeof pubkey === "string" && + typeof identifier === "string" + ) { + const relay = + Array.isArray(relays) && relays.length && typeof relays[0] === "string" + ? relays[0] + : null; + return { + type: "a", + value: `${kind}:${pubkey}:${identifier}`, + relay, + }; + } + } + if (decoded?.type === "nevent" && decoded.data) { + const { id, relays } = decoded.data; + if (typeof id === "string" && id.trim()) { + const relay = + Array.isArray(relays) && relays.length && typeof relays[0] === "string" + ? relays[0] + : null; + return { + type: "e", + value: id.trim(), + relay, + }; + } + } + } + } catch (err) { + if (isDevMode) { + console.warn(`[nostr] Failed to decode pointer ${trimmed}:`, err); + } + } + } + + const type = trimmed.includes(":") ? "a" : "e"; + return { type, value: trimmed, relay: null }; +} + +function pointerToTag(pointer) { + const normalized = clonePointerItem(pointer); + if (!normalized) { + return null; + } + + const tag = [normalized.type, normalized.value]; + if (normalized.relay) { + tag.push(normalized.relay); + } + return tag; +} + +function extractPointerItemsFromEvent(event) { + if (!event || typeof event !== "object") { + return []; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const seen = new Set(); + const items = []; + + for (const tag of tags) { + const pointer = normalizePointerTag(tag); + if (!pointer) { + continue; + } + + const key = pointerKey(pointer); + if (!key || seen.has(key)) { + continue; + } + + seen.add(key); + items.push(pointer); + } + + return items; +} + +function eventToAddressPointer(event) { + if (!event || typeof event !== "object") { + return ""; + } + + const kind = typeof event.kind === "number" ? event.kind : null; + const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; + if (!kind || !pubkey) { + return ""; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") { + return `${kind}:${pubkey}:${tag[1]}`; + } + } + + return ""; +} + +function signEventWithPrivateKey(event, privateKey) { + if ( + !privateKey || + typeof privateKey !== "string" || + !window?.NostrTools?.getEventHash || + typeof window.NostrTools.signEvent !== "function" + ) { + throw new Error("Missing signing primitives"); + } + + const tags = Array.isArray(event.tags) + ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) + : []; + + const prepared = { + kind: event.kind, + pubkey: event.pubkey, + created_at: event.created_at, + tags, + content: typeof event.content === "string" ? event.content : "", + }; + + const id = window.NostrTools.getEventHash(prepared); + const sig = window.NostrTools.signEvent(prepared, privateKey); + + return { ...prepared, id, sig }; +} + +function cloneEventForCache(event) { + if (!event || typeof event !== "object") { + return null; + } + + const cloned = { ...event }; + if (Array.isArray(event.tags)) { + cloned.tags = event.tags.map((tag) => + Array.isArray(tag) ? [...tag] : tag + ); + } + + return cloned; +} + /** * Example "encryption" that just reverses strings. * In real usage, replace with actual crypto. @@ -435,6 +683,10 @@ class NostrClient { this.activeMap = new Map(); this.hasRestoredLocalData = false; + + this.sessionActor = null; + this.watchHistoryCache = new Map(); + this.watchHistoryStorage = null; } restoreLocalData() { @@ -536,6 +788,750 @@ class NostrClient { return this.allEvents.size > 0; } + /** + * Load watch history cache payload from localStorage. + */ + getWatchHistoryStorage() { + if (this.watchHistoryStorage) { + return this.watchHistoryStorage; + } + + const base = { version: 1, actors: {} }; + + 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 now = Date.now(); + let mutated = false; + for (const [actor, info] of Object.entries(parsed.actors || {})) { + if ( + !info || + typeof info !== "object" || + typeof info.savedAt !== "number" || + now - info.savedAt > WATCH_HISTORY_CACHE_TTL_MS + ) { + delete parsed.actors[actor]; + mutated = true; + } + } + + const actors = { ...(parsed.actors || {}) }; + this.watchHistoryStorage = { version: 1, actors }; + + if (mutated) { + 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; + } + + createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) { + const normalizedItems = []; + const seen = new Set(); + + 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); + normalizedItems.push(pointer); + } + } + + 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(), + resolving: previousEntry?.resolving + ? new Set(previousEntry.resolving) + : new Set(), + }; + + 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.resolving]) { + if (!validKeys.has(key)) { + entry.resolving.delete(key); + } + } + + return entry; + } + + 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, + })), + 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 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); + } + } + } + + if ( + !window?.NostrTools?.generatePrivateKey || + typeof window.NostrTools.getPublicKey !== "function" + ) { + if (isDevMode) { + console.warn( + "[nostr] Unable to generate session actor: missing NostrTools helpers." + ); + } + return ""; + } + + const privateKey = window.NostrTools.generatePrivateKey(); + const pubkey = window.NostrTools.getPublicKey(privateKey); + + 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 publishViewEvent(videoPointer, options = {}) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const pointer = normalizePointerInput(videoPointer); + if (!pointer) { + return { ok: false, error: "invalid-pointer" }; + } + + const actorPubkey = await this.ensureSessionActor(); + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + const createdAt = + typeof options.created_at === "number" && options.created_at > 0 + ? Math.floor(options.created_at) + : Math.floor(Date.now() / 1000); + + const additionalTags = Array.isArray(options.additionalTags) + ? options.additionalTags.filter( + (tag) => Array.isArray(tag) && typeof tag[0] === "string" + ) + : []; + + const tags = [ + ["t", "view"], + ["video", pointer.value], + ...additionalTags, + ]; + + const content = + typeof options.content === "string" ? options.content : ""; + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: actorPubkey, + created_at: createdAt, + tags, + content, + }; + + let signedEvent = null; + const normalizedActor = actorPubkey.toLowerCase(); + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function" + ) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn("[nostr] Failed to sign view event with extension:", error); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + await this.ensureSessionActor(true); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + throw new Error("session-actor-mismatch"); + } + const privateKey = this.sessionActor.privateKey; + signedEvent = signEventWithPrivateKey(event, privateKey); + } catch (error) { + console.warn("[nostr] Failed to sign view event with session key:", error); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const relayList = + Array.isArray(options.relays) && options.relays.length + ? options.relays + : this.relays; + const relays = + Array.isArray(relayList) && relayList.length ? relayList : RELAY_URLS; + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const success = publishResults.some((result) => result.success); + if (!success) { + console.warn("[nostr] View event rejected by relays:", publishResults); + } + + return { ok: success, event: signedEvent, results: publishResults }; + } + + async updateWatchHistoryList(pointer) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const normalizedPointer = normalizePointerInput(pointer); + if (!normalizedPointer) { + return { ok: false, error: "invalid-pointer" }; + } + + 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 dedupe = new Map(); + const nextItems = []; + const pushPointer = (item) => { + const candidate = normalizePointerInput(item); + if (!candidate) { + return; + } + const key = pointerKey(candidate); + if (!key || dedupe.has(key)) { + return; + } + dedupe.set(key, true); + nextItems.push(candidate); + }; + + pushPointer(normalizedPointer); + for (const item of existingEntry.items) { + pushPointer(item); + if (nextItems.length >= WATCH_HISTORY_MAX_ITEMS) { + break; + } + } + + const trimmedItems = + nextItems.length > WATCH_HISTORY_MAX_ITEMS + ? nextItems.slice(0, WATCH_HISTORY_MAX_ITEMS) + : nextItems; + + const tags = [["d", WATCH_HISTORY_LIST_IDENTIFIER]]; + for (const item of trimmedItems) { + const tag = pointerToTag(item); + if (tag) { + tags.push(tag); + } + } + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: actorPubkey, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; + + let signedEvent; + const normalizedActor = actorPubkey.toLowerCase(); + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function" + ) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history list with extension:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + await this.ensureSessionActor(true); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + throw new Error("session-actor-mismatch"); + } + const privateKey = this.sessionActor.privateKey; + signedEvent = signEventWithPrivateKey(event, privateKey); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history list with session key:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + const success = publishResults.some((result) => result.success); + if (!success) { + console.warn( + "[nostr] Failed to publish watch history list:", + publishResults + ); + } + + const newEntry = this.createWatchHistoryEntry( + signedEvent, + trimmedItems, + Date.now(), + existingEntry + ); + + this.watchHistoryCache.set(actorPubkey, newEntry); + this.persistWatchHistoryEntry(actorPubkey, newEntry); + + return { + ok: success, + event: signedEvent, + items: newEntry.items.map((item) => clonePointerItem(item)).filter(Boolean), + }; + } + + async fetchWatchHistory(pubkeyOrSession) { + 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 cached = this.watchHistoryCache.get(actor); + if (cached && now - cached.savedAt < WATCH_HISTORY_CACHE_TTL_MS) { + return { + pointerEvent: cached.pointerEvent + ? cloneEventForCache(cached.pointerEvent) + : null, + items: cached.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + + if (typeof localStorage !== "undefined") { + const storage = this.getWatchHistoryStorage(); + const stored = storage.actors?.[actor]; + if ( + stored && + typeof stored.savedAt === "number" && + now - stored.savedAt < WATCH_HISTORY_CACHE_TTL_MS + ) { + const entry = this.createWatchHistoryEntry( + stored.pointerEvent, + Array.isArray(stored.items) ? stored.items : [], + stored.savedAt, + this.watchHistoryCache.get(actor) || null + ); + this.watchHistoryCache.set(actor, entry); + return { + pointerEvent: entry.pointerEvent + ? cloneEventForCache(entry.pointerEvent) + : null, + items: entry.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + } + + if (!this.pool) { + const fallbackEntry = this.createWatchHistoryEntry( + null, + [], + Date.now(), + this.watchHistoryCache.get(actor) || null + ); + this.watchHistoryCache.set(actor, fallbackEntry); + return { pointerEvent: null, items: [] }; + } + + const relayList = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + + const filter = { + kinds: [WATCH_HISTORY_KIND], + authors: [actor], + "#d": [WATCH_HISTORY_LIST_IDENTIFIER], + limit: 1, + }; + + let pointerEvent = null; + try { + const perRelay = await Promise.all( + relayList.map(async (url) => { + try { + const evt = await this.pool.get([url], filter); + return evt || null; + } catch (err) { + if (isDevMode) { + console.warn( + `[nostr] Failed to fetch watch history from ${url}:`, + err + ); + } + return null; + } + }) + ); + + const events = perRelay.filter(Boolean); + if (events.length) { + pointerEvent = events.reduce((latest, candidate) => { + if (!latest) { + return candidate; + } + const latestCreated = + typeof latest.created_at === "number" ? latest.created_at : 0; + const candidateCreated = + typeof candidate.created_at === "number" ? candidate.created_at : 0; + return candidateCreated > latestCreated ? candidate : latest; + }, null); + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to fetch watch history list:", error); + } + } + + const items = pointerEvent ? extractPointerItemsFromEvent(pointerEvent) : []; + const entry = this.createWatchHistoryEntry( + pointerEvent, + items, + Date.now(), + this.watchHistoryCache.get(actor) || null + ); + + this.watchHistoryCache.set(actor, entry); + this.persistWatchHistoryEntry(actor, entry); + + 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 []; + } + + 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 available = entry.items.filter((item) => { + const key = pointerKey(item); + if (!key) { + return false; + } + if (entry.resolved.has(key)) { + return false; + } + if (entry.resolving.has(key)) { + return false; + } + return true; + }); + + const batch = available.slice(0, effectiveBatchSize); + if (!batch.length) { + return []; + } + + batch.forEach((item) => entry.resolving.add(pointerKey(item))); + + 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]; + } + 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); + } + } + + const resolvedVideos = []; + 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); + const event = pointerMatches.get(key); + 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.resolved.set(key, video); + resolvedVideos.push(video); + } + } catch (err) { + if (isDevMode) { + console.warn("[nostr] Failed to convert watch history event:", err); + } + } + } + entry.resolving.delete(key); + } + + return resolvedVideos; + } + + async recordVideoView(videoPointer, options = {}) { + const pointer = normalizePointerInput(videoPointer); + if (!pointer) { + return { + ok: false, + error: "invalid-pointer", + view: { ok: false, error: "invalid-pointer" }, + history: { ok: false, error: "invalid-pointer" }, + }; + } + + const view = await this.publishViewEvent(pointer, options); + const history = await this.updateWatchHistoryList(pointer); + + return { + ok: view.ok && history.ok, + view, + history, + }; + } + /** * Connect to the configured relays */ From b56cd0c95fb2fe9f0e9ad4b6226df2dc67060955 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:22:54 -0400 Subject: [PATCH 03/31] Add playback view logging via Nostr helpers --- js/app.js | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++- js/nostr.js | 6 ++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index 1ec336e6..bba4a79e 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,11 @@ // js/app.js import { loadView } from "./viewManager.js"; -import { nostrClient } from "./nostr.js"; +import { + nostrClient, + recordVideoView, + updateWatchHistoryList, +} from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; import { accessControl, normalizeNpub } from "./accessControl.js"; @@ -54,6 +58,30 @@ function truncateMiddle(text, maxLength = 72) { return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; } +function pointerArrayToKey(pointer) { + if (!Array.isArray(pointer) || pointer.length < 2) { + return ""; + } + + const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : ""; + if (!type) { + return ""; + } + + const value = + typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : ""; + if (!value) { + return ""; + } + + const relay = + pointer.length > 2 && typeof pointer[2] === "string" + ? pointer[2].trim() + : ""; + + return relay ? `${type}:${value}:${relay}` : `${type}:${value}`; +} + /** * Simple "decryption" placeholder for private videos. */ @@ -671,6 +699,10 @@ class bitvidApp { this.pubkey = null; this.currentMagnetUri = null; this.currentVideo = null; + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + this.playbackTelemetryState = null; + this.loggedViewPointerKeys = new Set(); this.videoSubscription = null; this.videoList = null; this._videoListElement = null; @@ -6261,6 +6293,7 @@ class bitvidApp { `[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})` ); try { + this.cancelPendingViewLogging(); this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); @@ -6358,6 +6391,209 @@ class bitvidApp { } } + cancelPendingViewLogging() { + const state = this.playbackTelemetryState; + if (!state) { + return; + } + + if (state.timerId) { + clearTimeout(state.timerId); + } + + if (Array.isArray(state.handlers) && state.videoEl) { + for (const { eventName, handler } of state.handlers) { + try { + state.videoEl.removeEventListener(eventName, handler); + } catch (err) { + if (isDevMode) { + console.warn( + `[cancelPendingViewLogging] Failed to detach ${eventName} listener:`, + err + ); + } + } + } + } + + this.playbackTelemetryState = null; + } + + preparePlaybackViewLogging(videoEl) { + this.cancelPendingViewLogging(); + + if (!videoEl || typeof videoEl.addEventListener !== "function") { + return; + } + + const pointer = this.currentVideoPointer; + const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer); + if (!pointer || !pointerKey) { + return; + } + + if (this.loggedViewPointerKeys.has(pointerKey)) { + return; + } + + const THRESHOLD_SECONDS = 12; + const state = { + videoEl, + pointer, + pointerKey, + handlers: [], + timerId: null, + fired: false, + }; + + const finalize = () => { + if (state.fired) { + return; + } + state.fired = true; + cancelTimer(); + this.cancelPendingViewLogging(); + + const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = + state; + + (async () => { + try { + const result = await recordVideoView(thresholdPointer); + const viewOk = !!result?.view?.ok; + if (viewOk) { + this.loggedViewPointerKeys.add(thresholdPointerKey); + } else if (isDevMode) { + console.warn( + "[playVideoWithFallback] View event rejected by relays:", + result?.view || result + ); + } + + let historyOk = !!result?.history?.ok; + if (!historyOk) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history update via recordVideoView failed; retrying.", + result?.history || result + ); + } + const fallbackHistory = await updateWatchHistoryList( + thresholdPointer + ); + historyOk = !!fallbackHistory?.ok; + if (!historyOk && isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history retry failed:", + fallbackHistory + ); + } + } + + if (!viewOk && isDevMode && historyOk) { + console.warn( + "[playVideoWithFallback] Watch history updated but view event was rejected." + ); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Exception while recording video view:", + error + ); + } + try { + const fallbackHistory = await updateWatchHistoryList( + thresholdPointer + ); + if (!fallbackHistory?.ok && isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history retry after view exception failed:", + fallbackHistory + ); + } + } catch (historyError) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Exception while retrying watch history after view failure:", + historyError + ); + } + } + } + })().catch((error) => { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Unexpected error while recording video view:", + error + ); + } + }); + }; + + const cancelTimer = () => { + if (state.timerId) { + clearTimeout(state.timerId); + state.timerId = null; + } + }; + + const scheduleTimer = () => { + if (state.fired) { + return; + } + const currentSeconds = Number.isFinite(videoEl.currentTime) + ? videoEl.currentTime + : 0; + const remainingMs = Math.max( + 0, + Math.ceil((THRESHOLD_SECONDS - currentSeconds) * 1000) + ); + if (remainingMs <= 0) { + finalize(); + return; + } + state.timerId = window.setTimeout(finalize, remainingMs); + }; + + const registerHandler = (eventName, handler) => { + videoEl.addEventListener(eventName, handler); + state.handlers.push({ eventName, handler }); + }; + + registerHandler("timeupdate", () => { + if (videoEl.currentTime >= THRESHOLD_SECONDS) { + finalize(); + } + }); + + const cancelOnPause = () => { + if (!state.fired) { + cancelTimer(); + } + }; + + ["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) => + registerHandler(event, cancelOnPause) + ); + + const resumeIfNeeded = () => { + if (!state.fired && !state.timerId) { + scheduleTimer(); + } + }; + + ["play", "playing"].forEach((event) => + registerHandler(event, resumeIfNeeded) + ); + + this.playbackTelemetryState = state; + + if (!videoEl.paused && videoEl.currentTime > 0) { + resumeIfNeeded(); + } + } + teardownVideoElement(videoElement, { replaceNode = false } = {}) { if (!videoElement) { this.log( @@ -6774,6 +7010,7 @@ class bitvidApp { */ async hideModal() { // 1) Clear intervals, cleanup, etc. (unchanged) + this.cancelPendingViewLogging(); this.clearActiveIntervals(); try { @@ -8963,6 +9200,7 @@ class bitvidApp { // blank frame. Guard every playback entry point with waitForCleanup() so the // race never reappears. await this.waitForCleanup(); + this.cancelPendingViewLogging(); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; @@ -9089,6 +9327,7 @@ class bitvidApp { this.resetTorrentStats(); this.playSource = null; this.cleanupUrlPlaybackWatchdog(); + this.preparePlaybackViewLogging(activeVideoEl); if (magnetForPlayback) { const label = playbackConfig.didMutate @@ -9378,6 +9617,9 @@ class bitvidApp { return; } + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + if (this.blacklistedEventIds.has(eventId)) { this.showError("This content has been removed or is not allowed."); return; @@ -9447,6 +9689,44 @@ class bitvidApp { lightningAddress: null, }; + const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); + const normalizedPubkey = + typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + const primaryPointer = + dTagValue && normalizedPubkey + ? [ + "a", + `${ + typeof video.kind === "number" && Number.isFinite(video.kind) + ? video.kind + : 30078 + }:${normalizedPubkey}:${dTagValue}`, + ] + : null; + const fallbackId = + typeof (video.id || eventId) === "string" + ? (video.id || eventId).trim() + : ""; + const fallbackPointer = + !primaryPointer && fallbackId + ? ["e", fallbackId] + : null; + + const resolvedPointer = primaryPointer || fallbackPointer; + const resolvedPointerKey = pointerArrayToKey(resolvedPointer); + + this.currentVideoPointer = resolvedPointer && resolvedPointerKey + ? resolvedPointer + : null; + this.currentVideoPointerKey = this.currentVideoPointer + ? resolvedPointerKey + : null; + + if (this.currentVideo) { + this.currentVideo.pointer = this.currentVideoPointer; + this.currentVideo.pointerKey = this.currentVideoPointerKey; + } + this.syncModalMoreMenuData(); this.currentMagnetUri = sanitizedMagnet || null; @@ -9525,6 +9805,9 @@ class bitvidApp { title = "Untitled", description = "", } = {}) { + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; const decodedMagnet = safeDecodeMagnet(trimmedMagnet); @@ -9562,6 +9845,8 @@ class bitvidApp { originalMagnet: trimmedMagnet, torrentSupported: magnetSupported, lightningAddress: null, + pointer: null, + pointerKey: null, }; this.syncModalMoreMenuData(); diff --git a/js/nostr.js b/js/nostr.js index 9359a91a..93188edc 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2675,3 +2675,9 @@ class NostrClient { } export const nostrClient = new NostrClient(); + +export const recordVideoView = (...args) => + nostrClient.recordVideoView(...args); + +export const updateWatchHistoryList = (...args) => + nostrClient.updateWatchHistoryList(...args); From ab607dc7b8a95e82d19a7281ce0781f2d6dc4fd5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:36:41 -0400 Subject: [PATCH 04/31] Add watch history view --- assets/svg/history-icon.svg | 22 ++++ components/sidebar.html | 11 ++ js/historyView.js | 220 ++++++++++++++++++++++++++++++++++++ js/viewManager.js | 10 ++ views/history.html | 19 ++++ 5 files changed, 282 insertions(+) create mode 100644 assets/svg/history-icon.svg create mode 100644 js/historyView.js create mode 100644 views/history.html diff --git a/assets/svg/history-icon.svg b/assets/svg/history-icon.svg new file mode 100644 index 00000000..c224ce68 --- /dev/null +++ b/assets/svg/history-icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/components/sidebar.html b/components/sidebar.html index beaab150..dd133e69 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -34,6 +34,17 @@ /> Explore + + History + History + { + if (video && video.id) { + dedupeMap.set(video.id, video); + } + }); + nextVideos.forEach((video) => { + if (video && video.id) { + dedupeMap.set(video.id, video); + } + }); + resolvedVideos = Array.from(dedupeMap.values()); +} + +function renderResolvedVideos() { + const { grid, emptyEl } = getElements(); + if (!grid) { + return; + } + + if (!resolvedVideos.length) { + if (emptyEl) { + emptyEl.textContent = EMPTY_COPY; + emptyEl.classList.remove("hidden"); + } + grid.classList.add("hidden"); + return; + } + + subscriptions.renderSameGridStyle(resolvedVideos, "watchHistoryGrid"); + grid.classList.remove("hidden"); + if (emptyEl) { + emptyEl.classList.add("hidden"); + } +} + +async function loadNextBatch({ initial = false } = {}) { + if (isLoading || !hasMore) { + if (initial && !isLoading && resolvedVideos.length === 0) { + setLoadingVisible(false); + } + return; + } + + isLoading = true; + + try { + const videos = await nostrClient.resolveWatchHistory(BATCH_SIZE); + + if (videos.length === 0) { + if (initial && resolvedVideos.length === 0) { + showEmptyState(); + } else { + hasMore = false; + cleanupObservers(); + } + return; + } + + mergeResolvedVideos(videos); + renderResolvedVideos(); + setLoadingVisible(false); + } catch (error) { + console.error("[historyView] Failed to resolve watch history:", error); + if (initial && resolvedVideos.length === 0) { + showEmptyState("We couldn't load your watch history. Please try again later."); + } + } finally { + isLoading = false; + } +} + +function attachObservers() { + const { sentinel } = getElements(); + if (!sentinel || !hasMore) { + return; + } + + if ("IntersectionObserver" in window) { + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadNextBatch(); + } + }); + }, + { rootMargin: "300px 0px" } + ); + observer.observe(sentinel); + return; + } + + scrollHandler = () => { + const rect = sentinel.getBoundingClientRect(); + if (rect.top <= window.innerHeight + 200) { + loadNextBatch(); + } + }; + window.addEventListener("scroll", scrollHandler, { passive: true }); + scrollHandler(); +} + +export async function initHistoryView() { + const { view } = getElements(); + if (!view) { + return; + } + + cleanupObservers(); + resolvedVideos = []; + hasMore = true; + isLoading = false; + resetUiState(); + + try { + const actor = window.app?.pubkey || undefined; + await nostrClient.fetchWatchHistory(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; + } + + await loadNextBatch({ initial: true }); + + if (hasMore) { + attachObservers(); + } +} + +// Expose init on window for debugging/manual triggers if needed. +if (!window.bitvid) { + window.bitvid = {}; +} +window.bitvid.initHistoryView = initHistoryView; diff --git a/js/viewManager.js b/js/viewManager.js index 741cd0a2..30671ed8 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -67,6 +67,16 @@ export const viewInitRegistry = { explore: () => { console.log("Explore view loaded."); }, + history: async () => { + try { + const module = await import("./historyView.js"); + if (typeof module.initHistoryView === "function") { + await module.initHistoryView(); + } + } catch (error) { + console.error("Failed to initialize history view:", error); + } + }, /** * Subscriptions view: diff --git a/views/history.html b/views/history.html new file mode 100644 index 00000000..fa69bf07 --- /dev/null +++ b/views/history.html @@ -0,0 +1,19 @@ + +
+
+

Watch History

+
+
+
+
+ + +
+ +
From 80fcb22f89328e59bb7baa290a88713124bbdd3d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:59:33 -0400 Subject: [PATCH 05/31] Add profile watch history pane to modal --- components/profile-modal.html | 39 +++ js/app.js | 66 +++++ js/historyView.js | 483 ++++++++++++++++++++++------------ 3 files changed, 413 insertions(+), 175 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index b473123d..3c3728e0 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -88,6 +88,15 @@ > Blocked + + ` + : ""; + + const gearMenu = canEdit + ? ` +
+ + +
+ ` + : ""; + + const moreMenu = ` +
+ + +
+ `; + + const cardControls = ` +
+ ${moreMenu}${gearMenu} +
+ `; + + 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 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 cardHtml = ` +
+ +
+ ${safeTitle} +
+
+
+

+ ${safeTitle} +

+
+
+
+ Placeholder +
+
+

+ Loading name... +

+
+ ${timeAgo} +
+
+
+ ${cardControls} +
+ ${connectionBadgesHtml} +
+
+ `; + + 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; + } + + if (trimmedUrl) { + cardEl.dataset.urlHealthState = "checking"; + if (cardEl.dataset.urlHealthReason) { + delete cardEl.dataset.urlHealthReason; + } + cardEl.dataset.urlHealthEventId = video.id || ""; + cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); + } else { + cardEl.dataset.urlHealthState = "offline"; + cardEl.dataset.urlHealthReason = "missing-source"; + if (cardEl.dataset.urlHealthEventId) { + delete cardEl.dataset.urlHealthEventId; + } + if (cardEl.dataset.urlHealthUrl) { + delete cardEl.dataset.urlHealthUrl; + } + } + if (magnetProvided && magnetSupported) { + cardEl.dataset.streamHealthState = "checking"; + if (cardEl.dataset.streamHealthReason) { + delete cardEl.dataset.streamHealthReason; + } + } else { + cardEl.dataset.streamHealthState = "unhealthy"; + cardEl.dataset.streamHealthReason = magnetProvided + ? "unsupported" + : "missing-source"; + } + + 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; + } + + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + if (trimmedUrl) { + badgeEl.dataset.urlHealthEventId = video.id || ""; + badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); + } else { + if (badgeEl.dataset.urlHealthEventId) { + delete badgeEl.dataset.urlHealthEventId; + } + if (badgeEl.dataset.urlHealthUrl) { + delete badgeEl.dataset.urlHealthUrl; + } + } + } + } + fragment.appendChild(cardEl); + }); + + container.innerHTML = ""; + container.appendChild(fragment); + attachHealthBadges(container); + attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { + if (!window.app?.handleUrlHealthBadge) { + return; + } + const video = window.app.videosMap?.get?.(eventId) || { id: eventId }; + window.app.handleUrlHealthBadge({ video, url, badgeEl }); + }); + + window.app?.attachVideoListHandler?.(); + + 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"); + + authorPics.forEach((pic) => { + localAuthorSet.add(pic.getAttribute("data-pubkey")); + }); + authorNames.forEach((nameEl) => { + localAuthorSet.add(nameEl.getAttribute("data-pubkey")); + }); + + if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { + window.app.batchFetchProfiles(localAuthorSet); + } + + authorPics.forEach((pic) => { + pic.style.cursor = "pointer"; + pic.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const pubkey = pic.getAttribute("data-pubkey"); + window.app?.goToProfile(pubkey); + }); + }); + + authorNames.forEach((nameEl) => { + nameEl.style.cursor = "pointer"; + nameEl.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const pubkey = nameEl.getAttribute("data-pubkey"); + window.app?.goToProfile(pubkey); + }); + }); +} + export function createWatchHistoryRenderer(config = {}) { const fallbackGetActor = async () => { const candidate = @@ -268,10 +730,8 @@ export function createWatchHistoryRenderer(config = {}) { if (typeof renderGrid === "function") { renderGrid(state.resolvedVideos, grid); - } else if (grid.id) { - subscriptions.renderSameGridStyle(state.resolvedVideos, grid.id); } else { - subscriptions.renderSameGridStyle(state.resolvedVideos, "watchHistoryGrid"); + renderWatchHistoryGrid(state.resolvedVideos, grid); } grid.classList.remove("hidden"); From 034c4c3761b7614dde2bfc663ad03d60bb69528c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:34:41 -0400 Subject: [PATCH 16/31] Chunk watch history payloads and rebuild fetch pipeline --- config/instance-config.js | 20 ++ js/config.js | 4 + js/nostr.js | 648 ++++++++++++++++++++++++++++------- tests/watch-history.test.mjs | 131 ++++--- 4 files changed, 636 insertions(+), 167 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 2ef81c37..2586c026 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -83,3 +83,23 @@ export const WATCH_HISTORY_MAX_ITEMS = 1500; * bursty loads can disable batching at the cost of additional round trips. */ export const WATCH_HISTORY_BATCH_RESOLVE = true; + +/** + * Maximum size of the JSON payload for each watch-history chunk, measured + * before encryption. + * + * BitVid splits large histories across multiple events to stay within relay + * limits. This cap keeps individual chunks under roughly 60 KB so that + * encrypted payloads remain relay-friendly even after base64 expansion. + * Lower the number if your relays enforce tighter limits. + */ +export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000; + +/** + * Number of watch-history events to request when syncing from relays. + * + * Chunked histories can span several events. This fetch limit should exceed + * the maximum number of chunks you expect a client to publish in one snapshot + * so the UI can stitch the full list back together. + */ +export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; diff --git a/js/config.js b/js/config.js index fe95d495..460f5adb 100644 --- a/js/config.js +++ b/js/config.js @@ -8,6 +8,8 @@ import { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_FETCH_EVENT_LIMIT, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -55,3 +57,5 @@ export { WATCH_HISTORY_KIND }; export { WATCH_HISTORY_LIST_IDENTIFIER }; export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; +export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; +export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; diff --git a/js/nostr.js b/js/nostr.js index 197d4e97..08647f28 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -6,6 +6,8 @@ import { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_FETCH_EVENT_LIMIT, } from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -239,26 +241,163 @@ function normalizePointersFromPayload(payload) { function parseWatchHistoryPayload(plaintext) { if (typeof plaintext !== "string") { - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } try { const parsed = JSON.parse(plaintext); if (!parsed || typeof parsed !== "object") { - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } const version = Number.isFinite(parsed.version) ? parsed.version : 0; const items = normalizePointersFromPayload(parsed); - return { version, items }; + const snapshot = + typeof parsed.snapshot === "string" ? parsed.snapshot : ""; + const chunkIndex = Number.isFinite(parsed.chunkIndex) + ? Math.max(0, Math.floor(parsed.chunkIndex)) + : 0; + const totalChunks = Number.isFinite(parsed.totalChunks) + ? Math.max(1, Math.floor(parsed.totalChunks)) + : 1; + return { version, items, snapshot, chunkIndex, totalChunks }; } catch (error) { if (isDevMode) { console.warn("[nostr] Failed to parse watch history payload:", error); } - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } } +function chunkWatchHistoryPayloadItems(payloadItems, snapshotId, maxBytes) { + const items = Array.isArray(payloadItems) ? payloadItems : []; + const safeMax = Math.max(128, Math.floor(maxBytes || 0)); + const measurementLimit = Math.max(64, safeMax - 32); + const normalizedSnapshot = + typeof snapshotId === "string" ? snapshotId : ""; + + const chunks = []; + const skipped = []; + let current = []; + + const estimateLength = (chunkItems, chunkIndex, totalGuess) => + JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex, + totalChunks: totalGuess, + items: chunkItems, + }).length; + + for (const item of items) { + if (!item || typeof item !== "object") { + continue; + } + + if (!current.length) { + const candidate = [item]; + const size = estimateLength(candidate, chunks.length, chunks.length + 1); + if (size <= measurementLimit) { + current = candidate; + } else { + skipped.push(item); + } + continue; + } + + const chunkIndex = chunks.length; + const candidate = [...current, item]; + const size = estimateLength(candidate, chunkIndex, chunkIndex + 1); + if (size <= measurementLimit) { + current = candidate; + continue; + } + + chunks.push(current); + current = []; + + const nextIndex = chunks.length; + const soloSize = estimateLength([item], nextIndex, nextIndex + 1); + if (soloSize <= measurementLimit) { + current = [item]; + } else { + skipped.push(item); + } + } + + if (current.length || chunks.length === 0) { + chunks.push(current); + } + + let needsRebalance = true; + while (needsRebalance) { + needsRebalance = false; + for (let index = 0; index < chunks.length; index++) { + let chunkItems = chunks[index]; + if (!Array.isArray(chunkItems)) { + chunks[index] = []; + chunkItems = chunks[index]; + } + + let payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + + while (chunkItems.length && payloadSize > safeMax) { + const overflow = chunkItems.pop(); + if (!overflow) { + break; + } + if (!chunks[index + 1]) { + chunks[index + 1] = []; + } + chunks[index + 1].unshift(overflow); + needsRebalance = true; + chunkItems = chunks[index]; + payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + } + } + } + + while (chunks.length > 1 && chunks[chunks.length - 1].length === 0) { + chunks.pop(); + } + + if (!chunks.length) { + chunks.push([]); + } + + return { chunks, skipped }; +} + function eventToAddressPointer(event) { if (!event || typeof event !== "object") { return ""; @@ -1026,13 +1165,28 @@ class NostrClient { ? normalizedPayload.items : []; + const version = Number.isFinite(normalizedPayload.version) + ? normalizedPayload.version + : 1; + const prepared = { - version: Number.isFinite(normalizedPayload.version) - ? normalizedPayload.version - : 1, + 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 = ""; @@ -1101,7 +1255,13 @@ class NostrClient { async decryptWatchHistoryEvent(pointerEvent, actorPubkey) { const fallbackItems = extractPointerItemsFromEvent(pointerEvent); - const fallbackPayload = { version: 0, items: fallbackItems }; + const fallbackPayload = { + version: 0, + items: fallbackItems, + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim() @@ -1295,61 +1455,49 @@ class NostrClient { relay: item.relay || null, })); - const encryptionResult = await this.encryptWatchHistoryPayload( - normalizedActor, - { version: 1, items: payloadItems } - ); - if (!encryptionResult.ok) { - if (isDevMode) { - console.warn( - "[nostr] Unable to encrypt watch history snapshot:", - encryptionResult.error - ); - } - return encryptionResult; - } + 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 pointerTags = trimmedItems.map((item) => { - const tag = [item.type, item.value]; - if (item.relay) { - tag.push(item.relay); - } - return tag; + 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 tags = [ - ["d", WATCH_HISTORY_LIST_IDENTIFIER], - ["encrypted", "nip04"], - ...pointerTags, - ]; + if (chunkInfo.skipped?.length && isDevMode) { + console.warn( + `[nostr] Skipped ${chunkInfo.skipped.length} oversized watch history entries while chunking.` + ); + } - const event = { - kind: WATCH_HISTORY_KIND, - pubkey: normalizedActor, - created_at: Math.floor(Date.now() / 1000), - tags, - content: encryptionResult.ciphertext, - }; + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; - let signedEvent; const normalizedLogged = typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - - if ( - normalizedActor.toLowerCase() === normalizedLogged && + const normalizedActorLower = normalizedActor.toLowerCase(); + const useExtension = + normalizedActorLower === normalizedLogged && window?.nostr && - typeof window.nostr.signEvent === "function" - ) { - try { - signedEvent = await window.nostr.signEvent(event); - } catch (error) { - console.warn( - "[nostr] Failed to sign watch history list with extension:", - error - ); - return { ok: false, error: "signing-failed", details: error }; - } - } else { + typeof window.nostr.signEvent === "function"; + + if (!useExtension) { try { if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { await this.ensureSessionActor(true); @@ -1357,46 +1505,134 @@ class NostrClient { if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { throw new Error("session-actor-mismatch"); } - signedEvent = signEventWithPrivateKey( - event, - this.sessionActor.privateKey - ); } catch (error) { console.warn( - "[nostr] Failed to sign watch history list with session key:", + "[nostr] Failed to prepare signing key for watch history chunks:", error ); return { ok: false, error: "signing-failed", details: error }; } } - const relays = - Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + const totalChunks = chunkItemsList.length || 1; + const baseTimestamp = Math.floor(Date.now() / 1000); + const chunkResults = []; + let overallSuccess = true; - const publishResults = await Promise.all( - relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) - ); + for (let index = 0; index < totalChunks; index++) { + const chunkItems = Array.isArray(chunkItemsList[index]) + ? chunkItemsList[index] + : []; - const success = publishResults.some((result) => result.success); - if (!success) { - console.warn( - "[nostr] Failed to publish watch history list:", - publishResults + 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 tags = [ + ["d", WATCH_HISTORY_LIST_IDENTIFIER], + ["encrypted", "nip04"], + ["snapshot", snapshotId], + ["chunk", String(index), String(totalChunks)], + ...pointerTags, + ]; + if (index === 0) { + tags.splice(2, 0, ["head", "1"]); + } + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: normalizedActor, + created_at: baseTimestamp + (totalChunks - index - 1), + tags, + 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 }; + } + } + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const chunkSuccess = publishResults.some((result) => result.success); + if (!chunkSuccess) { + overallSuccess = false; + console.warn( + "[nostr] Failed to publish watch history chunk:", + publishResults + ); + } + + chunkResults.push({ + chunkIndex: index, + event: signedEvent, + results: publishResults, + success: chunkSuccess, + }); } - if (success) { + if (overallSuccess) { this.cancelWatchHistoryRepublish(normalizedActor); - } else if (allowRetry) { - this.scheduleWatchHistoryRepublish(normalizedActor, trimmedItems); + } else if (allowRetry && persistedItems.length) { + this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } const baselineEntry = existingEntry || this.watchHistoryCache.get(normalizedActor) || null; + const headEvent = chunkResults[0]?.event || null; const newEntry = this.createWatchHistoryEntry( - signedEvent, - trimmedItems, + headEvent, + persistedItems, Date.now(), baselineEntry ); @@ -1405,8 +1641,12 @@ class NostrClient { this.persistWatchHistoryEntry(normalizedActor, newEntry); return { - ok: success, - event: signedEvent, + ok: overallSuccess, + event: headEvent, + events: chunkResults.map((chunk) => chunk.event), + chunks: chunkResults, + results: chunkResults.map((chunk) => chunk.results), + snapshot: snapshotId, items: newEntry.items .map((item) => clonePointerItem(item)) .filter(Boolean), @@ -1750,44 +1990,41 @@ class NostrClient { 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 filter = { kinds: [WATCH_HISTORY_KIND], authors: [actor], "#d": [WATCH_HISTORY_LIST_IDENTIFIER], - limit: 1, + limit: fetchLimit, }; - let pointerEvent = null; + let fetchedEvents = []; try { - const perRelay = await Promise.all( - relayList.map(async (url) => { - try { - const evt = await this.pool.get([url], filter); - return evt || null; - } catch (err) { - if (isDevMode) { - console.warn( - `[nostr] Failed to fetch watch history from ${url}:`, - err - ); - } - return null; + const events = await this.pool.list(relayList, [filter]); + if (Array.isArray(events)) { + const dedupe = new Map(); + for (const evt of events) { + if (!evt || typeof evt !== "object") { + continue; } - }) - ); - - const events = perRelay.filter(Boolean); - if (events.length) { - pointerEvent = events.reduce((latest, candidate) => { - if (!latest) { - return candidate; + const id = typeof evt.id === "string" ? evt.id : null; + if (!id) { + continue; } - const latestCreated = - typeof latest.created_at === "number" ? latest.created_at : 0; - const candidateCreated = - typeof candidate.created_at === "number" ? candidate.created_at : 0; - return candidateCreated > latestCreated ? candidate : latest; - }, null); + const existing = dedupe.get(id); + if ( + !existing || + (typeof evt.created_at === "number" ? evt.created_at : 0) > + (typeof existing.created_at === "number" ? existing.created_at : 0) + ) { + dedupe.set(id, evt); + } + } + fetchedEvents = Array.from(dedupe.values()); } } catch (error) { if (isDevMode) { @@ -1795,32 +2032,187 @@ class NostrClient { } } + 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; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + 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, + }; + + bucket.chunks.set(chunkIndex, { + event, + payload: { + ...payload, + snapshot: snapshotId, + chunkIndex, + totalChunks, + }, + created_at: typeof event.created_at === "number" ? event.created_at : 0, + }); + 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); + 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 = 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), + }; + + if (!fallbackSnapshot) { + fallbackSnapshot = snapshot; + } + + if (snapshot.chunkCount >= snapshot.expectedChunks) { + selectedSnapshot = snapshot; + break; + } + + if (!selectedSnapshot) { + selectedSnapshot = snapshot; + } + } + + const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null; + const previousEntry = this.watchHistoryCache.get(actor) || null; - const decryptedPayload = pointerEvent - ? await this.decryptWatchHistoryEvent(pointerEvent, actor) - : { version: 0, items: [] }; - const remoteItems = Array.isArray(decryptedPayload.items) - ? decryptedPayload.items - : []; - - let items = remoteItems; - let pointerForEntry = pointerEvent; - let usedFallback = false; - const storedItems = Array.isArray(stored?.items) ? stored.items : []; const storedPointerEvent = stored?.pointerEvent || null; - if (!pointerEvent && previousEntry?.pointerEvent) { - pointerForEntry = previousEntry.pointerEvent; - } else if (!pointerEvent && storedPointerEvent) { - pointerForEntry = storedPointerEvent; + const remoteItems = Array.isArray(effectiveSnapshot?.items) + ? effectiveSnapshot.items + : []; + + let pointerForEntry = effectiveSnapshot?.pointerEvent || null; + if (!pointerForEntry) { + if (previousEntry?.pointerEvent) { + pointerForEntry = previousEntry.pointerEvent; + } else if (storedPointerEvent) { + pointerForEntry = storedPointerEvent; + } } - const shouldFallback = - (!pointerEvent && !items.length) || - (pointerEvent && decryptedPayload.version === 0 && !items.length); + let items = remoteItems; + let usedFallback = false; + const remoteVersion = effectiveSnapshot?.version ?? 0; - if (shouldFallback) { + if (!effectiveSnapshot || (remoteVersion === 0 && !remoteItems.length)) { if (previousEntry?.items?.length) { items = previousEntry.items; usedFallback = true; @@ -1840,7 +2232,11 @@ class NostrClient { this.watchHistoryCache.set(actor, entry); this.persistWatchHistoryEntry(actor, entry); - if (!pointerEvent && usedFallback && entry.items.length) { + if ( + (!effectiveSnapshot || !effectiveSnapshot.pointerEvent) && + usedFallback && + entry.items.length + ) { this.scheduleWatchHistoryRepublish(actor, entry.items); } diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2bff7e48..0d4dadf0 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -24,8 +24,11 @@ const { nostrClient } = await import("../js/nostr.js"); function createPublishingClient(actorPubkey) { const client = new nostrClient.constructor(); + const publishedEvents = []; + const payloads = []; client.pool = { publish(urls, event) { + publishedEvents.push(event); return { on(eventName, handler) { if (eventName === "ok" || eventName === "seen") { @@ -39,14 +42,14 @@ function createPublishingClient(actorPubkey) { client.pubkey = ""; client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; client.ensureSessionActor = async () => actorPubkey; - client.encryptWatchHistoryPayload = async () => ({ - ok: true, - ciphertext: "ciphertext", - }); + client.encryptWatchHistoryPayload = async (_actor, payload) => { + payloads.push(payload); + return { ok: true, ciphertext: JSON.stringify(payload) }; + }; client.persistWatchHistoryEntry = () => {}; client.cancelWatchHistoryRepublish = () => {}; client.scheduleWatchHistoryRepublish = () => {}; - return client; + return { client, publishedEvents, payloads }; } function createDecryptClient(actorPubkey) { @@ -57,56 +60,102 @@ function createDecryptClient(actorPubkey) { } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const POINTER_A = { - type: "a", - value: `30078:${ACTOR}:watch-history`, - relay: "wss://relay.unit", -}; -const POINTER_E = { type: "e", value: "event-id-123", relay: null }; -const publishingClient = createPublishingClient(ACTOR); +const { client: publishingClient, publishedEvents, payloads } = + createPublishingClient(ACTOR); + +publishingClient.watchHistoryPayloadMaxBytes = 400; +publishingClient.watchHistoryFetchEventLimit = 10; + +const longPointers = Array.from({ length: 8 }, (_, index) => { + const relay = index % 3 === 0 ? "wss://relay.unit" : null; + if (index % 2 === 0) { + return { + type: "e", + value: `event-${index}-${"x".repeat(90)}`, + relay, + }; + } + return { + type: "a", + value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`, + relay, + }; +}); + const publishResult = await publishingClient.publishWatchHistorySnapshot( ACTOR, - [POINTER_A, POINTER_E] + longPointers ); assert.equal(publishResult.ok, true, "publish should report success"); +assert(publishResult.events.length > 1, "snapshot should chunk into multiple events"); +assert.equal( + publishResult.events.length, + payloads.length, + "each chunk should produce an encryption payload" +); -const pointerTags = publishResult.event.tags.filter((tag) => - tag && (tag[0] === "a" || tag[0] === "e") +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 chunks should share a snapshot id"); + +const headTag = publishResult.event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "head" +); +assert(headTag, "head chunk should include head tag"); + +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 = 10; +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(pointerTags, [ - ["a", POINTER_A.value, POINTER_A.relay], - ["e", POINTER_E.value], -]); assert.deepEqual( - publishResult.items.map((item) => ({ + fetched.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, })), - [ - { type: "a", value: POINTER_A.value, relay: POINTER_A.relay }, - { type: "e", value: POINTER_E.value, relay: null }, - ] + longPointers.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + "chunked fetch should preserve pointer order" ); -delete globalThis.window.nostr; -delete globalThis.window.NostrTools.nip04; - -const decryptClient = createDecryptClient(ACTOR); -const decrypted = await decryptClient.decryptWatchHistoryEvent( - publishResult.event, - ACTOR -); - -assert.deepEqual(decrypted, { - version: 0, - items: [ - { type: "a", value: POINTER_A.value, relay: POINTER_A.relay }, - { type: "e", value: POINTER_E.value, relay: null }, - ], -}); - console.log("watch history tests passed"); From 1bcb770556cd2499507e940d647746b83f911d2f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:42:21 -0400 Subject: [PATCH 17/31] Extend watch history cache TTL and add tests --- config/instance-config.js | 10 ++++ js/config.js | 2 + js/nostr.js | 103 ++++++++++++++++++++--------------- tests/watch-history.test.mjs | 73 +++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 43 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 2586c026..4c45d063 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -103,3 +103,13 @@ export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000; * so the UI can stitch the full list back together. */ export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; + +/** + * How long clients should cache watch-history snapshots in localStorage. + * + * A 24-hour window keeps recently played videos available across reloads + * without requiring a fresh relay sync on every visit. Increase the value for + * longer-lived caches or decrease it if your deployment needs tighter + * retention guarantees. + */ +export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/js/config.js b/js/config.js index 460f5adb..705f48f7 100644 --- a/js/config.js +++ b/js/config.js @@ -10,6 +10,7 @@ import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, + WATCH_HISTORY_CACHE_TTL_MS, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -59,3 +60,4 @@ export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; +export { WATCH_HISTORY_CACHE_TTL_MS }; diff --git a/js/nostr.js b/js/nostr.js index 08647f28..2f944d75 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -8,6 +8,7 @@ import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, + WATCH_HISTORY_CACHE_TTL_MS, } from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -32,7 +33,6 @@ const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1"; -const WATCH_HISTORY_CACHE_TTL_MS = EVENTS_CACHE_TTL_MS; // To limit error spam let errorLogCount = 0; @@ -861,6 +861,7 @@ class NostrClient { this.watchHistoryCache = new Map(); this.watchHistoryStorage = null; this.watchHistoryRepublishTimers = new Map(); + this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; } restoreLocalData() { @@ -962,60 +963,72 @@ class NostrClient { return this.allEvents.size > 0; } + /** + * Resolve the effective TTL for watch-history cache entries. + */ + getWatchHistoryCacheTtlMs() { + const candidate = this.watchHistoryCacheTtlMs; + if (Number.isFinite(candidate) && candidate >= 0) { + return candidate; + } + return WATCH_HISTORY_CACHE_TTL_MS; + } + /** * Load watch history cache payload from localStorage. */ getWatchHistoryStorage() { - if (this.watchHistoryStorage) { - return this.watchHistoryStorage; - } - const base = { version: 1, actors: {} }; - if (typeof localStorage === "undefined") { - this.watchHistoryStorage = base; - return this.watchHistoryStorage; - } + 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 }; + 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); } } - } 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; - for (const [actor, info] of Object.entries(parsed.actors || {})) { + 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 > WATCH_HISTORY_CACHE_TTL_MS + now - info.savedAt > ttl ) { - delete parsed.actors[actor]; + delete actors[actor]; mutated = true; } } - const actors = { ...(parsed.actors || {}) }; - this.watchHistoryStorage = { version: 1, actors }; - - if (mutated) { + if (mutated && typeof localStorage !== "undefined") { try { localStorage.setItem( WATCH_HISTORY_CACHE_STORAGE_KEY, @@ -1936,16 +1949,20 @@ class NostrClient { } const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); const cached = this.watchHistoryCache.get(actor); - if (cached && now - cached.savedAt < WATCH_HISTORY_CACHE_TTL_MS) { - return { - pointerEvent: cached.pointerEvent - ? cloneEventForCache(cached.pointerEvent) - : null, - items: cached.items - .map((item) => clonePointerItem(item)) - .filter(Boolean), - }; + if (cached) { + if (now - cached.savedAt < ttl) { + return { + pointerEvent: cached.pointerEvent + ? cloneEventForCache(cached.pointerEvent) + : null, + items: cached.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + this.watchHistoryCache.delete(actor); } let storage = null; @@ -1956,7 +1973,7 @@ class NostrClient { if ( stored && typeof stored.savedAt === "number" && - now - stored.savedAt < WATCH_HISTORY_CACHE_TTL_MS + now - stored.savedAt < ttl ) { const entry = this.createWatchHistoryEntry( stored.pointerEvent, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 0d4dadf0..338c39f5 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -158,4 +158,77 @@ assert.deepEqual( "chunked fetch should preserve pointer order" ); +localStorage.clear(); + +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; +} + console.log("watch history tests passed"); From 32f04b94cb5befb32753c251fc82cedbfd252b61 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:01:55 -0400 Subject: [PATCH 18/31] Ensure watch history snapshots advance created_at --- js/nostr.js | 30 +++++++++++++++- tests/watch-history.test.mjs | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/js/nostr.js b/js/nostr.js index 2f944d75..cf8a49d4 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1528,7 +1528,33 @@ class NostrClient { } const totalChunks = chunkItemsList.length || 1; - const baseTimestamp = Math.floor(Date.now() / 1000); + + 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 chunkResults = []; let overallSuccess = true; @@ -1578,6 +1604,8 @@ class NostrClient { tags.splice(2, 0, ["head", "1"]); } + // 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 = { kind: WATCH_HISTORY_KIND, pubkey: normalizedActor, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 338c39f5..9c6536dd 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -158,6 +158,74 @@ assert.deepEqual( "chunked fetch should preserve pointer order" ); +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 originalDateNow = Date.now; From 0f98e597edeb1c711669dbbac7815ca6377ff5af Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:21:54 -0400 Subject: [PATCH 19/31] Improve history view loading status messaging --- js/historyView.js | 64 ++++++++++++++++++++++++++++++++++++++-------- views/history.html | 15 +++++++++-- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 6fd013d7..6484fb30 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -12,6 +12,9 @@ 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."; + function toNumber(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; @@ -490,6 +493,7 @@ export function createWatchHistoryRenderer(config = {}) { viewSelector: "#watchHistoryView", gridSelector: "#watchHistoryGrid", loadingSelector: "#watchHistoryLoading", + statusSelector: "#watchHistoryStatus", emptySelector: "#watchHistoryEmpty", sentinelSelector: "#watchHistorySentinel", scrollContainerSelector: null, @@ -507,6 +511,7 @@ export function createWatchHistoryRenderer(config = {}) { viewSelector, gridSelector, loadingSelector, + statusSelector, emptySelector, sentinelSelector, scrollContainerSelector, @@ -524,6 +529,7 @@ export function createWatchHistoryRenderer(config = {}) { view: viewSelector, grid: gridSelector, loading: loadingSelector, + status: statusSelector, empty: emptySelector, sentinel: sentinelSelector, scrollContainer: scrollContainerSelector, @@ -630,36 +636,69 @@ export function createWatchHistoryRenderer(config = {}) { 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 setLoadingVisible = (visible) => { - const { loadingEl } = getElements(); - if (!loadingEl) { + const setStatusMessage = (message = "") => { + const { statusEl } = getElements(); + if (!statusEl) { return; } - if (visible) { - loadingEl.classList.remove("hidden"); + + const nextMessage = typeof message === "string" ? message.trim() : ""; + if (nextMessage) { + statusEl.textContent = nextMessage; + statusEl.classList.remove("hidden"); } else { - loadingEl.classList.add("hidden"); + statusEl.textContent = ""; + 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, loadingEl, emptyEl } = getElements(); + const { grid, emptyEl } = getElements(); if (grid) { grid.innerHTML = ""; grid.classList.add("hidden"); } - if (loadingEl) { - loadingEl.classList.remove("hidden"); - } if (emptyEl) { emptyEl.textContent = state.emptyCopy; emptyEl.classList.add("hidden"); } + setLoadingVisible(true); }; const cleanupObservers = () => { @@ -688,7 +727,9 @@ export function createWatchHistoryRenderer(config = {}) { emptyEl.textContent = message; emptyEl.classList.remove("hidden"); } - setLoadingVisible(false); + const shouldShowEmptyStatus = message === state.emptyCopy; + const statusMessage = shouldShowEmptyStatus ? WATCH_HISTORY_EMPTY_STATUS : ""; + setLoadingVisible(false, { statusMessage }); state.hasMore = false; cleanupObservers(); debugLog("showing empty state", { message }); @@ -753,6 +794,7 @@ export function createWatchHistoryRenderer(config = {}) { return; } + setLoadingVisible(true); state.isLoading = true; debugLog("loading next batch", { initial, diff --git a/views/history.html b/views/history.html index 795e5ae5..f5d08587 100644 --- a/views/history.html +++ b/views/history.html @@ -3,8 +3,19 @@

Watch History

-
-
+
+ +
-
+
+
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 = ` -
+
${moreMenu}${gearMenu}
`; @@ -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 = ` -
- -
- ${safeTitle} -
-
-
-

+

-
-
-
- Placeholder -
-
-

- Loading name... -

-
- ${timeAgo} -
-
+
+ ${safeTitle}
+
+
+

+ ${safeTitle} +

+

${timeAgo}

+
+
+
+

${watchedAtLabel}

+
+ + +
+
+ + View + + + ${cardControls}
- ${connectionBadgesHtml}
-
+ `; const t = document.createElement("template"); @@ -303,35 +341,6 @@ function renderWatchHistoryGrid(videos, containerOrElement) { delete cardEl.dataset.ownerPubkey; } - if (trimmedUrl) { - cardEl.dataset.urlHealthState = "checking"; - if (cardEl.dataset.urlHealthReason) { - delete cardEl.dataset.urlHealthReason; - } - cardEl.dataset.urlHealthEventId = video.id || ""; - cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); - } else { - cardEl.dataset.urlHealthState = "offline"; - cardEl.dataset.urlHealthReason = "missing-source"; - if (cardEl.dataset.urlHealthEventId) { - delete cardEl.dataset.urlHealthEventId; - } - if (cardEl.dataset.urlHealthUrl) { - delete cardEl.dataset.urlHealthUrl; - } - } - if (magnetProvided && magnetSupported) { - cardEl.dataset.streamHealthState = "checking"; - if (cardEl.dataset.streamHealthReason) { - delete cardEl.dataset.streamHealthReason; - } - } else { - cardEl.dataset.streamHealthState = "unhealthy"; - cardEl.dataset.streamHealthReason = magnetProvided - ? "unsupported" - : "missing-source"; - } - const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); interactiveEls.forEach((el) => { if (!el.dataset) return; @@ -351,34 +360,12 @@ function renderWatchHistoryGrid(videos, containerOrElement) { delete cardEl.dataset.magnet; } - const badgeEl = cardEl.querySelector("[data-url-health-state]"); - if (badgeEl) { - if (trimmedUrl) { - badgeEl.dataset.urlHealthEventId = video.id || ""; - badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); - } else { - if (badgeEl.dataset.urlHealthEventId) { - delete badgeEl.dataset.urlHealthEventId; - } - if (badgeEl.dataset.urlHealthUrl) { - delete badgeEl.dataset.urlHealthUrl; - } - } - } } fragment.appendChild(cardEl); }); container.innerHTML = ""; container.appendChild(fragment); - attachHealthBadges(container); - attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { - if (!window.app?.handleUrlHealthBadge) { - return; - } - const video = window.app.videosMap?.get?.(eventId) || { id: eventId }; - window.app.handleUrlHealthBadge({ video, url, badgeEl }); - }); window.app?.attachVideoListHandler?.(); @@ -447,6 +434,9 @@ function renderWatchHistoryGrid(videos, containerOrElement) { 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")); @@ -454,29 +444,50 @@ function renderWatchHistoryGrid(videos, containerOrElement) { 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) => { - pic.style.cursor = "pointer"; - pic.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = pic.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); - }); + 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) => { - nameEl.style.cursor = "pointer"; - nameEl.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = nameEl.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); - }); + 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); + }); + } }); } @@ -618,6 +629,7 @@ export function createWatchHistoryRenderer(config = {}) { batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), actor: null, snapshotFingerprint: null, + gridEventHandler: null, }; const query = (selector) => { @@ -717,6 +729,17 @@ export function createWatchHistoryRenderer(config = {}) { } }; + 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) { @@ -754,6 +777,130 @@ export function createWatchHistoryRenderer(config = {}) { state.resolvedVideos = Array.from(dedupeMap.values()); }; + const ensureGridEventHandlers = () => { + const { grid } = getElements(); + if (!grid || state.gridEventHandler) { + return; + } + + 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"); + + 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; + }); + + setLoadingVisible(false); + renderResolvedVideos(); + 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 { + 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) { @@ -775,6 +922,8 @@ export function createWatchHistoryRenderer(config = {}) { renderWatchHistoryGrid(state.resolvedVideos, grid); } + ensureGridEventHandlers(); + grid.classList.remove("hidden"); if (emptyEl) { emptyEl.classList.add("hidden"); @@ -1097,6 +1246,7 @@ export function createWatchHistoryRenderer(config = {}) { destroy() { debugLog("destroy called"); cleanupObservers(); + cleanupGridHandler(); state.initialized = false; state.resolvedVideos = []; state.hasMore = true; From 6b6c97a52d0b4adbc691bc2ad035a88fd0cb6437 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:19:18 -0400 Subject: [PATCH 28/31] Filter watch history by blocklists --- js/historyView.js | 223 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 199 insertions(+), 24 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index a55f3a73..4c6f5af7 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -3,6 +3,7 @@ import { nostrClient } from "./nostr.js"; import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; import { subscriptions } from "./subscriptions.js"; +import { accessControl } from "./accessControl.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; @@ -18,6 +19,123 @@ function toNumber(value, fallback) { return Number.isFinite(parsed) ? parsed : fallback; } +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 getWatchedAtScore(video) { + if (!video || typeof video !== "object") { + return Number.NEGATIVE_INFINITY; + } + + const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); + if (Number.isFinite(watchedAt)) { + return watchedAt; + } + + const createdAtSeconds = toNumber(video?.created_at, Number.NaN); + if (Number.isFinite(createdAtSeconds)) { + return createdAtSeconds * 1000; + } + + return Number.NEGATIVE_INFINITY; +} + function getAbsoluteShareUrl(nevent) { if (!nevent) { return ""; @@ -62,17 +180,7 @@ function renderWatchHistoryGrid(videos, containerOrElement) { window.app?.dedupeVideosByRoot?.(safeVideos) ?? subscriptions.dedupeToNewestByRoot(safeVideos); - const filteredVideos = dedupedVideos.filter((video) => { - if (!video || typeof video !== "object") { - return false; - } - - if (window.app?.isAuthorBlocked && window.app.isAuthorBlocked(video.pubkey)) { - return false; - } - - return true; - }); + const filteredVideos = filterAccessibleVideos(dedupedVideos); if (!filteredVideos.length) { container.innerHTML = ` @@ -632,6 +740,26 @@ export function createWatchHistoryRenderer(config = {}) { 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; + }; + const query = (selector) => { if (!selector) { return null; @@ -759,22 +887,56 @@ export function createWatchHistoryRenderer(config = {}) { }; 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) => { - if (video && video.id) { - dedupeMap.set(video.id, video); - } - }); - nextVideos.forEach((video) => { - if (video && video.id) { - dedupeMap.set(video.id, video); - } - }); + state.resolvedVideos.forEach((video) => ingest(dedupeMap, video)); + nextVideos.forEach((video) => ingest(dedupeMap, video)); + state.resolvedVideos = Array.from(dedupeMap.values()); + applyAccessFilters(); }; const ensureGridEventHandlers = () => { @@ -831,6 +993,8 @@ export function createWatchHistoryRenderer(config = {}) { return true; }); + applyAccessFilters(); + setLoadingVisible(false); renderResolvedVideos(); window.app?.showSuccess?.("Removed from watch history."); @@ -907,7 +1071,9 @@ export function createWatchHistoryRenderer(config = {}) { return; } - if (!state.resolvedVideos.length) { + const accessibleVideos = applyAccessFilters(); + + if (!accessibleVideos.length) { if (emptyEl) { emptyEl.textContent = state.emptyCopy; emptyEl.classList.remove("hidden"); @@ -917,9 +1083,9 @@ export function createWatchHistoryRenderer(config = {}) { } if (typeof renderGrid === "function") { - renderGrid(state.resolvedVideos, grid); + renderGrid(accessibleVideos, grid); } else { - renderWatchHistoryGrid(state.resolvedVideos, grid); + renderWatchHistoryGrid(accessibleVideos, grid); } ensureGridEventHandlers(); @@ -1124,6 +1290,15 @@ export function createWatchHistoryRenderer(config = {}) { state.snapshotFingerprint = null; } + 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) { From 177cd191e3cf194a70d3ae65a4d17ed1884f76d4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:29:23 -0400 Subject: [PATCH 29/31] Simplify watch history controls and add loading spinners --- css/style.css | 22 +++-- js/historyView.js | 215 ++++++++++++---------------------------------- 2 files changed, 69 insertions(+), 168 deletions(-) diff --git a/css/style.css b/css/style.css index cdabeb25..65cdbe6c 100644 --- a/css/style.css +++ b/css/style.css @@ -360,12 +360,6 @@ header img { 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; @@ -1129,6 +1123,22 @@ button:not(.icon-button):focus { display: flex; } +.watch-history-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.status-spinner--inline { + animation: spin 1s linear infinite; + border: 0.125rem solid rgba(96, 165, 250, 0.35); + border-radius: 9999px; + border-top-color: #60a5fa; + display: inline-block; + height: 1rem; + width: 1rem; +} + .status-banner { align-items: center; background-color: rgba(31, 41, 55, 0.9); diff --git a/js/historyView.js b/js/historyView.js index 4c6f5af7..13fa4ff5 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -234,105 +234,6 @@ function renderWatchHistoryGrid(videos, containerOrElement) { } } - let hasOlder = false; - if (canEdit && video.videoRootId && window.app?.hasOlderVersion) { - hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray); - } - - const revertButton = hasOlder - ? ` - - ` - : ""; - - const gearMenu = canEdit - ? ` -
- - -
- ` - : ""; - - const moreMenu = ` -
- - -
- `; - - const cardControls = ` -
- ${moreMenu}${gearMenu} -
- `; - const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; const playbackUrl = typeof video.url === "string" ? video.url : ""; @@ -432,7 +333,6 @@ function renderWatchHistoryGrid(videos, containerOrElement) { > Remove - ${cardControls}
@@ -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