mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add watch history feed adapter and update renderer
This commit is contained in:
1 parent
b1b0b0f625
commit
7c3af36003
6 files changed
+916
-220
No files matched your search
@@ -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
|
||||
|
||||
@@ -16,3 +16,4 @@ export {
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js";
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
Reference in new issue
Block a user