Merge pull request #183 from PR0M3TH3AN/codex/update-classifywatchhistorypointer-logic

Fix watch history chunk classification for address pointers
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-30 20:10:32 -04:00
commit 5f88e85835
3 files changed
+269 -46

No files matched your search

+4
View File
@@ -950,6 +950,10 @@ export function createWatchHistoryRenderer(config = {}) {
state.actor = beforeResult.actor.trim();
}
if (state.actor) {
nostrClient.resetWatchHistoryProgress(state.actor);
}
let fingerprint;
if (typeof fingerprintOverride !== "undefined") {
fingerprint = fingerprintOverride;
+151 -46
View File
@@ -1102,9 +1102,15 @@ class NostrClient {
resolved: previousEntry?.resolved
? new Map(previousEntry.resolved)
: new Map(),
resolvedVideos: previousEntry?.resolvedVideos
? new Map(previousEntry.resolvedVideos)
: new Map(),
resolving: previousEntry?.resolving
? new Set(previousEntry.resolving)
: new Set(),
delivered: previousEntry?.delivered instanceof Set
? new Set(previousEntry.delivered)
: new Set(),
};
const validKeys = new Set(entry.items.map((item) => pointerKey(item)));
@@ -1113,11 +1119,21 @@ class NostrClient {
entry.resolved.delete(key);
}
}
for (const key of entry.resolvedVideos.keys()) {
if (!validKeys.has(key)) {
entry.resolvedVideos.delete(key);
}
}
for (const key of [...entry.resolving]) {
if (!validKeys.has(key)) {
entry.resolving.delete(key);
}
}
for (const key of [...entry.delivered]) {
if (!validKeys.has(key)) {
entry.delivered.delete(key);
}
}
const previousChunkPointers = previousEntry?.chunkPointers;
const headPointer = previousChunkPointers?.head
@@ -1214,6 +1230,40 @@ class NostrClient {
return `${pointerId}:${pointerCreated}:${itemKeys}`;
}
resetWatchHistoryProgress(pubkeyOrSession = null) {
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
? pubkeyOrSession.trim()
: "";
if (!actor) {
if (this.sessionActor?.pubkey) {
actor = this.sessionActor.pubkey.trim();
} else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
actor = this.pubkey.trim();
}
}
if (!actor) {
return;
}
const entry = this.watchHistoryCache.get(actor);
if (!entry) {
return;
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
} else {
entry.delivered.clear();
}
if (entry.resolving instanceof Set) {
entry.resolving.clear();
}
}
async encryptWatchHistoryPayload(actorPubkey, payload) {
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
@@ -2518,7 +2568,10 @@ class NostrClient {
}
for (const key of Array.from(entry.resolved.keys())) {
if (!key || !key.startsWith("a:")) {
if (
!key ||
!key.startsWith(`a:${WATCH_HISTORY_KIND}:`)
) {
continue;
}
if (!chunkPointerKeys.has(key)) {
@@ -2570,6 +2623,13 @@ class NostrClient {
return [];
}
if (!(entry.resolvedVideos instanceof Map)) {
entry.resolvedVideos = new Map();
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
}
const parsedBatchSize = Number(batchSize);
const normalizedBatchSize =
Number.isFinite(parsedBatchSize) && parsedBatchSize > 0
@@ -2579,19 +2639,46 @@ class NostrClient {
? Math.max(1, normalizedBatchSize)
: 1;
const classifyWatchHistoryPointer = (pointer) => {
const classifyWatchHistoryPointer = (pointer, ownerPubkey) => {
if (!pointer || pointer.type !== "a") {
return "none";
}
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const value =
typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return "none";
}
const parts = value.split(":");
if (parts.length < 3) {
return "none";
}
const [kindStr, pubkeyRaw, ...identifierParts] = parts;
const kind = Number.parseInt(kindStr, 10);
if (!Number.isFinite(kind) || kind !== WATCH_HISTORY_KIND) {
return "none";
}
if (identifier === WATCH_HISTORY_LIST_IDENTIFIER) {
const identifier = identifierParts.join(":");
const normalizedIdentifier =
typeof identifier === "string" ? identifier.trim() : "";
if (!normalizedIdentifier) {
return "none";
}
const normalizedIdentifierLower = normalizedIdentifier.toLowerCase();
if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) {
return "head";
}
if (typeof identifier === "string" && identifier) {
if (!normalizedIdentifierLower.startsWith("watch-history:")) {
return "none";
}
const normalizedOwner =
typeof ownerPubkey === "string" && ownerPubkey.trim()
? ownerPubkey.trim().toLowerCase()
: "";
const normalizedPubkey =
typeof pubkeyRaw === "string" && pubkeyRaw.trim()
? pubkeyRaw.trim().toLowerCase()
: "";
if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) {
return "chunk";
}
return "none";
@@ -2628,7 +2715,7 @@ class NostrClient {
if (!pointer) {
continue;
}
const pointerType = classifyWatchHistoryPointer(pointer);
const pointerType = classifyWatchHistoryPointer(pointer, actor);
if (pointerType === "head") {
addPointerToMap(pointer, headPointerMap);
continue;
@@ -2664,12 +2751,18 @@ class NostrClient {
if (!key) {
return false;
}
if (entry.resolved.has(key)) {
if (entry.delivered.has(key)) {
return false;
}
if (entry.resolving.has(key)) {
return false;
}
if (entry.resolvedVideos.has(key)) {
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
return false;
}
}
return true;
});
@@ -2700,56 +2793,53 @@ class NostrClient {
const videoBatch = available.slice(0, effectiveBatchSize);
const batch = [...videoBatch, ...chunkAvailable];
if (!batch.length) {
return [];
}
batch.forEach((item) => {
const key = pointerKey(item);
if (key) {
entry.resolving.add(key);
}
});
const relaySet = new Set(
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS
);
const filters = [];
for (const pointer of batch) {
if (pointer.relay) {
relaySet.add(pointer.relay);
}
if (pointer.type === "e") {
filters.push({ ids: [pointer.value] });
} else if (pointer.type === "a") {
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number(kindStr);
if (Number.isFinite(kind) && pubkey) {
const filter = { kinds: [kind], authors: [pubkey] };
if (identifier) {
filter["#d"] = [identifier];
if (batch.length) {
batch.forEach((item) => {
const key = pointerKey(item);
if (key) {
entry.resolving.add(key);
}
});
for (const pointer of batch) {
if (pointer.relay) {
relaySet.add(pointer.relay);
}
if (pointer.type === "e") {
filters.push({ ids: [pointer.value] });
} else if (pointer.type === "a") {
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number(kindStr);
if (Number.isFinite(kind) && pubkey) {
const filter = { kinds: [kind], authors: [pubkey] };
if (identifier) {
filter["#d"] = [identifier];
}
filters.push(filter);
}
filters.push(filter);
}
}
}
if (!filters.length) {
batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
return [];
}
let events = [];
try {
events = await this.pool.list(Array.from(relaySet), filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to resolve watch history batch:", error);
if (filters.length) {
try {
events = await this.pool.list(Array.from(relaySet), filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to resolve watch history batch:", error);
}
}
} else if (batch.length) {
batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
}
const resolvedVideos = [];
const pointerMatches = new Map();
for (const evt of events || []) {
@@ -2776,7 +2866,7 @@ class NostrClient {
if (!key) {
continue;
}
const pointerType = classifyWatchHistoryPointer(pointer);
const pointerType = classifyWatchHistoryPointer(pointer, actor);
const event = pointerMatches.get(key);
if (pointerType === "chunk" || pointerType === "head") {
if (event) {
@@ -2803,8 +2893,7 @@ class NostrClient {
this.activeMap.set(activeKey, video);
}
}
entry.resolved.set(key, video);
resolvedVideos.push(video);
entry.resolvedVideos.set(key, video);
}
} catch (err) {
if (isDevMode) {
@@ -2815,7 +2904,23 @@ class NostrClient {
entry.resolving.delete(key);
}
return resolvedVideos;
const deliverableVideos = [];
for (const pointer of videoCandidates) {
if (deliverableVideos.length >= effectiveBatchSize) {
break;
}
const key = pointerKey(pointer);
if (!key || entry.delivered.has(key)) {
continue;
}
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
deliverableVideos.push(cachedVideo);
entry.delivered.add(key);
}
}
return deliverableVideos;
}
async recordVideoView(videoPointer, options = {}) {
+114
View File
@@ -398,6 +398,120 @@ try {
localStorage.clear();
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
const videoPointer = {
type: "a",
value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
relay: null,
};
const pointerClient = createDecryptClient(ACTOR);
const pointerEntry = pointerClient.createWatchHistoryEntry(
null,
[videoPointer],
Date.now()
);
pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
pointerClient.fetchWatchHistory = async () => {};
const observedResolveFilters = [];
const videoEvent = {
id: "video-pointer-event",
kind: WATCH_HISTORY_KIND,
pubkey: VIDEO_POINTER_AUTHOR,
created_at: 1_700_555_000,
tags: [["d", VIDEO_POINTER_IDENTIFIER]],
content: JSON.stringify({
version: 3,
title: "Pointer Video",
url: "https://cdn.example.com/video.mp4",
}),
};
pointerClient.pool = {
list: async (_relays, filters) => {
observedResolveFilters.push(filters);
return [videoEvent];
},
};
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
assert.equal(
resolvedFromPointer.length,
1,
"resolveWatchHistory should return results for 'a' style video pointers"
);
assert.equal(
resolvedFromPointer[0].id,
videoEvent.id,
"resolved video should match the pointer event id"
);
assert.equal(
resolvedFromPointer[0].title,
"Pointer Video",
"resolved video should parse title from the event payload"
);
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
assert(
cachedEntry.resolvedVideos.has(pointerKey),
"resolved cache should retain the video"
);
assert.equal(
cachedEntry.resolvedVideos.get(pointerKey)?.id,
videoEvent.id,
"resolved cache should store the converted video result"
);
assert(
cachedEntry.delivered.has(pointerKey),
"delivery set should mark the pointer as delivered"
);
assert(
observedResolveFilters.some((filters) =>
filters.some((filter) =>
Array.isArray(filter?.["#d"]) &&
filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
)
),
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
);
const secondResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
secondResolve.length,
0,
"subsequent resolve calls should avoid refetching already delivered videos"
);
pointerClient.resetWatchHistoryProgress(ACTOR);
assert.equal(
cachedEntry.delivered.size,
0,
"resetWatchHistoryProgress should clear delivered pointers"
);
pointerClient.pool = {
list: async () => {
throw new Error("cached resolve should not trigger network fetch");
},
};
const cachedResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
cachedResolve.length,
1,
"reset should allow cached videos to be delivered again"
);
assert.equal(
cachedResolve[0].id,
videoEvent.id,
"cached resolve should reuse the stored video payload"
);
const originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;