From 9d7efce44e802842c73009ef9a4cdf0f2d3a62f0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Oct 2025 07:16:26 -0400 Subject: [PATCH 1/3] Refactor watch history actions into controller --- js/app.js | 247 +++------------------- js/historyView.js | 5 +- js/ui/watchHistoryController.js | 364 ++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 223 deletions(-) create mode 100644 js/ui/watchHistoryController.js diff --git a/js/app.js b/js/app.js index 95614077..6198c217 100644 --- a/js/app.js +++ b/js/app.js @@ -1,10 +1,6 @@ // js/app.js -import { - nostrClient, - normalizePointerInput, - pointerKey as derivePointerKey, -} from "./nostr.js"; +import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, @@ -40,6 +36,7 @@ import AuthService from "./services/authService.js"; import nostrService from "./services/nostrService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; +import WatchHistoryController from "./ui/watchHistoryController.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; import { @@ -209,6 +206,17 @@ class Application { this.registerSubscriptionsFeed(); this.registerWatchHistoryFeed(); + this.watchHistoryController = new WatchHistoryController({ + watchHistoryService, + nostrClient, + showError: (message) => this.showError(message), + showSuccess: (message) => this.showSuccess(message), + dropWatchHistoryMetadata: (pointerKey) => + this.dropWatchHistoryMetadata(pointerKey), + getActivePubkey: () => + typeof this.pubkey === "string" && this.pubkey ? this.pubkey : "", + }); + this.playbackService = services.playbackService || new PlaybackService({ @@ -3366,244 +3374,41 @@ class Application { } } - buildPointerFromDataset(dataset = {}) { - if (!dataset || typeof dataset !== "object") { - return null; - } - - const typeValue = typeof dataset.pointerType === "string" ? dataset.pointerType : ""; - const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : ""; - const value = - typeof dataset.pointerValue === "string" && dataset.pointerValue.trim() - ? dataset.pointerValue.trim() - : ""; - - if (!normalizedType || !value) { - return null; - } - - const pointer = { type: normalizedType, value }; - - if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) { - pointer.relay = dataset.pointerRelay.trim(); - } - - if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) { - const parsed = Number.parseInt(dataset.pointerWatchedAt, 10); - if (Number.isFinite(parsed)) { - pointer.watchedAt = parsed; - } - } - - if (dataset.pointerSession === "true") { - pointer.session = true; - } - - return pointer; - } - async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) { - const pointer = this.buildPointerFromDataset(dataset); - const pointerKeyValue = - typeof dataset.pointerKey === "string" && dataset.pointerKey.trim() - ? dataset.pointerKey.trim() - : pointer - ? derivePointerKey(normalizePointerInput(pointer)) || "" - : ""; - - if (!pointerKeyValue) { - this.showError("Unable to determine which history entry to remove."); + if (!this.watchHistoryController) { + this.showError("Watch history sync is not available right now."); return; } - const payload = { - removed: { - pointer, - pointerKey: pointerKeyValue, - }, - reason: dataset.reason || "remove-item", - }; - - let card = null; - if (trigger instanceof HTMLElement) { - card = trigger.closest(".video-card"); - if (card instanceof HTMLElement) { - card.dataset.historyRemovalPending = "true"; - card.classList.add("opacity-60"); - card.classList.add("pointer-events-none"); - } - } - try { - await this.handleWatchHistoryRemoval(payload); + await this.watchHistoryController.removeEntry({ + dataset, + trigger, + removeCard: dataset.removeCard === "true", + reason: dataset.reason || "remove-item", + }); } catch (error) { - if (card instanceof HTMLElement) { - delete card.dataset.historyRemovalPending; - card.classList.remove("opacity-60", "pointer-events-none"); - } if (!error?.handled) { this.showError("Failed to remove from history. Please try again."); } - return; - } - - if (card instanceof HTMLElement) { - delete card.dataset.historyRemovalPending; - card.classList.remove("opacity-60", "pointer-events-none"); - if (dataset.removeCard === "true") { - card.remove(); - } } } async handleWatchHistoryRemoval(payload = {}) { - if (!watchHistoryService?.isEnabled?.()) { + if (!this.watchHistoryController) { + this.showError("Watch history sync is not available right now."); const error = new Error("watch-history-disabled"); error.handled = true; - this.showError("Watch history sync is not available right now."); - throw error; - } - - const reason = - typeof payload.reason === "string" && payload.reason.trim() - ? payload.reason.trim() - : "remove-item"; - - const actorCandidate = - typeof payload.actor === "string" && payload.actor.trim() - ? payload.actor.trim() - : typeof this.pubkey === "string" && this.pubkey.trim() - ? this.pubkey.trim() - : typeof nostrClient?.sessionActor?.pubkey === "string" && - nostrClient.sessionActor.pubkey - ? nostrClient.sessionActor.pubkey - : ""; - - const removedPointerRaw = - payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null; - const removedPointerNormalized = normalizePointerInput(removedPointerRaw); - let removedPointerKey = - typeof payload?.removed?.pointerKey === "string" - ? payload.removed.pointerKey - : ""; - if (!removedPointerKey && removedPointerNormalized) { - removedPointerKey = derivePointerKey(removedPointerNormalized) || ""; - } - - if (removedPointerKey) { - this.dropWatchHistoryMetadata(removedPointerKey); - } - - const normalizeEntry = (entry) => { - if (!entry) { - return null; - } - const pointer = normalizePointerInput(entry.pointer || entry); - if (!pointer) { - return null; - } - if (Number.isFinite(entry.watchedAt)) { - pointer.watchedAt = Math.floor(entry.watchedAt); - } else if (Number.isFinite(entry.pointer?.watchedAt)) { - pointer.watchedAt = Math.floor(entry.pointer.watchedAt); - } - if (entry.pointer?.session === true || entry.session === true) { - pointer.session = true; - } - return pointer; - }; - - let normalizedItems = null; - - if (Array.isArray(payload.items) && payload.items.length) { - normalizedItems = payload.items.map(normalizeEntry).filter(Boolean); - } - - if (!normalizedItems) { - try { - const latest = await watchHistoryService.loadLatest(actorCandidate); - normalizedItems = Array.isArray(latest) - ? latest.map(normalizeEntry).filter(Boolean) - : []; - } catch (error) { - this.showError("Failed to load watch history. Please try again."); - if (error && typeof error === "object") { - error.handled = true; - } - throw error; - } - } - - if (removedPointerKey) { - normalizedItems = normalizedItems.filter((entry) => { - try { - return derivePointerKey(entry) !== removedPointerKey; - } catch (error) { - return true; - } - }); - } - - this.showSuccess("Removing from history…"); - - try { - const snapshotResult = await watchHistoryService.snapshot(normalizedItems, { - actor: actorCandidate || undefined, - reason, - }); - - try { - await nostrClient.updateWatchHistoryList(normalizedItems, { - actorPubkey: actorCandidate || undefined, - replace: true, - source: reason, - }); - } catch (updateError) { - if (isDevMode) { - console.warn( - "[watchHistory] Failed to update local watch history list:", - updateError, - ); - } - } - - this.showSuccess( - "Removed from encrypted history. Relay sync may take a moment.", - ); - - return { handledToasts: true, snapshot: snapshotResult }; - } catch (error) { - let message = "Failed to remove from history. Please try again."; - if (error?.result?.retryable) { - message = - "Removal will retry once encrypted history is accepted by your relays."; - } - this.showError(message); - if (error && typeof error === "object") { - error.handled = true; - } throw error; } + return this.watchHistoryController.handleWatchHistoryRemoval(payload); } flushWatchHistory(reason = "session-end", context = "watch-history") { - if (!watchHistoryService?.isEnabled?.()) { + if (!this.watchHistoryController) { return Promise.resolve(); } - try { - const result = watchHistoryService.snapshot(undefined, { reason }); - return Promise.resolve(result).catch((error) => { - if (isDevMode) { - console.warn(`[${context}] Watch history flush failed:`, error); - } - throw error; - }); - } catch (error) { - if (isDevMode) { - console.warn(`[${context}] Failed to queue watch history flush:`, error); - } - return Promise.reject(error); - } + return this.watchHistoryController.flush(reason, context); } getActiveViewIdentityKey() { diff --git a/js/historyView.js b/js/historyView.js index 003c83a4..01fa8085 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -606,8 +606,9 @@ export function createWatchHistoryRenderer(config = {}) { batchSize = WATCH_HISTORY_BATCH_SIZE, remove = (payload) => { const app = getAppInstance(); - if (app?.handleWatchHistoryRemoval) { - return app.handleWatchHistoryRemoval(payload); + const controller = app?.watchHistoryController; + if (controller?.handleWatchHistoryRemoval) { + return controller.handleWatchHistoryRemoval(payload); } return defaultRemoveHandler(payload); }, diff --git a/js/ui/watchHistoryController.js b/js/ui/watchHistoryController.js new file mode 100644 index 00000000..f122ee71 --- /dev/null +++ b/js/ui/watchHistoryController.js @@ -0,0 +1,364 @@ +import { isDevMode } from "../config.js"; +import { + normalizePointerInput, + pointerKey as derivePointerKey, +} from "../nostr.js"; + +const noop = () => {}; + +function resolveCardElement(trigger) { + if (!(trigger instanceof HTMLElement)) { + return null; + } + const candidate = trigger.closest(".video-card"); + return candidate instanceof HTMLElement ? candidate : null; +} + +function safeInvoke(callback, payload) { + if (typeof callback !== "function") { + return; + } + try { + callback(payload); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryController] onStateChange handler failed:", + error, + ); + } + } +} + +export default class WatchHistoryController { + constructor({ + watchHistoryService, + nostrClient, + showError, + showSuccess, + dropWatchHistoryMetadata, + getActivePubkey, + } = {}) { + this.watchHistoryService = watchHistoryService || null; + this.nostrClient = nostrClient || null; + this.showError = typeof showError === "function" ? showError : noop; + this.showSuccess = typeof showSuccess === "function" ? showSuccess : noop; + this.dropWatchHistoryMetadata = + typeof dropWatchHistoryMetadata === "function" + ? dropWatchHistoryMetadata + : noop; + this.getActivePubkey = + typeof getActivePubkey === "function" ? getActivePubkey : () => ""; + } + + buildPointerFromDataset(dataset = {}) { + if (!dataset || typeof dataset !== "object") { + return null; + } + + const typeValue = + typeof dataset.pointerType === "string" ? dataset.pointerType : ""; + const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : ""; + const value = + typeof dataset.pointerValue === "string" && dataset.pointerValue.trim() + ? dataset.pointerValue.trim() + : ""; + + if (!normalizedType || !value) { + return null; + } + + const pointer = { type: normalizedType, value }; + + if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) { + pointer.relay = dataset.pointerRelay.trim(); + } + + if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) { + const parsed = Number.parseInt(dataset.pointerWatchedAt, 10); + if (Number.isFinite(parsed)) { + pointer.watchedAt = parsed; + } + } + + if (dataset.pointerSession === "true") { + pointer.session = true; + } + + return pointer; + } + + resolvePointerKey(pointer, explicitKey) { + if (typeof explicitKey === "string" && explicitKey.trim()) { + return explicitKey.trim(); + } + + const normalized = normalizePointerInput(pointer); + if (!normalized) { + return ""; + } + + try { + return derivePointerKey(normalized) || ""; + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryController] Failed to derive pointer key:", + error, + ); + } + return ""; + } + } + + applyCardState(card, { status, removeCard } = {}) { + if (!(card instanceof HTMLElement)) { + return; + } + + if (status === "pending") { + card.dataset.historyRemovalPending = "true"; + card.classList.add("opacity-60"); + card.classList.add("pointer-events-none"); + return; + } + + delete card.dataset.historyRemovalPending; + card.classList.remove("opacity-60", "pointer-events-none"); + + if (status === "removed" && removeCard) { + card.remove(); + } + } + + resolveActor(actor) { + if (typeof actor === "string" && actor.trim()) { + return actor.trim(); + } + + const candidate = this.getActivePubkey(); + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + + const sessionPubkey = this.nostrClient?.sessionActor?.pubkey; + if (typeof sessionPubkey === "string" && sessionPubkey.trim()) { + return sessionPubkey.trim(); + } + + return ""; + } + + async removeEntry(options = {}) { + if (!this.watchHistoryService?.isEnabled?.()) { + this.showError("Watch history sync is not available right now."); + const error = new Error("watch-history-disabled"); + error.handled = true; + throw error; + } + + const { + dataset = {}, + pointer: pointerInput = null, + pointerKey: explicitPointerKey = "", + reason: providedReason, + trigger = null, + removeCard = false, + actor, + items, + onStateChange, + } = options; + + const pointer = pointerInput || this.buildPointerFromDataset(dataset); + const pointerKey = this.resolvePointerKey(pointer, explicitPointerKey || dataset.pointerKey); + + if (!pointerKey) { + this.showError("Unable to determine which history entry to remove."); + return null; + } + + const reason = + typeof providedReason === "string" && providedReason.trim() + ? providedReason.trim() + : typeof dataset.reason === "string" && dataset.reason.trim() + ? dataset.reason.trim() + : "remove-item"; + + const card = resolveCardElement(trigger); + this.applyCardState(card, { status: "pending" }); + safeInvoke(onStateChange, { status: "pending", card, removeCard }); + + try { + const result = await this.handleWatchHistoryRemoval({ + actor, + items, + reason, + removed: { + pointer, + pointerKey, + }, + }); + + this.applyCardState(card, { status: removeCard ? "removed" : "idle", removeCard }); + safeInvoke(onStateChange, { + status: removeCard ? "removed" : "idle", + card, + removeCard, + result, + }); + + return result; + } catch (error) { + this.applyCardState(card, { status: "idle" }); + safeInvoke(onStateChange, { status: "error", card, error, removeCard }); + + if (!error?.handled) { + this.showError("Failed to remove from history. Please try again."); + } + + throw error; + } + } + + async handleWatchHistoryRemoval(payload = {}) { + if (!this.watchHistoryService?.isEnabled?.()) { + const error = new Error("watch-history-disabled"); + error.handled = true; + this.showError("Watch history sync is not available right now."); + throw error; + } + + const reason = + typeof payload.reason === "string" && payload.reason.trim() + ? payload.reason.trim() + : "remove-item"; + + const actorCandidate = this.resolveActor(payload.actor); + + const removedPointerRaw = + payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null; + const removedPointerNormalized = normalizePointerInput(removedPointerRaw); + let removedPointerKey = + typeof payload?.removed?.pointerKey === "string" + ? payload.removed.pointerKey + : ""; + if (!removedPointerKey && removedPointerNormalized) { + removedPointerKey = derivePointerKey(removedPointerNormalized) || ""; + } + + if (removedPointerKey) { + this.dropWatchHistoryMetadata(removedPointerKey); + } + + const normalizeEntry = (entry) => { + if (!entry) { + return null; + } + const pointer = normalizePointerInput(entry.pointer || entry); + if (!pointer) { + return null; + } + if (Number.isFinite(entry.watchedAt)) { + pointer.watchedAt = Math.floor(entry.watchedAt); + } else if (Number.isFinite(entry.pointer?.watchedAt)) { + pointer.watchedAt = Math.floor(entry.pointer.watchedAt); + } + if (entry.pointer?.session === true || entry.session === true) { + pointer.session = true; + } + return pointer; + }; + + let normalizedItems = null; + + if (Array.isArray(payload.items) && payload.items.length) { + normalizedItems = payload.items.map(normalizeEntry).filter(Boolean); + } + + if (!normalizedItems) { + try { + const latest = await this.watchHistoryService.loadLatest(actorCandidate); + normalizedItems = Array.isArray(latest) + ? latest.map(normalizeEntry).filter(Boolean) + : []; + } catch (error) { + this.showError("Failed to load watch history. Please try again."); + if (error && typeof error === "object") { + error.handled = true; + } + throw error; + } + } + + if (removedPointerKey) { + normalizedItems = normalizedItems.filter((entry) => { + try { + return derivePointerKey(entry) !== removedPointerKey; + } catch (error) { + return true; + } + }); + } + + this.showSuccess("Removing from history…"); + + try { + const snapshotResult = await this.watchHistoryService.snapshot(normalizedItems, { + actor: actorCandidate || undefined, + reason, + }); + + try { + await this.nostrClient?.updateWatchHistoryList?.(normalizedItems, { + actorPubkey: actorCandidate || undefined, + replace: true, + source: reason, + }); + } catch (updateError) { + if (isDevMode) { + console.warn( + "[watchHistoryController] Failed to update local watch history list:", + updateError, + ); + } + } + + this.showSuccess( + "Removed from encrypted history. Relay sync may take a moment.", + ); + + return { handledToasts: true, snapshot: snapshotResult }; + } catch (error) { + let message = "Failed to remove from history. Please try again."; + if (error?.result?.retryable) { + message = + "Removal will retry once encrypted history is accepted by your relays."; + } + this.showError(message); + if (error && typeof error === "object") { + error.handled = true; + } + throw error; + } + } + + flush(reason = "session-end", context = "watch-history") { + if (!this.watchHistoryService?.isEnabled?.()) { + return Promise.resolve(); + } + try { + const result = this.watchHistoryService.snapshot(undefined, { reason }); + return Promise.resolve(result).catch((error) => { + if (isDevMode) { + console.warn(`[${context}] Watch history flush failed:`, error); + } + throw error; + }); + } catch (error) { + if (isDevMode) { + console.warn(`[${context}] Failed to queue watch history flush:`, error); + } + return Promise.reject(error); + } + } +} From 10751a8fb95292d446711d69912e476ae7a8f6f8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Oct 2025 07:33:44 -0400 Subject: [PATCH 2/3] Improve watch history loading responsiveness --- js/historyView.js | 60 +++++++++++++++ js/ui/watchHistoryController.js | 5 +- js/watchHistoryService.js | 106 +++++++++++++++++--------- tests/watch-history.test.mjs | 127 +++++++++++++++++++++++++++++++- 4 files changed, 263 insertions(+), 35 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 01fa8085..1bf8082b 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -654,6 +654,12 @@ export function createWatchHistoryRenderer(config = {}) { featureEnabled: syncEnabled || localSupported, }; + let rendererRef = null; + let fingerprintRefreshQueued = false; + const scheduleTask = + typeof queueMicrotask === "function" + ? queueMicrotask + : (callback) => Promise.resolve().then(callback); let elements = { view: null, grid: null, @@ -733,6 +739,7 @@ export function createWatchHistoryRenderer(config = {}) { } } subscriptions.clear(); + fingerprintRefreshQueued = false; } function clearToasts() { @@ -907,6 +914,50 @@ export function createWatchHistoryRenderer(config = {}) { } } + function queueFingerprintRefresh() { + if (!fingerprintRefreshQueued) { + return; + } + if (state.isLoading) { + return; + } + if (!rendererRef) { + return; + } + fingerprintRefreshQueued = false; + void rendererRef.refresh({ force: false }); + } + + function subscribeToFingerprintUpdates() { + if (typeof watchHistoryService.subscribe !== "function") { + return; + } + const unsubscribe = watchHistoryService.subscribe( + "fingerprint", + (payload) => { + if (!payload) { + return; + } + if ( + payload.actor && + state.actor && + payload.actor !== state.actor + ) { + return; + } + fingerprintRefreshQueued = true; + if (!state.isLoading) { + scheduleTask(() => { + queueFingerprintRefresh(); + }); + } + } + ); + if (typeof unsubscribe === "function") { + subscriptions.add(unsubscribe); + } + } + function setLoadingState(loading) { state.isLoading = loading; if (elements.loading) { @@ -1546,6 +1597,11 @@ export function createWatchHistoryRenderer(config = {}) { result = null; } setLoadingState(false); + if (fingerprintRefreshQueued) { + scheduleTask(() => { + queueFingerprintRefresh(); + }); + } const feedItems = Array.isArray(result?.items) ? result.items : []; const normalized = []; state.metadataCache.clear(); @@ -1644,6 +1700,7 @@ export function createWatchHistoryRenderer(config = {}) { bindMetadataToggle(); subscribeToMetadataPreference(); subscribeToRepublishEvents(); + subscribeToFingerprintUpdates(); updateInfoCallout(); state.metadataStorageEnabled = typeof watchHistoryService.shouldStoreMetadata === "function" @@ -1787,6 +1844,8 @@ export function createWatchHistoryRenderer(config = {}) { state.cursor = 0; state.hasMore = false; state.metadataCache.clear(); + rendererRef = null; + fingerprintRefreshQueued = false; }, async handleRemove(pointerKeyValue) { if (!pointerKeyValue) { @@ -1884,6 +1943,7 @@ export function createWatchHistoryRenderer(config = {}) { }, }; + rendererRef = renderer; return renderer; } diff --git a/js/ui/watchHistoryController.js b/js/ui/watchHistoryController.js index f122ee71..f2d3058d 100644 --- a/js/ui/watchHistoryController.js +++ b/js/ui/watchHistoryController.js @@ -277,7 +277,10 @@ export default class WatchHistoryController { if (!normalizedItems) { try { - const latest = await this.watchHistoryService.loadLatest(actorCandidate); + const latest = await this.watchHistoryService.loadLatest( + actorCandidate, + { allowStale: false }, + ); normalizedItems = Array.isArray(latest) ? latest.map(normalizeEntry).filter(Boolean) : []; diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 408a6a83..a201cd6f 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1200,12 +1200,58 @@ async function snapshot(items, options = {}) { return run; } -async function loadLatest(actorInput) { +function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) { + if (!actorKey) { + return Promise.resolve([]); + } + + if (cacheEntry && typeof cacheEntry === "object" && cacheEntry.promise) { + return cacheEntry.promise; + } + + const promise = nostrClient + .resolveWatchHistory(actorKey, { forceRefresh: true }) + .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) + .then(() => { + const latest = state.fingerprintCache.get(actorKey); + return latest?.items || []; + }) + .catch((error) => { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to load watch history:", error); + } + throw error; + }) + .finally(() => { + const entry = state.fingerprintCache.get(actorKey); + if (entry) { + delete entry.promise; + } + }); + + console.info("[watchHistoryService] Triggered nostr watch list refresh.", { + actor: actorKey, + }); + + const baseEntry = cacheEntry && typeof cacheEntry === "object" ? cacheEntry : {}; + state.fingerprintCache.set(actorKey, { + ...baseEntry, + promise, + }); + + return promise; +} + +async function loadLatest(actorInput, options = {}) { const actorKey = resolveActorKey(actorInput); if (!actorKey) { return []; } + const normalizedOptions = + options && typeof options === "object" ? options : {}; + const allowStale = normalizedOptions.allowStale !== false; + if (!isFeatureEnabled(actorKey)) { if (!state.restored) { restoreQueueState(); @@ -1231,12 +1277,13 @@ async function loadLatest(actorInput) { cacheExpiresAt: cacheEntry?.expiresAt || null, } ); - if ( - cacheEntry?.items && - cacheEntry.expiresAt && - cacheEntry.expiresAt > now && - Array.isArray(cacheEntry.items) - ) { + const hasCachedItems = Array.isArray(cacheEntry?.items); + const cacheIsFresh = + hasCachedItems && + cacheEntry?.expiresAt && + cacheEntry.expiresAt > now; + + if (cacheIsFresh) { console.info( "[watchHistoryService] Returning cached watch list items.", { @@ -1254,42 +1301,35 @@ async function loadLatest(actorInput) { actor: actorKey, } ); + if (allowStale && hasCachedItems) { + console.info( + "[watchHistoryService] Returning stale watch list items while refresh completes.", + { + actor: actorKey, + itemCount: cacheEntry.items.length, + } + ); + return cacheEntry.items; + } return cacheEntry.promise; } - const promise = nostrClient - .resolveWatchHistory(actorKey, { forceRefresh: true }) - .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) - .then(() => { - const latest = state.fingerprintCache.get(actorKey); - return latest?.items || []; - }) - .catch((error) => { - if (isDevMode) { - console.warn("[watchHistoryService] Failed to load watch history:", error); - } - throw error; - }) - .finally(() => { - const entry = state.fingerprintCache.get(actorKey); - if (entry) { - delete entry.promise; - } - }); + if (!allowStale || !hasCachedItems) { + return scheduleWatchHistoryRefresh(actorKey, cacheEntry); + } + + const refreshPromise = scheduleWatchHistoryRefresh(actorKey, cacheEntry); + refreshPromise.catch(() => {}); console.info( - "[watchHistoryService] Triggered nostr watch list refresh.", + "[watchHistoryService] Returning stale watch list items while refresh is pending.", { actor: actorKey, + itemCount: cacheEntry?.items?.length || 0, } ); - state.fingerprintCache.set(actorKey, { - ...(cacheEntry || {}), - promise, - }); - - return promise; + return cacheEntry.items || []; } async function getFingerprint(actorInput) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2c537da5..56a201b0 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -3,7 +3,11 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; -const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } = +const { + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_MAX_ITEMS, + WATCH_HISTORY_CACHE_TTL_MS, +} = await import("../js/config.js"); const { getWatchHistoryV2Enabled, @@ -986,6 +990,126 @@ async function testWatchHistoryServiceIntegration() { } } +async function testWatchHistoryStaleCacheRefresh() { + console.log("Running watch history stale cache refresh test..."); + + const actor = "stale-cache-actor"; + const originalResolve = nostrClient.resolveWatchHistory; + const originalFingerprint = nostrClient.getWatchHistoryFingerprint; + const originalDateNow = Date.now; + + try { + watchHistoryService.resetProgress(actor); + + nostrClient.getWatchHistoryFingerprint = async (_actor, items) => { + const values = (Array.isArray(items) ? items : []).map((entry) => { + if (!entry) { + return ""; + } + if (typeof entry.value === "string") { + return entry.value; + } + if ( + entry.pointer && + typeof entry.pointer.value === "string" + ) { + return entry.pointer.value; + } + return ""; + }); + return `fingerprint:${values.join("|")}`; + }; + + const firstItems = [ + { type: "e", value: "watch-history-old", watchedAt: 1_700_000_000 }, + ]; + + let resolveCallCount = 0; + nostrClient.resolveWatchHistory = async (requestedActor) => { + resolveCallCount += 1; + assert.equal( + requestedActor, + actor, + "initial load should request the expected actor", + ); + return firstItems; + }; + + await watchHistoryService.loadLatest(actor, { allowStale: false }); + + const baseNow = originalDateNow(); + Date.now = () => baseNow + WATCH_HISTORY_CACHE_TTL_MS + 1; + + const refreshedItems = [ + { type: "e", value: "watch-history-new", watchedAt: 1_700_000_600 }, + ]; + + let refreshResolve; + const refreshGate = new Promise((resolve) => { + refreshResolve = resolve; + }); + + resolveCallCount = 0; + nostrClient.resolveWatchHistory = async (requestedActor) => { + resolveCallCount += 1; + assert.equal( + requestedActor, + actor, + "refresh should continue targeting the same actor", + ); + await refreshGate; + return refreshedItems; + }; + + const staleResult = await watchHistoryService.loadLatest(actor); + assert.equal( + resolveCallCount, + 1, + "stale load should trigger a single background refresh", + ); + assert.equal( + staleResult.length, + firstItems.length, + "stale load should return cached entries immediately", + ); + const staleValue = + staleResult[0]?.value || staleResult[0]?.pointer?.value || ""; + assert.equal( + staleValue, + "watch-history-old", + "stale load should surface the cached pointer value", + ); + + refreshResolve(); + await refreshGate; + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + nostrClient.resolveWatchHistory = async () => { + throw new Error("loadLatest should rely on refreshed cache"); + }; + + const freshResult = await watchHistoryService.loadLatest(actor); + assert.equal( + freshResult.length, + refreshedItems.length, + "fresh load should surface refreshed watch history entries", + ); + const freshValue = + freshResult[0]?.value || freshResult[0]?.pointer?.value || ""; + assert.equal( + freshValue, + "watch-history-new", + "fresh load should include the updated pointer", + ); + } finally { + Date.now = originalDateNow; + nostrClient.resolveWatchHistory = originalResolve; + nostrClient.getWatchHistoryFingerprint = originalFingerprint; + watchHistoryService.resetProgress(actor); + } +} + async function testWatchHistoryLocalFallbackWhenDisabled() { console.log("Running watch history local fallback test..."); @@ -1199,6 +1323,7 @@ await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); +await testWatchHistoryStaleCacheRefresh(); await testWatchHistoryLocalFallbackWhenDisabled(); await testWatchHistorySyncEnabledForLoggedInUsers(); await testWatchHistoryAppLoginFallback(); From 46ef2b8123847a99b208f355ca303c462f44e7bb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Oct 2025 08:11:26 -0400 Subject: [PATCH 3/3] Ensure watch history loads include full lists by default --- js/historyView.js | 4 +++- js/watchHistoryService.js | 5 ++++- tests/watch-history.test.mjs | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 1bf8082b..bb5e58e8 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -572,7 +572,9 @@ export function createWatchHistoryRenderer(config = {}) { }); return engine.run("watch-history", { runtime }); } - const items = await watchHistoryService.loadLatest(actorInput); + const items = await watchHistoryService.loadLatest(actorInput, { + allowStale: true, + }); const normalized = normalizeHistoryItems(items); return { items: normalized, metadata: { engine: "service-fallback" } }; }, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index a201cd6f..e40f855d 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1250,7 +1250,10 @@ async function loadLatest(actorInput, options = {}) { const normalizedOptions = options && typeof options === "object" ? options : {}; - const allowStale = normalizedOptions.allowStale !== false; + // Only callers that can react to a later fingerprint update should opt into + // stale cache reads; everyone else waits for the fresh list so they do not + // miss entries. + const allowStale = normalizedOptions.allowStale === true; if (!isFeatureEnabled(actorKey)) { if (!state.restored) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 56a201b0..b913154e 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1061,7 +1061,9 @@ async function testWatchHistoryStaleCacheRefresh() { return refreshedItems; }; - const staleResult = await watchHistoryService.loadLatest(actor); + const staleResult = await watchHistoryService.loadLatest(actor, { + allowStale: true, + }); assert.equal( resolveCallCount, 1,