Add watch history feed adapter and update renderer

This commit is contained in:
thePR0M3TH3AN committed 2025-10-04 17:25:43 -04:00
1 parent b1b0b0f625
commit 7c3af36003
6 files changed
+916 -220

No files matched your search

+26
View File
@@ -25,6 +25,7 @@ import {
createDedupeByRootStage,
createChronologicalSorter,
createSubscriptionAuthorsSource,
registerWatchHistoryFeed,
} from "./feedEngine/index.js";
import watchHistoryService from "./watchHistoryService.js";
import r2Service from "./services/r2Service.js";
@@ -186,6 +187,7 @@ class Application {
}
this.registerRecentFeed();
this.registerSubscriptionsFeed();
this.registerWatchHistoryFeed();
this.playbackService =
services.playbackService ||
@@ -4997,6 +4999,30 @@ class Application {
}
}
registerWatchHistoryFeed() {
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
return null;
}
const existingDefinition =
typeof this.feedEngine.getFeedDefinition === "function"
? this.feedEngine.getFeedDefinition("watch-history")
: null;
if (existingDefinition) {
return existingDefinition;
}
try {
return registerWatchHistoryFeed(this.feedEngine, {
service: watchHistoryService,
nostr: this.nostrService,
});
} catch (error) {
console.warn("[Application] Failed to register watch history feed:", error);
return null;
}
}
buildRecentFeedRuntime() {
const blacklist =
this.blacklistedEventIds instanceof Set
+1
View File
@@ -16,3 +16,4 @@ export {
createSubscriptionAuthorsSource,
createWatchHistoryPointerSource,
} from "./sources.js";
export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js";
+342
View File
@@ -0,0 +1,342 @@
// js/feedEngine/watchHistoryFeed.js
import { pointerKey, normalizePointerInput } from "../nostr.js";
import watchHistoryService from "../watchHistoryService.js";
import nostrService from "../services/nostrService.js";
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js";
import {
createBlacklistFilterStage,
createWatchHistorySuppressionStage,
} from "./stages.js";
import { isPlainObject } from "./utils.js";
function normalizeActorCandidate(...values) {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
}
return "";
}
function resolveWatchedAt(...candidates) {
for (const candidate of candidates) {
if (!Number.isFinite(candidate)) {
continue;
}
let normalized = Math.floor(Number(candidate));
if (normalized <= 0) {
continue;
}
if (normalized > 10_000_000_000) {
normalized = Math.floor(normalized / 1000);
}
if (normalized > 0) {
return normalized;
}
}
return 0;
}
function resolveResumeSeconds(candidate) {
const candidates = [
candidate?.resumeSeconds,
candidate?.resume,
candidate?.resumeAt,
candidate?.resumePosition,
candidate?.resumeTime,
candidate?.progress?.resume,
candidate?.progress?.seconds,
candidate?.progress?.position,
candidate?.pointer?.resumeSeconds,
candidate?.pointer?.resume,
candidate?.pointer?.resumeAt,
];
for (const entry of candidates) {
if (!Number.isFinite(entry)) {
continue;
}
const normalized = Math.max(0, Math.floor(entry));
if (normalized > 0) {
return normalized;
}
}
return null;
}
function resolveCompleted(candidate) {
const candidates = [
candidate?.completed,
candidate?.complete,
candidate?.progress?.completed,
candidate?.progress?.complete,
candidate?.pointer?.completed,
candidate?.pointer?.complete,
];
return candidates.some((value) => value === true);
}
function normalizeHistoryEntry(raw) {
const pointer = normalizePointerInput(raw?.pointer || raw);
if (!pointer) {
return null;
}
const key = pointerKey(pointer);
if (!key) {
return null;
}
const watchedAt = resolveWatchedAt(
raw?.watchedAt,
raw?.timestamp,
raw?.created_at,
pointer?.watchedAt,
);
const resumeAt = resolveResumeSeconds(raw);
const completed = resolveCompleted(raw);
return {
pointer,
pointerKey: key,
watchedAt,
video: isPlainObject(raw?.video) ? raw.video : null,
metadata: {
source: "watch-history",
pointerKey: key,
watchedAt,
resumeAt,
completed,
session: raw?.session === true || pointer.session === true,
},
};
}
function createWatchHistorySource({ service = watchHistoryService } = {}) {
return async function watchHistorySource(context = {}) {
const actor = normalizeActorCandidate(
context?.runtime?.watchHistory?.actor,
context?.config?.actor,
context?.runtime?.actor,
);
if (!service || typeof service.loadLatest !== "function") {
return [];
}
let items = [];
try {
items = await Promise.resolve(service.loadLatest(actor || undefined));
} catch (error) {
context?.log?.("[watch-history-feed] Failed to load history", error);
items = [];
}
const map = new Map();
for (const raw of Array.isArray(items) ? items : []) {
const normalized = normalizeHistoryEntry(raw);
if (!normalized) {
continue;
}
const existing = map.get(normalized.pointerKey);
if (!existing || existing.watchedAt < normalized.watchedAt) {
map.set(normalized.pointerKey, normalized);
}
}
const results = Array.from(map.values());
results.sort((a, b) => {
if (a.watchedAt !== b.watchedAt) {
return b.watchedAt - a.watchedAt;
}
return a.pointerKey.localeCompare(b.pointerKey);
});
return results;
};
}
function createWatchHistoryHydratorStage({
service = watchHistoryService,
metadataResolver,
} = {}) {
const resolver = metadataResolver || createWatchHistoryMetadataResolver();
return async function watchHistoryHydratorStage(items = [], context = {}) {
const shouldStoreMetadata =
typeof service?.shouldStoreMetadata === "function"
? service.shouldStoreMetadata() !== false
: true;
const results = [];
for (const item of Array.isArray(items) ? items : []) {
if (!item || typeof item !== "object") {
continue;
}
const pointerKeyValue =
typeof item.pointerKey === "string" && item.pointerKey
? item.pointerKey
: pointerKey(item.pointer);
const watchedAtValue = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(Number(item.watchedAt)))
: Number.isFinite(item?.metadata?.watchedAt)
? Math.max(0, Math.floor(Number(item.metadata.watchedAt)))
: 0;
let video = item.video || null;
let profile = item.metadata?.profile || null;
if (pointerKeyValue && typeof service?.getLocalMetadata === "function") {
try {
const stored = service.getLocalMetadata(pointerKeyValue);
if (stored) {
video = video || stored.video || null;
profile = profile || stored.profile || null;
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to read cached metadata",
error,
);
}
}
if (!video && resolver?.resolveVideo) {
try {
video = await resolver.resolveVideo(item.pointer);
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to resolve video from pointer",
error,
);
}
}
if (video?.pubkey && !profile && resolver?.resolveProfile) {
try {
profile = resolver.resolveProfile(video.pubkey) || null;
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to resolve profile for pointer",
error,
);
}
}
const metadata = {
...(isPlainObject(item.metadata) ? item.metadata : {}),
pointerKey: pointerKeyValue,
watchedAt: watchedAtValue || null,
resumeAt: item.metadata?.resumeAt ?? null,
completed: item.metadata?.completed ?? false,
session: item.metadata?.session === true,
video: video || null,
profile: profile || null,
};
const nextItem = {
...item,
pointerKey: pointerKeyValue,
watchedAt: watchedAtValue,
video: video || null,
metadata,
};
results.push(nextItem);
if (pointerKeyValue) {
if (shouldStoreMetadata) {
try {
service?.setLocalMetadata?.(pointerKeyValue, {
video: metadata.video,
profile: metadata.profile,
});
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to persist metadata cache",
error,
);
}
} else if (typeof service?.removeLocalMetadata === "function") {
try {
service.removeLocalMetadata(pointerKeyValue);
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to clear cached metadata",
error,
);
}
}
}
}
return results;
};
}
function createWatchHistorySorter() {
return function watchHistorySorter(items = []) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
copy.sort((a, b) => {
if (a?.watchedAt !== b?.watchedAt) {
return (b?.watchedAt || 0) - (a?.watchedAt || 0);
}
const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : "";
const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : "";
return aKey.localeCompare(bKey);
});
return copy;
};
}
export function createWatchHistoryFeedDefinition({
service = watchHistoryService,
nostr = nostrService,
metadataResolverFactory = createWatchHistoryMetadataResolver,
shouldIncludeVideo,
} = {}) {
const metadataResolver = metadataResolverFactory({ nostr });
const blacklistStage = createBlacklistFilterStage({
shouldIncludeVideo:
typeof shouldIncludeVideo === "function"
? shouldIncludeVideo
: (video, options) =>
(nostr?.shouldIncludeVideo
? nostr.shouldIncludeVideo(video, options)
: true),
});
return {
source: createWatchHistorySource({ service }),
stages: [
createWatchHistoryHydratorStage({ service, metadataResolver }),
blacklistStage,
createWatchHistorySuppressionStage(),
],
sorter: createWatchHistorySorter(),
defaultConfig: {
actorFilters: [],
},
};
}
export function registerWatchHistoryFeed(engine, options = {}) {
if (!engine || typeof engine.registerFeed !== "function") {
return null;
}
const definition = createWatchHistoryFeedDefinition(options);
return engine.registerFeed("watch-history", definition);
}
+148 -220
View File
@@ -7,7 +7,6 @@ import {
nostrClient,
updateWatchHistoryList,
} from "./nostr.js";
import nostrService from "./services/nostrService.js";
import {
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
@@ -83,6 +82,34 @@ function getAppInstance() {
return getApplication();
}
function buildWatchHistoryFeedRuntime({ actor, cursor = 0 } = {}) {
const app = getAppInstance();
const normalizedActor =
typeof actor === "string" && actor.trim() ? actor.trim() : "";
const blacklist =
app?.blacklistedEventIds instanceof Set
? new Set(app.blacklistedEventIds)
: new Set();
const runtime = {
blacklistedEventIds: blacklist,
isAuthorBlocked: (pubkey) =>
(typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) ||
false,
watchHistory: {
actor: normalizedActor,
cursor: Number.isFinite(cursor) ? cursor : 0,
},
};
if (normalizedActor) {
runtime.actor = normalizedActor;
}
return runtime;
}
function safeLocaleDate(date) {
try {
return date.toLocaleDateString(undefined, {
@@ -311,171 +338,6 @@ function getPointerVideoId(video, pointer) {
return "";
}
async function resolveVideoFromPointer(pointer, caches) {
if (!pointer) {
return null;
}
const app = getAppInstance();
const type = pointer.type === "a" ? "a" : "e";
const value = typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return null;
}
const normalizeVideo = (video) => {
if (!video || typeof video !== "object") {
return null;
}
if (video.deleted) {
return null;
}
return video;
};
if (type === "e") {
const fromCache = app?.videosMap?.get(value);
if (fromCache) {
const normalized = normalizeVideo(fromCache);
if (normalized) {
return normalized;
}
}
if (nostrClient?.allEvents instanceof Map) {
const event = nostrClient.allEvents.get(value);
if (event) {
const normalized = normalizeVideo(event);
if (normalized) {
return normalized;
}
}
}
if (typeof app?.getOldEventById === "function") {
try {
const fetched = await app.getOldEventById(value);
if (fetched) {
const normalized = normalizeVideo(fetched);
if (normalized) {
return normalized;
}
}
} catch (error) {
if (isDevEnv) {
console.warn("[historyView] Failed to load video via app cache:", error);
}
}
}
if (typeof nostrClient?.getEventById === "function") {
try {
const fetched = await nostrClient.getEventById(value);
const normalized = normalizeVideo(fetched);
if (normalized) {
return normalized;
}
} catch (error) {
if (isDevEnv) {
console.warn("[historyView] Failed to fetch event by id:", error);
}
}
}
return null;
}
const compareAddress = (video) => {
if (!video || typeof video !== "object") {
return false;
}
if (typeof app?.getVideoAddressPointer !== "function") {
return false;
}
const address = app.getVideoAddressPointer(video);
return typeof address === "string" && address.trim() === value;
};
if (app?.videosMap instanceof Map) {
for (const cached of app.videosMap.values()) {
if (compareAddress(cached)) {
const normalized = normalizeVideo(cached);
if (normalized) {
return normalized;
}
}
}
}
const filterOptions = {
blacklistedEventIds: app?.blacklistedEventIds,
isAuthorBlocked: (pubkey) =>
(typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) || false,
};
const activeVideos = Array.isArray(caches?.activeVideos)
? caches.activeVideos
: nostrService.getFilteredActiveVideos(filterOptions);
for (const candidate of activeVideos) {
if (compareAddress(candidate)) {
const normalized = normalizeVideo(candidate);
if (normalized) {
return normalized;
}
}
}
if (!caches?.catalogPromise) {
caches.catalogPromise = nostrService
.fetchVideos(filterOptions)
.catch((error) => {
if (isDevEnv) {
console.warn("[historyView] Failed to fetch video catalog:", error);
}
return [];
});
}
if (caches?.catalogPromise) {
try {
const catalog = await caches.catalogPromise;
if (Array.isArray(catalog)) {
for (const candidate of catalog) {
if (compareAddress(candidate)) {
const normalized = normalizeVideo(candidate);
if (normalized) {
return normalized;
}
}
}
}
} catch (error) {
if (isDevEnv) {
console.warn("[historyView] Failed to search catalog for pointer:", error);
}
}
}
return null;
}
function resolveProfileForPubkey(pubkey) {
if (typeof pubkey !== "string" || !pubkey.trim()) {
return null;
}
const app = getAppInstance();
if (!app) {
return null;
}
try {
const cacheEntry = app.getProfileCacheEntry
? app.getProfileCacheEntry(pubkey)
: null;
if (cacheEntry && typeof cacheEntry === "object") {
return cacheEntry.profile || null;
}
} catch (error) {
if (isDevEnv) {
console.warn("[historyView] Failed to read profile cache:", error);
}
}
return null;
}
function buildHistoryCard({
item,
video,
@@ -700,7 +562,20 @@ function buildHistoryCard({
export function createWatchHistoryRenderer(config = {}) {
const {
fetchHistory = watchHistoryService.loadLatest.bind(watchHistoryService),
fetchHistory = async (actorInput, { cursor = 0 } = {}) => {
const app = getAppInstance();
const engine = app?.feedEngine;
if (engine && typeof engine.run === "function") {
const runtime = buildWatchHistoryFeedRuntime({
actor: actorInput,
cursor,
});
return engine.run("watch-history", { runtime });
}
const items = await watchHistoryService.loadLatest(actorInput);
const normalized = normalizeHistoryItems(items);
return { items: normalized, metadata: { engine: "service-fallback" } };
},
snapshot = watchHistoryService.snapshot.bind(watchHistoryService),
getActor,
viewSelector = "#watchHistoryView",
@@ -766,10 +641,7 @@ export function createWatchHistoryRenderer(config = {}) {
metadataPreference: "encrypted-only",
privacyDismissed: false,
metadataCache: new Map(),
catalogCaches: {
activeVideos: null,
catalogPromise: null,
},
feedMetadata: null,
metadataStorageEnabled:
typeof watchHistoryService.shouldStoreMetadata === "function"
? watchHistoryService.shouldStoreMetadata() !== false
@@ -1225,31 +1097,21 @@ export function createWatchHistoryRenderer(config = {}) {
async function hydrateBatch(batch) {
const hydrated = [];
const caches = state.catalogCaches;
if (!caches.activeVideos && typeof nostrClient?.getActiveVideos === "function") {
caches.activeVideos = nostrClient.getActiveVideos();
}
for (const item of batch) {
if (!item) {
continue;
}
if (state.metadataCache.has(item.pointerKey)) {
hydrated.push({ ...item, metadata: state.metadataCache.get(item.pointerKey) });
continue;
}
const pointerKeyValue = item.pointerKey;
let video = null;
let profile = null;
if (
pointerKeyValue &&
typeof watchHistoryService.getLocalMetadata === "function"
) {
const pointerKeyValue = item.pointerKey || null;
const baseMetadata =
item && typeof item === "object" && item.metadata && typeof item.metadata === "object"
? { ...item.metadata }
: {};
let cached = pointerKeyValue ? state.metadataCache.get(pointerKeyValue) : null;
if (!cached && pointerKeyValue && typeof watchHistoryService.getLocalMetadata === "function") {
try {
const stored = watchHistoryService.getLocalMetadata(pointerKeyValue);
if (stored) {
video = stored.video || null;
profile = stored.profile || null;
}
cached = watchHistoryService.getLocalMetadata(pointerKeyValue) || null;
} catch (error) {
if (isDevEnv) {
console.warn(
@@ -1260,28 +1122,32 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
}
try {
const resolvedVideo = await resolveVideoFromPointer(item.pointer, caches);
if (resolvedVideo) {
video = resolvedVideo;
}
} catch (error) {
if (isDevEnv) {
console.warn(
"[historyView] Failed to resolve video for pointer:",
error,
);
}
let video = item.video || baseMetadata.video || null;
let profile = baseMetadata.profile || null;
if (cached) {
video = video || cached.video || null;
profile = profile || cached.profile || null;
}
if (video?.pubkey) {
profile = resolveProfileForPubkey(video.pubkey) || profile;
}
const metadata = { video: video || null, profile: profile || null };
state.metadataCache.set(pointerKeyValue, metadata);
const metadata = {
...baseMetadata,
video: video || null,
profile: profile || null,
};
if (pointerKeyValue) {
state.metadataCache.set(pointerKeyValue, {
video: metadata.video,
profile: metadata.profile,
});
if (state.metadataStorageEnabled) {
try {
watchHistoryService.setLocalMetadata?.(pointerKeyValue, metadata);
watchHistoryService.setLocalMetadata?.(pointerKeyValue, {
video: metadata.video,
profile: metadata.profile,
});
} catch (error) {
if (isDevEnv) {
console.warn(
@@ -1291,9 +1157,9 @@ export function createWatchHistoryRenderer(config = {}) {
);
}
}
} else {
} else if (typeof watchHistoryService.removeLocalMetadata === "function") {
try {
watchHistoryService.removeLocalMetadata?.(pointerKeyValue);
watchHistoryService.removeLocalMetadata(pointerKeyValue);
} catch (error) {
if (isDevEnv) {
console.warn(
@@ -1305,7 +1171,8 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
}
hydrated.push({ ...item, metadata });
hydrated.push({ ...item, video: metadata.video, metadata });
}
return hydrated;
}
@@ -1653,9 +1520,9 @@ export function createWatchHistoryRenderer(config = {}) {
: await resolveActorKey();
state.actor = actor;
updateSessionFallbackWarning();
let items = [];
let result = null;
try {
items = await fetchHistory(actor);
result = await fetchHistory(actor, { cursor: 0 });
state.lastError = null;
hideErrorBanner();
} catch (error) {
@@ -1665,21 +1532,70 @@ export function createWatchHistoryRenderer(config = {}) {
? error.message
: "Failed to load watch history from relays.";
showErrorBanner(message);
items = [];
result = null;
}
setLoadingState(false);
const normalized = normalizeHistoryItems(items);
const feedItems = Array.isArray(result?.items) ? result.items : [];
const normalized = [];
state.metadataCache.clear();
for (const entry of feedItems) {
const pointer = normalizePointerInput(entry?.pointer || entry);
if (!pointer) {
continue;
}
const pointerKeyValue =
typeof entry?.pointerKey === "string" && entry.pointerKey
? entry.pointerKey
: pointerKey(pointer);
if (!pointerKeyValue) {
continue;
}
const watchedAtRaw = Number.isFinite(entry?.watchedAt)
? entry.watchedAt
: Number.isFinite(entry?.metadata?.watchedAt)
? entry.metadata.watchedAt
: 0;
const watchedAt = Math.max(0, Math.floor(Number(watchedAtRaw) || 0));
const baseMetadata =
entry && typeof entry === "object" && entry.metadata && typeof entry.metadata === "object"
? { ...entry.metadata }
: {};
const video = entry?.video || baseMetadata.video || null;
const profile = baseMetadata.profile || null;
const metadata = {
...baseMetadata,
pointerKey: pointerKeyValue,
watchedAt: Number.isFinite(baseMetadata.watchedAt)
? baseMetadata.watchedAt
: watchedAt || null,
video: video || null,
profile: profile || null,
};
state.metadataCache.set(pointerKeyValue, {
video: metadata.video,
profile: metadata.profile,
});
normalized.push({
pointer,
pointerKey: pointerKeyValue,
watchedAt,
video: metadata.video,
metadata,
});
}
const fingerprint = computeFingerprint(normalized);
if (!force && fingerprint && fingerprint === state.fingerprint) {
return false;
}
state.items = normalized;
state.feedMetadata =
result && typeof result.metadata === "object" && result.metadata
? { ...result.metadata }
: null;
state.fingerprint = fingerprint;
state.cursor = 0;
state.hasMore = state.items.length > 0;
state.metadataCache.clear();
state.catalogCaches.catalogPromise = null;
state.catalogCaches.activeVideos = null;
return true;
}
@@ -1902,6 +1818,7 @@ export function createWatchHistoryRenderer(config = {}) {
}
const remainingItems = state.items.slice();
const actor = await resolveActorKey();
let refreshNeeded = true;
try {
const handler =
typeof remove === "function"
@@ -1927,11 +1844,22 @@ export function createWatchHistoryRenderer(config = {}) {
if (isDevEnv) {
console.warn("[historyView] Removal failed:", error);
}
await this.refresh();
} finally {
if (parentDay instanceof HTMLElement) {
removeEmptyDayContainers(elements.grid);
}
if (refreshNeeded) {
try {
await this.refresh({ force: true });
} catch (error) {
if (isDevEnv) {
console.warn(
"[historyView] Failed to refresh watch history after removal:",
error,
);
}
}
}
}
},
async render() {
+267
View File
@@ -0,0 +1,267 @@
// js/watchHistoryMetadata.js
import { getApplication } from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import { nostrClient } from "./nostr.js";
const isDevEnv =
typeof process !== "undefined" && process?.env?.NODE_ENV !== "production";
function normalizeVideoCandidate(candidate) {
if (!candidate || typeof candidate !== "object") {
return null;
}
if (candidate.deleted) {
return null;
}
return candidate;
}
export function createWatchHistoryMetadataResolver({
appResolver = getApplication,
nostr = nostrService,
client = nostrClient,
} = {}) {
const caches = {
activeVideos: null,
catalogPromise: null,
};
function getAppInstance() {
try {
return typeof appResolver === "function" ? appResolver() : getApplication();
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to resolve application instance:",
error,
);
}
return null;
}
}
async function resolveVideo(pointer) {
if (!pointer || typeof pointer !== "object") {
return null;
}
const app = getAppInstance();
const type = pointer.type === "a" ? "a" : "e";
const value = typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return null;
}
if (type === "e") {
const fromAppCache = app?.videosMap?.get?.(value);
const normalizedAppCache = normalizeVideoCandidate(fromAppCache);
if (normalizedAppCache) {
return normalizedAppCache;
}
const fromClientCache = client?.allEvents instanceof Map
? client.allEvents.get(value)
: null;
const normalizedClientCache = normalizeVideoCandidate(fromClientCache);
if (normalizedClientCache) {
return normalizedClientCache;
}
if (typeof app?.getOldEventById === "function") {
try {
const fetched = await app.getOldEventById(value);
const normalized = normalizeVideoCandidate(fetched);
if (normalized) {
return normalized;
}
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to fetch old event by id:",
error,
);
}
}
}
if (typeof client?.getEventById === "function") {
try {
const fetched = await client.getEventById(value);
const normalized = normalizeVideoCandidate(fetched);
if (normalized) {
return normalized;
}
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to fetch event by id:",
error,
);
}
}
}
return null;
}
const compareAddress = (candidate) => {
if (!candidate || typeof candidate !== "object") {
return false;
}
if (typeof app?.getVideoAddressPointer !== "function") {
return false;
}
try {
const address = app.getVideoAddressPointer(candidate);
return typeof address === "string" && address.trim() === value;
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to compute address pointer:",
error,
);
}
}
return false;
};
if (app?.videosMap instanceof Map) {
for (const cached of app.videosMap.values()) {
if (compareAddress(cached)) {
const normalized = normalizeVideoCandidate(cached);
if (normalized) {
return normalized;
}
}
}
}
const blacklist = app?.blacklistedEventIds instanceof Set
? app.blacklistedEventIds
: undefined;
const filterOptions = {
blacklistedEventIds: blacklist,
isAuthorBlocked: (pubkey) => {
try {
return (
typeof app?.isAuthorBlocked === "function" &&
app.isAuthorBlocked(pubkey)
);
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to evaluate isAuthorBlocked:",
error,
);
}
return false;
}
},
};
if (!Array.isArray(caches.activeVideos)) {
try {
caches.activeVideos = nostr.getFilteredActiveVideos
? nostr.getFilteredActiveVideos(filterOptions)
: [];
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to read active videos cache:",
error,
);
}
caches.activeVideos = [];
}
}
for (const candidate of Array.isArray(caches.activeVideos)
? caches.activeVideos
: []) {
if (compareAddress(candidate)) {
const normalized = normalizeVideoCandidate(candidate);
if (normalized) {
return normalized;
}
}
}
if (!caches.catalogPromise && typeof nostr.fetchVideos === "function") {
caches.catalogPromise = nostr
.fetchVideos(filterOptions)
.catch((error) => {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to fetch video catalog:",
error,
);
}
return [];
});
}
if (caches.catalogPromise) {
try {
const catalog = await caches.catalogPromise;
for (const candidate of Array.isArray(catalog) ? catalog : []) {
if (compareAddress(candidate)) {
const normalized = normalizeVideoCandidate(candidate);
if (normalized) {
return normalized;
}
}
}
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to resolve catalog pointer:",
error,
);
}
}
}
return null;
}
function resolveProfile(pubkey) {
if (typeof pubkey !== "string" || !pubkey.trim()) {
return null;
}
const app = getAppInstance();
if (!app) {
return null;
}
try {
const cacheEntry =
typeof app.getProfileCacheEntry === "function"
? app.getProfileCacheEntry(pubkey)
: null;
if (cacheEntry && typeof cacheEntry === "object") {
return cacheEntry.profile || null;
}
} catch (error) {
if (isDevEnv) {
console.warn(
"[watchHistoryMetadata] Failed to read profile cache entry:",
error,
);
}
}
return null;
}
function reset() {
caches.activeVideos = null;
caches.catalogPromise = null;
}
return {
resolveVideo,
resolveProfile,
reset,
caches,
};
}
+132
View File
@@ -0,0 +1,132 @@
// Run with: node tests/watch-history-feed.test.mjs
import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const noopStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
};
if (typeof globalThis.localStorage === "undefined") {
globalThis.localStorage = noopStorage;
}
if (typeof window.localStorage === "undefined") {
window.localStorage = globalThis.localStorage;
}
const { createFeedEngine, registerWatchHistoryFeed } = await import(
"../js/feedEngine/index.js"
);
function createStubService(history = []) {
let loadCount = 0;
return {
get loadCount() {
return loadCount;
},
loadLatest: async () => {
loadCount += 1;
return history.map((entry) => ({ ...entry }));
},
setHistory(next) {
history = Array.isArray(next) ? next : [];
},
shouldStoreMetadata: () => false,
getLocalMetadata: () => null,
setLocalMetadata: () => {},
removeLocalMetadata: () => {},
};
}
function createResolverFactory() {
return () => ({
resolveVideo: async () => null,
resolveProfile: () => null,
});
}
async function testOrderingByWatchedAt() {
const service = createStubService([
{
pointer: { type: "e", value: "earlier" },
watchedAt: 100,
},
{
pointer: { type: "e", value: "latest" },
watchedAt: 200,
},
]);
const engine = createFeedEngine();
registerWatchHistoryFeed(engine, {
service,
nostr: { shouldIncludeVideo: () => true },
metadataResolverFactory: createResolverFactory(),
});
const result = await engine.runFeed("watch-history", {
runtime: { watchHistory: { actor: "npub-order" } },
});
assert.equal(result.items.length, 2, "feed should include two watch history entries");
assert.equal(
result.items[0].metadata.pointerKey,
"e:latest",
"newest watch event should be first in the feed",
);
assert.equal(
result.items[1].metadata.pointerKey,
"e:earlier",
"older watch event should be last in the feed",
);
assert.ok(
result.items[0].metadata.watchedAt >= result.items[1].metadata.watchedAt,
"items should be sorted by watchedAt descending",
);
}
async function testRemovalRefreshesSource() {
const initialHistory = [
{ pointer: { type: "e", value: "keep" }, watchedAt: 300 },
{ pointer: { type: "e", value: "remove" }, watchedAt: 250 },
];
const service = createStubService(initialHistory);
const engine = createFeedEngine();
registerWatchHistoryFeed(engine, {
service,
nostr: { shouldIncludeVideo: () => true },
metadataResolverFactory: createResolverFactory(),
});
const firstRun = await engine.runFeed("watch-history", {
runtime: { watchHistory: { actor: "npub-refresh" } },
});
assert.equal(firstRun.items.length, 2, "initial run should return full history");
assert.equal(service.loadCount, 1, "loadLatest should be invoked for the initial run");
service.setHistory([{ pointer: { type: "e", value: "keep" }, watchedAt: 300 }]);
const secondRun = await engine.runFeed("watch-history", {
runtime: { watchHistory: { actor: "npub-refresh" } },
});
assert.equal(service.loadCount, 2, "engine should request fresh history after change");
assert.equal(secondRun.items.length, 1, "second run should reflect updated history");
assert.equal(
secondRun.items[0].metadata.pointerKey,
"e:keep",
"remaining pointer should persist after refresh",
);
}
await testOrderingByWatchedAt();
await testRemovalRefreshesSource();
console.log("watch-history-feed.test.mjs completed successfully.");