Add watchedAt metadata to watch history pointers

This commit is contained in:
thePR0M3TH3AN committed 2025-09-30 20:59:36 -04:00
1 parent 5f88e85835
commit ad3f9cb200
2 files changed
+267 -5

No files matched your search

+105 -3
View File
@@ -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);
+162 -2
View File
@@ -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 {