From 99c3f60a2361e9e95d3e1be41fbee83f551e5a3c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:22:34 +0000 Subject: [PATCH] feat: implement video hydration stage for watch history feed This introduces a new stage in the watch history feed pipeline that fetches missing video metadata from relays. Since watch history storage was optimized to store only event IDs, this hydration step ensures that the UI can display titles and thumbnails without requiring them to be persisted in the history payload itself. - Implemented `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js`. - Integrated `convertEventToVideo` to transform fetched events into usable video objects. - Added a comprehensive integration test in `tests/watch-history.test.mjs` verifying the full flow from ID-only storage to hydrated feed items. --- js/feedEngine/watchHistoryFeed.js | 70 ++++++++++++++++++++ tests/watch-history.test.mjs | 102 +++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index 407f082d..7e9fc699 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -1,8 +1,10 @@ // js/feedEngine/watchHistoryFeed.js import { pointerKey, normalizePointerInput } from "../nostr/watchHistory.js"; +import { convertEventToVideo } from "../nostr/index.js"; import watchHistoryService from "../watchHistoryService.js"; import nostrService from "../services/nostrService.js"; +import { nostrClient } from "../nostrClientFacade.js"; import { isWatchHistoryDebugEnabled, logWatchHistoryDebug, @@ -202,6 +204,73 @@ function createWatchHistorySource({ service = watchHistoryService } = {}) { }; } +function createWatchHistoryHydrationStage() { + return async function watchHistoryHydrationStage(items = [], context = {}) { + const missing = []; + const idMap = new Map(); + + for (const item of items) { + if (item.video) { + continue; + } + const pointer = item.pointer; + if (pointer?.type === "e" && pointer.value) { + missing.push(pointer.value); + if (!idMap.has(pointer.value)) { + idMap.set(pointer.value, []); + } + idMap.get(pointer.value).push(item); + } + } + + if (!missing.length) { + return items; + } + + const uniqueIds = Array.from(new Set(missing)); + const relays = + Array.isArray(nostrClient?.relays) && nostrClient.relays.length + ? nostrClient.relays + : null; + + if (!relays || !nostrClient?.pool) { + return items; + } + + try { + const events = await nostrClient.pool.list(relays, [ + { ids: uniqueIds }, + ]); + + for (const event of events) { + if (!event || !event.id) { + 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; + } + } + } + } + } catch (err) { + debugWarn(`[watchHistoryFeed] Failed to convert event ${event.id}:`, err); + } + } + } catch (error) { + debugWarn("[watchHistoryFeed] Hydration fetch failed:", error); + } + + return items; + }; +} + function createWatchHistoryHydratorStage({ service = watchHistoryService } = {}) { return async function watchHistoryHydratorStage(items = [], context = {}) { const results = []; @@ -281,6 +350,7 @@ export function createWatchHistoryFeedDefinition({ source: createWatchHistorySource({ service }), stages: [ createWatchHistoryHydratorStage({ service }), + createWatchHistoryHydrationStage(), blacklistStage, createWatchHistorySuppressionStage(), ], diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index aa8dccd9..a083debf 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -23,10 +23,14 @@ const { nostrClient, setActiveSigner, getActiveSigner, clearActiveSigner } = awa const { rememberNostrTools } = await import("../js/nostr/toolkit.js"); const { normalizeActorKey } = await import("../js/nostr/watchHistory.js"); const { watchHistoryService } = await import("../js/watchHistoryService.js"); -const { buildHistoryCard } = await import("../js/historyView.js"); +const { buildHistoryCard, createWatchHistoryRenderer } = await import("../js/historyView.js"); const { getApplication, setApplication } = await import( "../js/applicationContext.js" ); +const { createWatchHistoryFeedDefinition } = await import( + "../js/feedEngine/watchHistoryFeed.js" +); +const { createFeedEngine } = await import("../js/feedEngine/engine.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -2514,6 +2518,102 @@ async function testWatchHistoryAppLoginFallback() { } } +async function testWatchHistoryFeedHydration() { + console.log("Running watch history feed hydration test..."); + + poolHarness.reset(); + watchHistoryService.resetProgress(); + + const actor = "feed-hydration-actor"; + const videoId = "hydration-video-id"; + const videoTitle = "Hydrated Video Title"; + const videoEvent = { + id: videoId, + kind: 30078, + pubkey: "video-author", + created_at: Math.floor(Date.now() / 1000) - 100, + tags: [["d", "hydration-d-tag"], ["t", "video"]], + content: JSON.stringify({ + title: videoTitle, + videoRootId: "root-id", + version: 2 + }) + }; + + const originalPoolList = nostrClient.pool.list; + const originalApp = getApplication(); + const originalPubkey = nostrClient.pubkey; + + try { + nostrClient.pubkey = actor; + const engine = createFeedEngine(); + engine.registerFeed("watch-history", createWatchHistoryFeedDefinition({ service: watchHistoryService })); + + setApplication({ + feedEngine: { run: (name, opts) => engine.runFeed(name, opts) }, + isAuthorBlocked: () => false, + getHashtagPreferences: () => ({ interests: [], disinterests: [] }) + }); + + // Mock pool.list to return the video event when requested by ID + nostrClient.pool.list = async (relays, filters) => { + const results = []; + for (const filter of filters) { + if (filter.ids && filter.ids.includes(videoId)) { + results.push(videoEvent); + } + + if (filter.kinds && filter.kinds.includes(WATCH_HISTORY_KIND)) { + results.push({ + id: "history-event-id", + kind: WATCH_HISTORY_KIND, + pubkey: actor, + created_at: Math.floor(Date.now() / 1000), + tags: [["d", "2023-11"]], + content: JSON.stringify({ + version: 2, + events: [videoId], + watchedAt: { [videoId]: Math.floor(Date.now() / 1000) } + }) + }); + } + } + return results; + }; + + // Simulate renderer fetching via feed engine + const renderer = createWatchHistoryRenderer({ + fetchHistory: async (actorInput, { cursor = 0 } = {}) => { + const runtime = { + watchHistory: { actor: actorInput, cursor }, + blacklistedEventIds: new Set(), + isAuthorBlocked: () => false + }; + return engine.run("watch-history", { runtime }); + }, + getActor: async () => actor + }); + + await renderer.init({ actor, force: true }); + const state = renderer.getState(); + + assert.equal(state.items.length, 1, "Should have 1 item"); + const item = state.items[0]; + + // Check if hydration worked + assert.ok(item.video, "Item should have video object populated"); + assert.equal(item.video.title, videoTitle, "Video title should be hydrated from relay event"); + assert.equal(item.video.id, videoId, "Video ID should match"); + + } finally { + nostrClient.pubkey = originalPubkey; + nostrClient.pool.list = originalPoolList; + setApplication(originalApp); + watchHistoryService.resetProgress(); + } +} + +await testWatchHistoryFeedHydration(); await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesPlaintext(); // await testPublishSnapshotFallsBackToNip04WhenNip44Unavailable(); // Obsolete with plaintext