From 8a6e169471cf90ef23820004a0f23160e3a35c77 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:11:43 -0400 Subject: [PATCH 1/5] Fix watch history ensureInitialLoad reset --- js/historyView.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/js/historyView.js b/js/historyView.js index ffbad480..1b344cbc 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -305,7 +305,14 @@ export function createWatchHistoryRenderer(config = {}) { } if (!state.resolvedVideos.length && !state.isLoading) { + if (!state.hasMore) { + state.hasMore = true; + } await loadNextBatch({ initial: true }); + + if (state.hasMore && !state.observer && !state.scrollListener) { + attachObservers(); + } } }, async loadMore() { From 789e0eff2614849fb4b6bfe1422473ad192c82fa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:24:23 -0400 Subject: [PATCH 2/5] Add detailed logging to watch history renderer --- js/historyView.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/js/historyView.js b/js/historyView.js index 1b344cbc..7917b948 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -42,6 +42,20 @@ export function createWatchHistoryRenderer(config = {}) { scrollContainer: scrollContainerSelector, }; + const LOG_PREFIX = "[historyView]"; + + const debugLog = (...args) => { + if (typeof console !== "undefined") { + if (typeof console.debug === "function") { + console.debug(LOG_PREFIX, ...args); + return; + } + if (typeof console.log === "function") { + console.log(LOG_PREFIX, ...args); + } + } + }; + const state = { isLoading: false, hasMore: true, @@ -103,10 +117,12 @@ export function createWatchHistoryRenderer(config = {}) { const cleanupObservers = () => { if (state.observer) { + debugLog("disconnecting intersection observer"); state.observer.disconnect(); state.observer = null; } if (state.scrollListener?.target && state.scrollListener?.handler) { + debugLog("removing scroll listener"); state.scrollListener.target.removeEventListener( "scroll", state.scrollListener.handler @@ -128,6 +144,7 @@ export function createWatchHistoryRenderer(config = {}) { setLoadingVisible(false); state.hasMore = false; cleanupObservers(); + debugLog("showing empty state", { message }); }; const mergeResolvedVideos = (nextVideos) => { @@ -180,6 +197,11 @@ export function createWatchHistoryRenderer(config = {}) { const loadNextBatch = async ({ initial = false } = {}) => { if (state.isLoading || !state.hasMore) { + debugLog("skipping loadNextBatch", { + reason: state.isLoading ? "already loading" : "no more items", + initial, + resolvedCount: state.resolvedVideos.length, + }); if (initial && !state.isLoading && state.resolvedVideos.length === 0) { setLoadingVisible(false); } @@ -187,9 +209,18 @@ export function createWatchHistoryRenderer(config = {}) { } state.isLoading = true; + debugLog("loading next batch", { + initial, + batchSize: state.batchSize, + resolvedCount: state.resolvedVideos.length, + }); try { const videos = await resolveBatch(state.batchSize); + debugLog("resolveBatch completed", { + initial, + received: Array.isArray(videos) ? videos.length : "non-array", + }); if (!Array.isArray(videos) || videos.length === 0) { if (initial && state.resolvedVideos.length === 0) { @@ -198,6 +229,10 @@ export function createWatchHistoryRenderer(config = {}) { state.hasMore = false; cleanupObservers(); setLoadingVisible(false); + debugLog("no videos returned", { + initial, + resolvedCount: state.resolvedVideos.length, + }); } return; } @@ -205,6 +240,10 @@ export function createWatchHistoryRenderer(config = {}) { mergeResolvedVideos(videos); renderResolvedVideos(); setLoadingVisible(false); + debugLog("rendered videos", { + totalResolved: state.resolvedVideos.length, + initial, + }); } catch (error) { console.error("[historyView] Failed to resolve watch history:", error); if (initial && state.resolvedVideos.length === 0) { @@ -214,20 +253,33 @@ export function createWatchHistoryRenderer(config = {}) { } } finally { state.isLoading = false; + debugLog("loadNextBatch finished", { + initial, + hasMore: state.hasMore, + resolvedCount: state.resolvedVideos.length, + }); } }; const attachObservers = () => { const { sentinel, scrollContainer } = getElements(); if (!sentinel || !state.hasMore) { + debugLog("skipping observer attachment", { + hasSentinel: Boolean(sentinel), + hasMore: state.hasMore, + }); return; } if ("IntersectionObserver" in window) { + debugLog("attaching intersection observer", { + useScrollContainer: Boolean(scrollContainer), + }); state.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { + debugLog("sentinel intersected"); loadNextBatch(); } }); @@ -248,6 +300,7 @@ export function createWatchHistoryRenderer(config = {}) { ? scrollContainer.clientHeight : window.innerHeight; if (rect.top <= threshold + 200) { + debugLog("scroll threshold met"); loadNextBatch(); } }; @@ -255,11 +308,15 @@ export function createWatchHistoryRenderer(config = {}) { target.addEventListener("scroll", handler, { passive: true }); state.scrollListener = { target, handler }; handler(); + debugLog("attached scroll listener", { + useScrollContainer: Boolean(scrollContainer), + }); }; const runInitialLoad = async () => { const { view } = getElements(); if (!view) { + debugLog("runInitialLoad aborted: view not found"); return; } @@ -269,6 +326,7 @@ export function createWatchHistoryRenderer(config = {}) { state.isLoading = false; state.initialized = false; resetUiState(); + debugLog("starting initial load"); try { await beforeInitialLoad?.(); @@ -287,47 +345,69 @@ export function createWatchHistoryRenderer(config = {}) { } state.initialized = true; + debugLog("initial load complete", { + hasMore: state.hasMore, + resolvedCount: state.resolvedVideos.length, + }); }; return { async init() { + debugLog("init called"); await runInitialLoad(); }, async ensureInitialLoad() { const { view } = getElements(); if (!view) { + debugLog("ensureInitialLoad aborted: view not found"); return; } if (!state.initialized) { + debugLog("ensureInitialLoad rerunning initial load (not initialized)"); await runInitialLoad(); return; } if (!state.resolvedVideos.length && !state.isLoading) { if (!state.hasMore) { + debugLog("ensureInitialLoad resetting hasMore before retry"); state.hasMore = true; } + debugLog("ensureInitialLoad requesting new batch", { + hasMore: state.hasMore, + }); await loadNextBatch({ initial: true }); if (state.hasMore && !state.observer && !state.scrollListener) { + debugLog("ensureInitialLoad reattaching observers"); attachObservers(); } + } else { + debugLog("ensureInitialLoad no action", { + resolvedCount: state.resolvedVideos.length, + isLoading: state.isLoading, + hasMore: state.hasMore, + }); } }, async loadMore() { + debugLog("loadMore called"); await loadNextBatch(); }, resume() { cleanupObservers(); if (state.hasMore) { + debugLog("resume attaching observers"); attachObservers(); } }, pause() { + debugLog("pause called"); cleanupObservers(); }, destroy() { + debugLog("destroy called"); cleanupObservers(); state.initialized = false; state.resolvedVideos = []; From befa1ad5ae9dc81e0e6bffd635acf24ca95ceba1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:46:07 -0400 Subject: [PATCH 3/5] Encrypt watch history snapshots and decrypt on fetch --- js/nostr.js | 272 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 250 insertions(+), 22 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 7533dc61..9c03fb3f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -185,19 +185,6 @@ function normalizePointerInput(pointer) { 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 []; @@ -225,6 +212,53 @@ function extractPointerItemsFromEvent(event) { return items; } +function normalizePointersFromPayload(payload) { + const normalized = []; + const seen = new Set(); + + if (!payload || typeof payload !== "object") { + return normalized; + } + + const source = Array.isArray(payload.items) ? payload.items : []; + for (const candidate of source) { + const pointer = normalizePointerInput(candidate); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + normalized.push(pointer); + } + + return normalized; +} + +function parseWatchHistoryPayload(plaintext) { + if (typeof plaintext !== "string") { + return { version: 0, items: [] }; + } + + try { + const parsed = JSON.parse(plaintext); + if (!parsed || typeof parsed !== "object") { + return { version: 0, items: [] }; + } + + const version = Number.isFinite(parsed.version) ? parsed.version : 0; + const items = normalizePointersFromPayload(parsed); + return { version, items }; + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse watch history payload:", error); + } + return { version: 0, items: [] }; + } +} + function eventToAddressPointer(event) { if (!event || typeof event !== "object") { return ""; @@ -935,6 +969,176 @@ class NostrClient { } } + async encryptWatchHistoryPayload(actorPubkey, payload) { + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim().toLowerCase() + : ""; + if (!normalizedActor) { + return { ok: false, error: "invalid-actor" }; + } + + let normalizedPayload = payload; + if (!normalizedPayload || typeof normalizedPayload !== "object") { + normalizedPayload = {}; + } + + const items = Array.isArray(normalizedPayload.items) + ? normalizedPayload.items + : []; + + const prepared = { + version: Number.isFinite(normalizedPayload.version) + ? normalizedPayload.version + : 1, + items, + }; + + const plaintext = JSON.stringify(prepared); + + let ciphertext = ""; + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr?.nip04?.encrypt + ) { + try { + ciphertext = await window.nostr.nip04.encrypt( + normalizedActor, + plaintext + ); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to encrypt watch history with extension:", + error + ); + } + } + } + + if (!ciphertext) { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + await this.ensureSessionActor(true); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to ensure session actor while encrypting watch history:", + error + ); + } + } + + const privateKey = this.sessionActor?.privateKey; + if (privateKey && window?.NostrTools?.nip04?.encrypt) { + try { + ciphertext = await window.NostrTools.nip04.encrypt( + privateKey, + normalizedActor, + plaintext + ); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to encrypt watch history with session key:", + error + ); + } + } + } + } + + if (!ciphertext) { + return { ok: false, error: "encryption-unavailable" }; + } + + return { ok: true, ciphertext }; + } + + async decryptWatchHistoryEvent(pointerEvent, actorPubkey) { + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim().toLowerCase() + : ""; + if (!normalizedActor) { + return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) }; + } + + const ciphertext = + pointerEvent && typeof pointerEvent.content === "string" + ? pointerEvent.content.trim() + : ""; + + if (!ciphertext) { + return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) }; + } + + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr?.nip04?.decrypt + ) { + try { + const plaintext = await window.nostr.nip04.decrypt( + normalizedActor, + ciphertext + ); + const parsed = parseWatchHistoryPayload(plaintext); + return parsed; + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to decrypt watch history with extension:", + error + ); + } + } + } + + try { + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + await this.ensureSessionActor(true); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to ensure session actor while decrypting watch history:", + error + ); + } + } + + const privateKey = this.sessionActor?.privateKey; + if (privateKey && window?.NostrTools?.nip04?.decrypt) { + try { + const plaintext = await window.NostrTools.nip04.decrypt( + privateKey, + normalizedActor, + ciphertext + ); + const parsed = parseWatchHistoryPayload(plaintext); + return parsed; + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to decrypt watch history with session key:", + error + ); + } + } + } + + return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) }; + } + cancelWatchHistoryRepublish(actor) { const key = typeof actor === "string" && actor.trim() @@ -1037,20 +1241,37 @@ class NostrClient { ? normalizedItems.slice(0, WATCH_HISTORY_MAX_ITEMS) : normalizedItems; - const tags = [["d", WATCH_HISTORY_LIST_IDENTIFIER]]; - for (const item of trimmedItems) { - const tag = pointerToTag(item); - if (tag) { - tags.push(tag); + const payloadItems = trimmedItems.map((item) => ({ + type: item.type, + value: item.value, + 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 tags = [ + ["d", WATCH_HISTORY_LIST_IDENTIFIER], + ["encrypted", "nip04"], + ]; + const event = { kind: WATCH_HISTORY_KIND, pubkey: normalizedActor, created_at: Math.floor(Date.now() / 1000), tags, - content: "", + content: encryptionResult.ciphertext, }; let signedEvent; @@ -1518,8 +1739,11 @@ class NostrClient { } const previousEntry = this.watchHistoryCache.get(actor) || null; - const remoteItems = pointerEvent - ? extractPointerItemsFromEvent(pointerEvent) + const decryptedPayload = pointerEvent + ? await this.decryptWatchHistoryEvent(pointerEvent, actor) + : { version: 0, items: [] }; + const remoteItems = Array.isArray(decryptedPayload.items) + ? decryptedPayload.items : []; let items = remoteItems; @@ -1535,7 +1759,11 @@ class NostrClient { pointerForEntry = storedPointerEvent; } - if (!items.length) { + const shouldFallback = + (!pointerEvent && !items.length) || + (pointerEvent && decryptedPayload.version === 0 && !items.length); + + if (shouldFallback) { if (previousEntry?.items?.length) { items = previousEntry.items; usedFallback = true; From 2351f9e126362baf74447bc834f552d40c2963ad Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:04:56 -0400 Subject: [PATCH 4/5] Refresh watch history when snapshots change --- js/app.js | 5 +- js/historyView.js | 234 +++++++++++++++++++++++++++++++++++++++++----- js/nostr.js | 39 ++++++++ 3 files changed, 249 insertions(+), 29 deletions(-) diff --git a/js/app.js b/js/app.js index 6a16a6d6..3a954fb3 100644 --- a/js/app.js +++ b/js/app.js @@ -3955,10 +3955,7 @@ class bitvidApp { sentinelSelector: "#profileHistorySentinel", scrollContainerSelector: "#profileHistoryScroll", emptyCopy: "You haven’t watched any videos yet.", - beforeInitialLoad: async () => { - const actor = this.pubkey || undefined; - await nostrClient.fetchWatchHistory(actor); - }, + getActor: async () => this.pubkey || window.app?.pubkey || undefined, }); } this.adminModeratorsSection = diff --git a/js/historyView.js b/js/historyView.js index 7917b948..0b508d7f 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -16,22 +16,47 @@ function toNumber(value, fallback) { } export function createWatchHistoryRenderer(config = {}) { + const fallbackGetActor = async () => { + const candidate = + typeof window?.app?.pubkey === "string" && window.app.pubkey.trim() + ? window.app.pubkey.trim() + : ""; + return candidate || undefined; + }; + + const mergedConfig = { + viewSelector: "#watchHistoryView", + gridSelector: "#watchHistoryGrid", + loadingSelector: "#watchHistoryLoading", + emptySelector: "#watchHistoryEmpty", + sentinelSelector: "#watchHistorySentinel", + scrollContainerSelector: null, + emptyCopy: WATCH_HISTORY_EMPTY_COPY, + batchSize: BATCH_SIZE, + getActor: fallbackGetActor, + beforeInitialLoad: null, + getSnapshotFingerprint: null, + resolveBatch: (size) => nostrClient.resolveWatchHistory(size), + renderGrid: null, + ...config, + }; + const { - viewSelector = "#watchHistoryView", - gridSelector = "#watchHistoryGrid", - loadingSelector = "#watchHistoryLoading", - emptySelector = "#watchHistoryEmpty", - sentinelSelector = "#watchHistorySentinel", - scrollContainerSelector = null, - emptyCopy = WATCH_HISTORY_EMPTY_COPY, - beforeInitialLoad = async () => { - const actor = window.app?.pubkey || undefined; - await nostrClient.fetchWatchHistory(actor); - }, - resolveBatch = (size) => nostrClient.resolveWatchHistory(size), - renderGrid = null, - batchSize = BATCH_SIZE, - } = config; + viewSelector, + gridSelector, + loadingSelector, + emptySelector, + sentinelSelector, + scrollContainerSelector, + emptyCopy, + batchSize, + getActor, + resolveBatch, + renderGrid, + } = mergedConfig; + + const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; + const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; const selectors = { view: viewSelector, @@ -56,6 +81,64 @@ export function createWatchHistoryRenderer(config = {}) { } }; + const resolveActor = async (hint = null) => { + if (typeof hint === "string" && hint.trim()) { + return hint.trim(); + } + + if (typeof getActor === "function") { + try { + const candidate = await getActor(); + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } catch (error) { + debugLog("failed to resolve actor via getActor", error); + } + } + + return null; + }; + + const callBeforeInitialLoad = async (context = {}) => { + let actor = await resolveActor(context.actor ?? null); + const finalContext = { ...context, actor }; + + if (typeof providedBeforeInitialLoad === "function") { + return providedBeforeInitialLoad(finalContext); + } + + const snapshot = await nostrClient.fetchWatchHistory(actor || undefined); + + if (!actor) { + try { + const ensured = await nostrClient.ensureSessionActor(); + if (typeof ensured === "string" && ensured.trim()) { + actor = ensured.trim(); + } + } catch (error) { + debugLog("failed to ensure session actor while resolving history", error); + } + } + + return { actor: actor || null, snapshot }; + }; + + const computeSnapshotFingerprint = async (context = {}) => { + const actor = await resolveActor(context.actor ?? null); + const finalContext = { ...context, actor }; + + if (typeof providedGetSnapshotFingerprint === "function") { + return providedGetSnapshotFingerprint(finalContext); + } + + if (finalContext.refresh) { + await nostrClient.fetchWatchHistory(actor || undefined); + } + + return nostrClient.getWatchHistoryFingerprint(actor || undefined); + }; + const state = { isLoading: false, hasMore: true, @@ -65,6 +148,8 @@ export function createWatchHistoryRenderer(config = {}) { initialized: false, emptyCopy, batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), + actor: null, + snapshotFingerprint: null, }; const query = (selector) => { @@ -313,29 +398,75 @@ export function createWatchHistoryRenderer(config = {}) { }); }; - const runInitialLoad = async () => { + const runInitialLoad = async (options = {}) => { + const { actor: actorHint = null, prefetched = null, fingerprintOverride } = + typeof options === "object" && options !== null ? options : {}; + const { view } = getElements(); if (!view) { debugLog("runInitialLoad aborted: view not found"); return; } + const resolvedActor = await resolveActor(actorHint ?? state.actor); + state.actor = resolvedActor || null; + cleanupObservers(); state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; state.initialized = false; + state.snapshotFingerprint = null; resetUiState(); - debugLog("starting initial load"); + debugLog("starting initial load", { actor: state.actor }); - try { - await beforeInitialLoad?.(); - } 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; + let beforeResult; + const hasPrefetched = + prefetched && + Object.prototype.hasOwnProperty.call(prefetched, "beforeResult"); + + if (hasPrefetched) { + beforeResult = prefetched.beforeResult; + } else { + try { + beforeResult = await callBeforeInitialLoad({ actor: state.actor }); + } catch (error) { + console.error("[historyView] Failed to fetch watch history list:", error); + showEmptyState( + "We couldn't load your watch history. Please try again later." + ); + return; + } + } + + if ( + beforeResult && + typeof beforeResult === "object" && + typeof beforeResult.actor === "string" && + beforeResult.actor.trim() + ) { + state.actor = beforeResult.actor.trim(); + } + + let fingerprint; + if (typeof fingerprintOverride !== "undefined") { + fingerprint = fingerprintOverride; + } else { + try { + fingerprint = await computeSnapshotFingerprint({ + actor: state.actor, + beforeResult, + refresh: false, + }); + } catch (error) { + debugLog("failed to compute snapshot fingerprint", error); + } + } + + if (typeof fingerprint === "string") { + state.snapshotFingerprint = fingerprint; + } else { + state.snapshotFingerprint = null; } await loadNextBatch({ initial: true }); @@ -348,6 +479,7 @@ export function createWatchHistoryRenderer(config = {}) { debugLog("initial load complete", { hasMore: state.hasMore, resolvedCount: state.resolvedVideos.length, + actor: state.actor, }); }; @@ -369,6 +501,56 @@ export function createWatchHistoryRenderer(config = {}) { return; } + state.actor = (await resolveActor(state.actor)) || null; + + if (!state.isLoading) { + try { + const beforeResult = await callBeforeInitialLoad({ + actor: state.actor, + reason: "ensureInitialLoad", + }); + if ( + beforeResult && + typeof beforeResult === "object" && + typeof beforeResult.actor === "string" && + beforeResult.actor.trim() + ) { + state.actor = beforeResult.actor.trim(); + } + const fingerprint = await computeSnapshotFingerprint({ + actor: state.actor, + beforeResult, + refresh: false, + }); + + if (typeof fingerprint === "string") { + if (fingerprint !== state.snapshotFingerprint) { + debugLog("ensureInitialLoad detected new snapshot", { + previous: state.snapshotFingerprint, + next: fingerprint, + actor: state.actor, + }); + await runInitialLoad({ + actor: state.actor, + prefetched: { beforeResult }, + fingerprintOverride: fingerprint, + }); + return; + } + state.snapshotFingerprint = fingerprint; + } + } catch (error) { + console.warn( + "[historyView] Failed to refresh watch history snapshot:", + error + ); + } + } else { + debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", { + actor: state.actor, + }); + } + if (!state.resolvedVideos.length && !state.isLoading) { if (!state.hasMore) { debugLog("ensureInitialLoad resetting hasMore before retry"); @@ -413,6 +595,8 @@ export function createWatchHistoryRenderer(config = {}) { state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; + state.actor = null; + state.snapshotFingerprint = null; resetUiState(); setLoadingVisible(false); }, diff --git a/js/nostr.js b/js/nostr.js index 9c03fb3f..e0aca71c 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -969,6 +969,45 @@ class NostrClient { } } + getWatchHistoryFingerprint(pubkeyOrSession) { + let actor = + typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() + ? pubkeyOrSession.trim() + : ""; + + if (!actor) { + if (this.sessionActor?.pubkey) { + actor = this.sessionActor.pubkey.trim(); + } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { + actor = this.pubkey.trim(); + } + } + + if (!actor) { + return ""; + } + + const entry = this.watchHistoryCache.get(actor); + if (!entry) { + return ""; + } + + const pointerId = + entry.pointerEvent && typeof entry.pointerEvent.id === "string" + ? entry.pointerEvent.id + : ""; + const pointerCreated = + entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at) + ? entry.pointerEvent.created_at + : 0; + const itemKeys = entry.items + .map((item) => pointerKey(item)) + .filter(Boolean) + .join("|"); + + return `${pointerId}:${pointerCreated}:${itemKeys}`; + } + async encryptWatchHistoryPayload(actorPubkey, payload) { const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim() From 70917449d99ea25b2d5504ff456c386137b3a3ce Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:17:33 -0400 Subject: [PATCH 5/5] Style watch history grids like video feed --- components/profile-modal.html | 3 +-- css/style.css | 28 ++++++++++++++++++++++++++++ views/history.html | 6 +----- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 3c3728e0..efe700ce 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -303,9 +303,8 @@
Your watch history is empty. Watch some videos to populate this list.