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] 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 {