Merge pull request #355 from PR0M3TH3AN/codex/refactor-watch-history-handling-into-controller

Refactor watch history handling into controller
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-08 08:30:03 -04:00
commit 484343b027
5 files changed
+663 -258

No files matched your search

+26 -221
View File
@@ -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() {
+66 -3
View File
@@ -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" } };
},
@@ -606,8 +608,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);
},
@@ -653,6 +656,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,
@@ -732,6 +741,7 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
subscriptions.clear();
fingerprintRefreshQueued = false;
}
function clearToasts() {
@@ -906,6 +916,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) {
@@ -1545,6 +1599,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();
@@ -1643,6 +1702,7 @@ export function createWatchHistoryRenderer(config = {}) {
bindMetadataToggle();
subscribeToMetadataPreference();
subscribeToRepublishEvents();
subscribeToFingerprintUpdates();
updateInfoCallout();
state.metadataStorageEnabled =
typeof watchHistoryService.shouldStoreMetadata === "function"
@@ -1786,6 +1846,8 @@ export function createWatchHistoryRenderer(config = {}) {
state.cursor = 0;
state.hasMore = false;
state.metadataCache.clear();
rendererRef = null;
fingerprintRefreshQueued = false;
},
async handleRemove(pointerKeyValue) {
if (!pointerKeyValue) {
@@ -1883,6 +1945,7 @@ export function createWatchHistoryRenderer(config = {}) {
},
};
rendererRef = renderer;
return renderer;
}
+367
View File
@@ -0,0 +1,367 @@
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,
{ allowStale: false },
);
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);
}
}
}
+76 -33
View File
@@ -1200,12 +1200,61 @@ 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 : {};
// 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) {
restoreQueueState();
@@ -1231,12 +1280,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 +1304,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) {
+128 -1
View File
@@ -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,128 @@ 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, {
allowStale: true,
});
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 +1325,7 @@ await testPublishSnapshotFailureRetry();
await testWatchHistoryPartialRelayRetry();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
await testWatchHistoryStaleCacheRefresh();
await testWatchHistoryLocalFallbackWhenDisabled();
await testWatchHistorySyncEnabledForLoggedInUsers();
await testWatchHistoryAppLoginFallback();