From 5724ec4e1e741f23a343cf4eb966c7bef280b26d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 02:51:18 +0000 Subject: [PATCH 1/4] Fix watch history hydration for address pointers Updated `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js` to properly handle and hydrate watch history items that use address pointers (type 'a'). Previously, only event ID pointers (type 'e') were being hydrated, causing videos watched via address pointers to display as "Untitled video" with missing metadata. This change parses the address pointers, constructs appropriate filters, fetches the events, and correctly maps them back to the history items. --- js/feedEngine/watchHistoryFeed.js | 89 ++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index 7e9fc699..0e3edc65 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -204,9 +204,26 @@ function createWatchHistorySource({ service = watchHistoryService } = {}) { }; } +function resolveEventAddress(event) { + if (!event || typeof event !== "object") { + return ""; + } + const kind = Number(event.kind); + const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; + if (!Number.isFinite(kind) || !pubkey) { + return ""; + } + const dTag = event.tags?.find((t) => t[0] === "d" && t[1]); + if (dTag) { + return `${kind}:${pubkey}:${dTag[1]}`; + } + return ""; +} + function createWatchHistoryHydrationStage() { return async function watchHistoryHydrationStage(items = [], context = {}) { const missing = []; + const missingAddresses = []; const idMap = new Map(); for (const item of items) { @@ -220,14 +237,22 @@ function createWatchHistoryHydrationStage() { idMap.set(pointer.value, []); } idMap.get(pointer.value).push(item); + } else if (pointer?.type === "a" && pointer.value) { + missingAddresses.push(pointer.value); + if (!idMap.has(pointer.value)) { + idMap.set(pointer.value, []); + } + idMap.get(pointer.value).push(item); } } - if (!missing.length) { + if (!missing.length && !missingAddresses.length) { return items; } const uniqueIds = Array.from(new Set(missing)); + const uniqueAddresses = Array.from(new Set(missingAddresses)); + const relays = Array.isArray(nostrClient?.relays) && nostrClient.relays.length ? nostrClient.relays @@ -237,24 +262,65 @@ function createWatchHistoryHydrationStage() { return items; } + const filters = []; + if (uniqueIds.length) { + filters.push({ ids: uniqueIds }); + } + + if (uniqueAddresses.length) { + const addressFilters = new Map(); + for (const address of uniqueAddresses) { + const parts = address.split(":"); + if (parts.length !== 3) continue; + const kind = Number(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + + const key = `${kind}:${pubkey}`; + if (!addressFilters.has(key)) { + addressFilters.set(key, { kinds: [kind], authors: [pubkey], "#d": [] }); + } + addressFilters.get(key)["#d"].push(dTag); + } + + for (const filter of addressFilters.values()) { + filters.push(filter); + } + } + try { - const events = await nostrClient.pool.list(relays, [ - { ids: uniqueIds }, - ]); + const events = await nostrClient.pool.list(relays, filters); for (const event of events) { - if (!event || !event.id) { + if (!event) { continue; } try { const video = convertEventToVideo(event); if (video && !video.invalid) { - const targets = idMap.get(event.id); - if (targets) { - for (const target of targets) { - target.video = video; - if (target.metadata) { - target.metadata.video = video; + // Match by ID + if (event.id) { + const targets = idMap.get(event.id); + if (targets) { + for (const target of targets) { + target.video = video; + if (target.metadata) { + target.metadata.video = video; + } + } + } + } + + // Match by Address + const address = resolveEventAddress(event); + if (address) { + const targets = idMap.get(address); + if (targets) { + for (const target of targets) { + target.video = video; + if (target.metadata) { + target.metadata.video = video; + } } } } @@ -369,4 +435,3 @@ export function registerWatchHistoryFeed(engine, options = {}) { const definition = createWatchHistoryFeedDefinition(options); return engine.registerFeed("watch-history", definition); } - From 1f54745840d97518a989c58cb6428c21b05093a0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 03:23:45 +0000 Subject: [PATCH 2/4] Fix watch history hydration for address pointers Updated `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js` to properly handle and hydrate watch history items that use address pointers (type 'a'). Previously, only event ID pointers (type 'e') were being hydrated, causing videos watched via address pointers to display as "Untitled video" with missing metadata. This change parses the address pointers, constructs appropriate filters (normalizing pubkeys to lowercase while preserving d-tag case), fetches the events, and correctly maps them back to the history items using a robust `normalizeAddressKey` utility. --- js/feedEngine/watchHistoryFeed.js | 34 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index 0e3edc65..f6153992 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -204,12 +204,22 @@ function createWatchHistorySource({ service = watchHistoryService } = {}) { }; } +function normalizeAddressKey(address) { + if (typeof address !== "string") return ""; + const parts = address.split(":"); + if (parts.length < 3) return address; + const kind = parts[0]; + const pubkey = parts[1].toLowerCase(); + const dTag = parts.slice(2).join(":"); // Preserve d-tag case + return `${kind}:${pubkey}:${dTag}`; +} + function resolveEventAddress(event) { if (!event || typeof event !== "object") { return ""; } const kind = Number(event.kind); - const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; + const pubkey = typeof event.pubkey === "string" ? event.pubkey.toLowerCase() : ""; if (!Number.isFinite(kind) || !pubkey) { return ""; } @@ -239,10 +249,13 @@ function createWatchHistoryHydrationStage() { idMap.get(pointer.value).push(item); } else if (pointer?.type === "a" && pointer.value) { missingAddresses.push(pointer.value); - if (!idMap.has(pointer.value)) { - idMap.set(pointer.value, []); + + // Use normalized key for matching to handle mixed-case pubkeys in pointers + const key = normalizeAddressKey(pointer.value); + if (!idMap.has(key)) { + idMap.set(key, []); } - idMap.get(pointer.value).push(item); + idMap.get(key).push(item); } } @@ -273,12 +286,16 @@ function createWatchHistoryHydrationStage() { const parts = address.split(":"); if (parts.length !== 3) continue; const kind = Number(parts[0]); - const pubkey = parts[1]; + const pubkey = parts[1]; // Keep original case for filter? No, fetch should be robust. + // Actually, Nostr pubkeys are lowercase hex. + // If the pointer has Uppercase pubkey, we should lowercase it for the filter + // because the relay will likely index it as lowercase or hex. + const normalizedPubkey = pubkey.toLowerCase(); const dTag = parts[2]; - const key = `${kind}:${pubkey}`; + const key = `${kind}:${normalizedPubkey}`; if (!addressFilters.has(key)) { - addressFilters.set(key, { kinds: [kind], authors: [pubkey], "#d": [] }); + addressFilters.set(key, { kinds: [kind], authors: [normalizedPubkey], "#d": [] }); } addressFilters.get(key)["#d"].push(dTag); } @@ -314,7 +331,8 @@ function createWatchHistoryHydrationStage() { // Match by Address const address = resolveEventAddress(event); if (address) { - const targets = idMap.get(address); + const key = normalizeAddressKey(address); + const targets = idMap.get(key); if (targets) { for (const target of targets) { target.video = video; From 914e0abc58730eecd08cbc9abdb4f55f36151eae Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 03:43:08 +0000 Subject: [PATCH 3/4] Fix watch history hydration for address pointers with colons Updated `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js` to properly handle and hydrate watch history items that use address pointers (type 'a'), specifically fixing a bug where d-tags containing colons were being ignored. Previously, only event ID pointers (type 'e') were being hydrated, and an initial fix for addresses failed for d-tags with colons. This change robustly parses address pointers, constructs appropriate filters, fetches the events, and correctly maps them back to the history items using a `normalizeAddressKey` utility that handles mixed-case pubkeys and complex d-tags. --- js/feedEngine/watchHistoryFeed.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index f6153992..cb92d324 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -284,14 +284,11 @@ function createWatchHistoryHydrationStage() { const addressFilters = new Map(); for (const address of uniqueAddresses) { const parts = address.split(":"); - if (parts.length !== 3) continue; + if (parts.length < 3) continue; const kind = Number(parts[0]); - const pubkey = parts[1]; // Keep original case for filter? No, fetch should be robust. - // Actually, Nostr pubkeys are lowercase hex. - // If the pointer has Uppercase pubkey, we should lowercase it for the filter - // because the relay will likely index it as lowercase or hex. + const pubkey = parts[1]; const normalizedPubkey = pubkey.toLowerCase(); - const dTag = parts[2]; + const dTag = parts.slice(2).join(":"); const key = `${kind}:${normalizedPubkey}`; if (!addressFilters.has(key)) { From 7312fa4fa46bf3f2b2f7f2e4501f372ecd63457c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 04:06:44 +0000 Subject: [PATCH 4/4] Fix watch history hydration using local cache and address support Updated `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js` to first check `nostrClient.allEvents` for video data before querying relays. This ensures that recently watched videos (which are already in memory) are instantly hydrated in the watch history list, fixing the "Untitled video" issue. Additionally, implemented robust support for hydrating address pointers (`type: 'a'`) by scanning the cache and constructing proper relay filters for missing items. This covers cases where history items reference videos by `kind:pubkey:d-tag` rather than ID. --- js/feedEngine/watchHistoryFeed.js | 72 ++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index cb92d324..a1860609 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -230,32 +230,84 @@ function resolveEventAddress(event) { return ""; } +function checkLocalCacheForVideo(pointer) { + if (!pointer || typeof pointer !== "object" || !nostrClient?.allEvents) { + return null; + } + + if (pointer.type === "e" && pointer.value) { + const cached = nostrClient.allEvents.get(pointer.value); + if (cached) { + return cached; + } + } + + // Address lookup requires scanning if not using 'e' + // We handle this in batch inside the hydration stage to avoid repeated scanning + return null; +} + function createWatchHistoryHydrationStage() { return async function watchHistoryHydrationStage(items = [], context = {}) { const missing = []; const missingAddresses = []; const idMap = new Map(); - for (const item of items) { - if (item.video) { - continue; - } + const itemsToHydrate = items.filter(item => !item.video && item.pointer); + let addressScanNeeded = false; + + // First pass: try to resolve from cache for IDs, and collect addresses + for (const item of itemsToHydrate) { const pointer = item.pointer; + if (pointer?.type === "e" && pointer.value) { + const cached = checkLocalCacheForVideo(pointer); + if (cached) { + item.video = cached; + if (item.metadata) item.metadata.video = cached; + continue; + } + missing.push(pointer.value); if (!idMap.has(pointer.value)) { idMap.set(pointer.value, []); } idMap.get(pointer.value).push(item); } else if (pointer?.type === "a" && pointer.value) { - missingAddresses.push(pointer.value); + addressScanNeeded = true; + // Don't add to missingAddresses yet, check cache first + } + } - // Use normalized key for matching to handle mixed-case pubkeys in pointers - const key = normalizeAddressKey(pointer.value); - if (!idMap.has(key)) { - idMap.set(key, []); + // Second pass: Scan cache for addresses if needed + if (addressScanNeeded && nostrClient?.allEvents) { + // Create a temporary index of cached videos by address + // Only iterate once + const cacheByAddress = new Map(); + for (const video of nostrClient.allEvents.values()) { + const address = resolveEventAddress(video); + if (address) { + cacheByAddress.set(normalizeAddressKey(address), video); + } + } + + for (const item of itemsToHydrate) { + const pointer = item.pointer; + if (pointer?.type === "a" && pointer.value) { + const key = normalizeAddressKey(pointer.value); + const cached = cacheByAddress.get(key); + if (cached) { + item.video = cached; + if (item.metadata) item.metadata.video = cached; + continue; + } + + missingAddresses.push(pointer.value); + if (!idMap.has(key)) { + idMap.set(key, []); + } + idMap.get(key).push(item); } - idMap.get(key).push(item); } }