diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index b61252bc..a1d6d13f 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -426,6 +426,30 @@ function clearQueue(actorKey) { notifyQueueChange(actorKey); } +// Replace the local watch-history queue with exactly `items` (used by a +// local-only REPLACE snapshot, i.e. a logged-out removal/clear). Source of truth +// for local history is loadLatest -> collectQueueItems(queue), so this is what +// makes a local delete persist. +function replaceLocalQueue(actorKey, items) { + const queue = ensureQueue(actorKey); + queue.items.clear(); + queue.throttle.clear(); + const now = Date.now(); + for (const item of Array.isArray(items) ? items : []) { + const pointer = normalizePointerInput(item); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + queue.items.set(key, { pointer, addedAt: now, updatedAt: now }); + } + persistQueueState(); + notifyQueueChange(actorKey); +} + function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) { if (!actorKey || !queue) { return; @@ -794,6 +818,15 @@ async function snapshot(items, options = {}) { } if (!isFeatureEnabled(actorKey)) { + // Local-only actors (logged out / session) keep watch history in the local + // queue, not on relays. A REPLACE snapshot (i.e. a removal/clear) must + // rewrite that queue so the deletion actually sticks — otherwise the removed + // item lingers locally and reappears on reload. Normal watch-tracking adds + // still flow through the queue via recordVideoView. + if (replace && Array.isArray(items)) { + replaceLocalQueue(actorKey, items); + return { ok: true, local: true, reason: "local-replaced", itemCount: items.length }; + } devLogger.debug( "[watchHistoryService] Snapshot skipped because this actor is limited to local watch history.", { diff --git a/tests/watch-history-local-remove.test.mjs b/tests/watch-history-local-remove.test.mjs new file mode 100644 index 00000000..b8d353ee --- /dev/null +++ b/tests/watch-history-local-remove.test.mjs @@ -0,0 +1,72 @@ +// Logged-out / local-only watch history lives in a localStorage queue, not on +// relays. A removal there is a REPLACE snapshot — it must rewrite the local queue +// so the deletion sticks. Previously snapshot no-op'd for local-only actors, so +// removing an item locally did nothing and it reappeared on reload. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { watchHistoryService } from "../js/watchHistoryService.js"; +import { nostrClient } from "../js/nostrClientFacade.js"; + +// Make `actor` local-only by making it the session actor (feature disabled for +// the session actor → local queue path). +function makeLocalActor(actor) { + nostrClient.pubkey = ""; + nostrClient.sessionActor = { pubkey: actor }; +} + +test("local-only replace snapshot seeds and then prunes the local queue", async () => { + const actor = "f".repeat(64); + makeLocalActor(actor); + + // Seed three items via a replace snapshot (the local queue path). + const seed = await watchHistoryService.snapshot( + [ + { type: "e", value: "local-a" }, + { type: "e", value: "local-b" }, + { type: "e", value: "local-c" }, + ], + { actor, replace: true }, + ); + assert.equal(seed.skipped, undefined, "local replace must NOT be skipped"); + + let items = await watchHistoryService.loadLatest(actor, { allowStale: false }); + assert.deepEqual( + items.map((i) => i.value).sort(), + ["local-a", "local-b", "local-c"], + "seeded local queue should have all three", + ); + + // Remove "local-b": replace with the remaining two. + await watchHistoryService.snapshot( + [ + { type: "e", value: "local-a" }, + { type: "e", value: "local-c" }, + ], + { actor, replace: true }, + ); + + items = await watchHistoryService.loadLatest(actor, { allowStale: false }); + assert.deepEqual( + items.map((i) => i.value).sort(), + ["local-a", "local-c"], + "removed item must be gone from the local queue (delete sticks)", + ); +}); + +test("local-only clear (empty replace) empties the local queue", async () => { + const actor = "e".repeat(64); + makeLocalActor(actor); + + await watchHistoryService.snapshot([{ type: "e", value: "only" }], { + actor, + replace: true, + }); + let items = await watchHistoryService.loadLatest(actor, { allowStale: false }); + assert.equal(items.length, 1); + + await watchHistoryService.snapshot([], { actor, replace: true }); + items = await watchHistoryService.loadLatest(actor, { allowStale: false }); + assert.equal(items.length, 0, "clearing the last local item must empty the queue"); +});