mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Merge pull request #173 from PR0M3TH3AN/codex/update-ensureinitialload-and-loadnextbatch
Fix watch history ensureInitialLoad reset pagination state
This commit is contained in:
6 files changed
+615
-57
No files matched your search
@@ -303,9 +303,8 @@
|
||||
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
|
||||
<div
|
||||
id="profileHistoryGrid"
|
||||
class="hidden"
|
||||
class="watch-history-grid watch-history-grid--modal hidden"
|
||||
role="list"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;"
|
||||
></div>
|
||||
<div id="profileHistorySentinel" class="h-1 w-full"></div>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,34 @@ header img {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Dedicated watch-history grids mirror the home feed layout */
|
||||
.watch-history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Profile modal gets a tighter column and removes outer padding */
|
||||
.watch-history-grid.watch-history-grid--modal {
|
||||
grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.watch-history-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.watch-history-grid.watch-history-grid--modal {
|
||||
grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Cards */
|
||||
.video-card {
|
||||
background-color: var(--color-card);
|
||||
|
||||
@@ -3955,10 +3955,7 @@ class bitvidApp {
|
||||
sentinelSelector: "#profileHistorySentinel",
|
||||
scrollContainerSelector: "#profileHistoryScroll",
|
||||
emptyCopy: "You haven’t watched any videos yet.",
|
||||
beforeInitialLoad: async () => {
|
||||
const actor = this.pubkey || undefined;
|
||||
await nostrClient.fetchWatchHistory(actor);
|
||||
},
|
||||
getActor: async () => this.pubkey || window.app?.pubkey || undefined,
|
||||
});
|
||||
}
|
||||
this.adminModeratorsSection =
|
||||
|
||||
+295
-24
@@ -16,22 +16,47 @@ function toNumber(value, fallback) {
|
||||
}
|
||||
|
||||
export function createWatchHistoryRenderer(config = {}) {
|
||||
const fallbackGetActor = async () => {
|
||||
const candidate =
|
||||
typeof window?.app?.pubkey === "string" && window.app.pubkey.trim()
|
||||
? window.app.pubkey.trim()
|
||||
: "";
|
||||
return candidate || undefined;
|
||||
};
|
||||
|
||||
const mergedConfig = {
|
||||
viewSelector: "#watchHistoryView",
|
||||
gridSelector: "#watchHistoryGrid",
|
||||
loadingSelector: "#watchHistoryLoading",
|
||||
emptySelector: "#watchHistoryEmpty",
|
||||
sentinelSelector: "#watchHistorySentinel",
|
||||
scrollContainerSelector: null,
|
||||
emptyCopy: WATCH_HISTORY_EMPTY_COPY,
|
||||
batchSize: BATCH_SIZE,
|
||||
getActor: fallbackGetActor,
|
||||
beforeInitialLoad: null,
|
||||
getSnapshotFingerprint: null,
|
||||
resolveBatch: (size) => nostrClient.resolveWatchHistory(size),
|
||||
renderGrid: null,
|
||||
...config,
|
||||
};
|
||||
|
||||
const {
|
||||
viewSelector = "#watchHistoryView",
|
||||
gridSelector = "#watchHistoryGrid",
|
||||
loadingSelector = "#watchHistoryLoading",
|
||||
emptySelector = "#watchHistoryEmpty",
|
||||
sentinelSelector = "#watchHistorySentinel",
|
||||
scrollContainerSelector = null,
|
||||
emptyCopy = WATCH_HISTORY_EMPTY_COPY,
|
||||
beforeInitialLoad = async () => {
|
||||
const actor = window.app?.pubkey || undefined;
|
||||
await nostrClient.fetchWatchHistory(actor);
|
||||
},
|
||||
resolveBatch = (size) => nostrClient.resolveWatchHistory(size),
|
||||
renderGrid = null,
|
||||
batchSize = BATCH_SIZE,
|
||||
} = config;
|
||||
viewSelector,
|
||||
gridSelector,
|
||||
loadingSelector,
|
||||
emptySelector,
|
||||
sentinelSelector,
|
||||
scrollContainerSelector,
|
||||
emptyCopy,
|
||||
batchSize,
|
||||
getActor,
|
||||
resolveBatch,
|
||||
renderGrid,
|
||||
} = mergedConfig;
|
||||
|
||||
const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad;
|
||||
const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint;
|
||||
|
||||
const selectors = {
|
||||
view: viewSelector,
|
||||
@@ -42,6 +67,78 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
scrollContainer: scrollContainerSelector,
|
||||
};
|
||||
|
||||
const LOG_PREFIX = "[historyView]";
|
||||
|
||||
const debugLog = (...args) => {
|
||||
if (typeof console !== "undefined") {
|
||||
if (typeof console.debug === "function") {
|
||||
console.debug(LOG_PREFIX, ...args);
|
||||
return;
|
||||
}
|
||||
if (typeof console.log === "function") {
|
||||
console.log(LOG_PREFIX, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveActor = async (hint = null) => {
|
||||
if (typeof hint === "string" && hint.trim()) {
|
||||
return hint.trim();
|
||||
}
|
||||
|
||||
if (typeof getActor === "function") {
|
||||
try {
|
||||
const candidate = await getActor();
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog("failed to resolve actor via getActor", error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const callBeforeInitialLoad = async (context = {}) => {
|
||||
let actor = await resolveActor(context.actor ?? null);
|
||||
const finalContext = { ...context, actor };
|
||||
|
||||
if (typeof providedBeforeInitialLoad === "function") {
|
||||
return providedBeforeInitialLoad(finalContext);
|
||||
}
|
||||
|
||||
const snapshot = await nostrClient.fetchWatchHistory(actor || undefined);
|
||||
|
||||
if (!actor) {
|
||||
try {
|
||||
const ensured = await nostrClient.ensureSessionActor();
|
||||
if (typeof ensured === "string" && ensured.trim()) {
|
||||
actor = ensured.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog("failed to ensure session actor while resolving history", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { actor: actor || null, snapshot };
|
||||
};
|
||||
|
||||
const computeSnapshotFingerprint = async (context = {}) => {
|
||||
const actor = await resolveActor(context.actor ?? null);
|
||||
const finalContext = { ...context, actor };
|
||||
|
||||
if (typeof providedGetSnapshotFingerprint === "function") {
|
||||
return providedGetSnapshotFingerprint(finalContext);
|
||||
}
|
||||
|
||||
if (finalContext.refresh) {
|
||||
await nostrClient.fetchWatchHistory(actor || undefined);
|
||||
}
|
||||
|
||||
return nostrClient.getWatchHistoryFingerprint(actor || undefined);
|
||||
};
|
||||
|
||||
const state = {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
@@ -51,6 +148,8 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
initialized: false,
|
||||
emptyCopy,
|
||||
batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)),
|
||||
actor: null,
|
||||
snapshotFingerprint: null,
|
||||
};
|
||||
|
||||
const query = (selector) => {
|
||||
@@ -103,10 +202,12 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
|
||||
const cleanupObservers = () => {
|
||||
if (state.observer) {
|
||||
debugLog("disconnecting intersection observer");
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
if (state.scrollListener?.target && state.scrollListener?.handler) {
|
||||
debugLog("removing scroll listener");
|
||||
state.scrollListener.target.removeEventListener(
|
||||
"scroll",
|
||||
state.scrollListener.handler
|
||||
@@ -128,6 +229,7 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
setLoadingVisible(false);
|
||||
state.hasMore = false;
|
||||
cleanupObservers();
|
||||
debugLog("showing empty state", { message });
|
||||
};
|
||||
|
||||
const mergeResolvedVideos = (nextVideos) => {
|
||||
@@ -180,6 +282,11 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
|
||||
const loadNextBatch = async ({ initial = false } = {}) => {
|
||||
if (state.isLoading || !state.hasMore) {
|
||||
debugLog("skipping loadNextBatch", {
|
||||
reason: state.isLoading ? "already loading" : "no more items",
|
||||
initial,
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
});
|
||||
if (initial && !state.isLoading && state.resolvedVideos.length === 0) {
|
||||
setLoadingVisible(false);
|
||||
}
|
||||
@@ -187,9 +294,18 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
}
|
||||
|
||||
state.isLoading = true;
|
||||
debugLog("loading next batch", {
|
||||
initial,
|
||||
batchSize: state.batchSize,
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
});
|
||||
|
||||
try {
|
||||
const videos = await resolveBatch(state.batchSize);
|
||||
debugLog("resolveBatch completed", {
|
||||
initial,
|
||||
received: Array.isArray(videos) ? videos.length : "non-array",
|
||||
});
|
||||
|
||||
if (!Array.isArray(videos) || videos.length === 0) {
|
||||
if (initial && state.resolvedVideos.length === 0) {
|
||||
@@ -198,6 +314,10 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
state.hasMore = false;
|
||||
cleanupObservers();
|
||||
setLoadingVisible(false);
|
||||
debugLog("no videos returned", {
|
||||
initial,
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +325,10 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
mergeResolvedVideos(videos);
|
||||
renderResolvedVideos();
|
||||
setLoadingVisible(false);
|
||||
debugLog("rendered videos", {
|
||||
totalResolved: state.resolvedVideos.length,
|
||||
initial,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[historyView] Failed to resolve watch history:", error);
|
||||
if (initial && state.resolvedVideos.length === 0) {
|
||||
@@ -214,20 +338,33 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
debugLog("loadNextBatch finished", {
|
||||
initial,
|
||||
hasMore: state.hasMore,
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const attachObservers = () => {
|
||||
const { sentinel, scrollContainer } = getElements();
|
||||
if (!sentinel || !state.hasMore) {
|
||||
debugLog("skipping observer attachment", {
|
||||
hasSentinel: Boolean(sentinel),
|
||||
hasMore: state.hasMore,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
debugLog("attaching intersection observer", {
|
||||
useScrollContainer: Boolean(scrollContainer),
|
||||
});
|
||||
state.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
debugLog("sentinel intersected");
|
||||
loadNextBatch();
|
||||
}
|
||||
});
|
||||
@@ -248,6 +385,7 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
? scrollContainer.clientHeight
|
||||
: window.innerHeight;
|
||||
if (rect.top <= threshold + 200) {
|
||||
debugLog("scroll threshold met");
|
||||
loadNextBatch();
|
||||
}
|
||||
};
|
||||
@@ -255,29 +393,80 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
target.addEventListener("scroll", handler, { passive: true });
|
||||
state.scrollListener = { target, handler };
|
||||
handler();
|
||||
debugLog("attached scroll listener", {
|
||||
useScrollContainer: Boolean(scrollContainer),
|
||||
});
|
||||
};
|
||||
|
||||
const runInitialLoad = async () => {
|
||||
const runInitialLoad = async (options = {}) => {
|
||||
const { actor: actorHint = null, prefetched = null, fingerprintOverride } =
|
||||
typeof options === "object" && options !== null ? options : {};
|
||||
|
||||
const { view } = getElements();
|
||||
if (!view) {
|
||||
debugLog("runInitialLoad aborted: view not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedActor = await resolveActor(actorHint ?? state.actor);
|
||||
state.actor = resolvedActor || null;
|
||||
|
||||
cleanupObservers();
|
||||
state.resolvedVideos = [];
|
||||
state.hasMore = true;
|
||||
state.isLoading = false;
|
||||
state.initialized = false;
|
||||
state.snapshotFingerprint = null;
|
||||
resetUiState();
|
||||
debugLog("starting initial load", { actor: state.actor });
|
||||
|
||||
try {
|
||||
await beforeInitialLoad?.();
|
||||
} catch (error) {
|
||||
console.error("[historyView] Failed to fetch watch history list:", error);
|
||||
showEmptyState(
|
||||
"We couldn't load your watch history. Please try again later."
|
||||
);
|
||||
return;
|
||||
let beforeResult;
|
||||
const hasPrefetched =
|
||||
prefetched &&
|
||||
Object.prototype.hasOwnProperty.call(prefetched, "beforeResult");
|
||||
|
||||
if (hasPrefetched) {
|
||||
beforeResult = prefetched.beforeResult;
|
||||
} else {
|
||||
try {
|
||||
beforeResult = await callBeforeInitialLoad({ actor: state.actor });
|
||||
} catch (error) {
|
||||
console.error("[historyView] Failed to fetch watch history list:", error);
|
||||
showEmptyState(
|
||||
"We couldn't load your watch history. Please try again later."
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
beforeResult &&
|
||||
typeof beforeResult === "object" &&
|
||||
typeof beforeResult.actor === "string" &&
|
||||
beforeResult.actor.trim()
|
||||
) {
|
||||
state.actor = beforeResult.actor.trim();
|
||||
}
|
||||
|
||||
let fingerprint;
|
||||
if (typeof fingerprintOverride !== "undefined") {
|
||||
fingerprint = fingerprintOverride;
|
||||
} else {
|
||||
try {
|
||||
fingerprint = await computeSnapshotFingerprint({
|
||||
actor: state.actor,
|
||||
beforeResult,
|
||||
refresh: false,
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog("failed to compute snapshot fingerprint", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof fingerprint === "string") {
|
||||
state.snapshotFingerprint = fingerprint;
|
||||
} else {
|
||||
state.snapshotFingerprint = null;
|
||||
}
|
||||
|
||||
await loadNextBatch({ initial: true });
|
||||
@@ -287,45 +476,127 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
}
|
||||
|
||||
state.initialized = true;
|
||||
debugLog("initial load complete", {
|
||||
hasMore: state.hasMore,
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
actor: state.actor,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
async init() {
|
||||
debugLog("init called");
|
||||
await runInitialLoad();
|
||||
},
|
||||
async ensureInitialLoad() {
|
||||
const { view } = getElements();
|
||||
if (!view) {
|
||||
debugLog("ensureInitialLoad aborted: view not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.initialized) {
|
||||
debugLog("ensureInitialLoad rerunning initial load (not initialized)");
|
||||
await runInitialLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
state.actor = (await resolveActor(state.actor)) || null;
|
||||
|
||||
if (!state.isLoading) {
|
||||
try {
|
||||
const beforeResult = await callBeforeInitialLoad({
|
||||
actor: state.actor,
|
||||
reason: "ensureInitialLoad",
|
||||
});
|
||||
if (
|
||||
beforeResult &&
|
||||
typeof beforeResult === "object" &&
|
||||
typeof beforeResult.actor === "string" &&
|
||||
beforeResult.actor.trim()
|
||||
) {
|
||||
state.actor = beforeResult.actor.trim();
|
||||
}
|
||||
const fingerprint = await computeSnapshotFingerprint({
|
||||
actor: state.actor,
|
||||
beforeResult,
|
||||
refresh: false,
|
||||
});
|
||||
|
||||
if (typeof fingerprint === "string") {
|
||||
if (fingerprint !== state.snapshotFingerprint) {
|
||||
debugLog("ensureInitialLoad detected new snapshot", {
|
||||
previous: state.snapshotFingerprint,
|
||||
next: fingerprint,
|
||||
actor: state.actor,
|
||||
});
|
||||
await runInitialLoad({
|
||||
actor: state.actor,
|
||||
prefetched: { beforeResult },
|
||||
fingerprintOverride: fingerprint,
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.snapshotFingerprint = fingerprint;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[historyView] Failed to refresh watch history snapshot:",
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", {
|
||||
actor: state.actor,
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.resolvedVideos.length && !state.isLoading) {
|
||||
if (!state.hasMore) {
|
||||
debugLog("ensureInitialLoad resetting hasMore before retry");
|
||||
state.hasMore = true;
|
||||
}
|
||||
debugLog("ensureInitialLoad requesting new batch", {
|
||||
hasMore: state.hasMore,
|
||||
});
|
||||
await loadNextBatch({ initial: true });
|
||||
|
||||
if (state.hasMore && !state.observer && !state.scrollListener) {
|
||||
debugLog("ensureInitialLoad reattaching observers");
|
||||
attachObservers();
|
||||
}
|
||||
} else {
|
||||
debugLog("ensureInitialLoad no action", {
|
||||
resolvedCount: state.resolvedVideos.length,
|
||||
isLoading: state.isLoading,
|
||||
hasMore: state.hasMore,
|
||||
});
|
||||
}
|
||||
},
|
||||
async loadMore() {
|
||||
debugLog("loadMore called");
|
||||
await loadNextBatch();
|
||||
},
|
||||
resume() {
|
||||
cleanupObservers();
|
||||
if (state.hasMore) {
|
||||
debugLog("resume attaching observers");
|
||||
attachObservers();
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
debugLog("pause called");
|
||||
cleanupObservers();
|
||||
},
|
||||
destroy() {
|
||||
debugLog("destroy called");
|
||||
cleanupObservers();
|
||||
state.initialized = false;
|
||||
state.resolvedVideos = [];
|
||||
state.hasMore = true;
|
||||
state.isLoading = false;
|
||||
state.actor = null;
|
||||
state.snapshotFingerprint = null;
|
||||
resetUiState();
|
||||
setLoadingVisible(false);
|
||||
},
|
||||
|
||||
+289
-22
@@ -185,19 +185,6 @@ function normalizePointerInput(pointer) {
|
||||
return { type, value: trimmed, relay: null };
|
||||
}
|
||||
|
||||
function pointerToTag(pointer) {
|
||||
const normalized = clonePointerItem(pointer);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tag = [normalized.type, normalized.value];
|
||||
if (normalized.relay) {
|
||||
tag.push(normalized.relay);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
function extractPointerItemsFromEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return [];
|
||||
@@ -225,6 +212,53 @@ function extractPointerItemsFromEvent(event) {
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizePointersFromPayload(payload) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const source = Array.isArray(payload.items) ? payload.items : [];
|
||||
for (const candidate of source) {
|
||||
const pointer = normalizePointerInput(candidate);
|
||||
if (!pointer) {
|
||||
continue;
|
||||
}
|
||||
const key = pointerKey(pointer);
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(pointer);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseWatchHistoryPayload(plaintext) {
|
||||
if (typeof plaintext !== "string") {
|
||||
return { version: 0, items: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(plaintext);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { version: 0, items: [] };
|
||||
}
|
||||
|
||||
const version = Number.isFinite(parsed.version) ? parsed.version : 0;
|
||||
const items = normalizePointersFromPayload(parsed);
|
||||
return { version, items };
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to parse watch history payload:", error);
|
||||
}
|
||||
return { version: 0, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function eventToAddressPointer(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return "";
|
||||
@@ -935,6 +969,215 @@ class NostrClient {
|
||||
}
|
||||
}
|
||||
|
||||
getWatchHistoryFingerprint(pubkeyOrSession) {
|
||||
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 "";
|
||||
}
|
||||
|
||||
const pointerId =
|
||||
entry.pointerEvent && typeof entry.pointerEvent.id === "string"
|
||||
? entry.pointerEvent.id
|
||||
: "";
|
||||
const pointerCreated =
|
||||
entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at)
|
||||
? entry.pointerEvent.created_at
|
||||
: 0;
|
||||
const itemKeys = entry.items
|
||||
.map((item) => pointerKey(item))
|
||||
.filter(Boolean)
|
||||
.join("|");
|
||||
|
||||
return `${pointerId}:${pointerCreated}:${itemKeys}`;
|
||||
}
|
||||
|
||||
async encryptWatchHistoryPayload(actorPubkey, payload) {
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" && actorPubkey.trim()
|
||||
? actorPubkey.trim().toLowerCase()
|
||||
: "";
|
||||
if (!normalizedActor) {
|
||||
return { ok: false, error: "invalid-actor" };
|
||||
}
|
||||
|
||||
let normalizedPayload = payload;
|
||||
if (!normalizedPayload || typeof normalizedPayload !== "object") {
|
||||
normalizedPayload = {};
|
||||
}
|
||||
|
||||
const items = Array.isArray(normalizedPayload.items)
|
||||
? normalizedPayload.items
|
||||
: [];
|
||||
|
||||
const prepared = {
|
||||
version: Number.isFinite(normalizedPayload.version)
|
||||
? normalizedPayload.version
|
||||
: 1,
|
||||
items,
|
||||
};
|
||||
|
||||
const plaintext = JSON.stringify(prepared);
|
||||
|
||||
let ciphertext = "";
|
||||
const normalizedLogged =
|
||||
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
|
||||
|
||||
if (
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
window?.nostr?.nip04?.encrypt
|
||||
) {
|
||||
try {
|
||||
ciphertext = await window.nostr.nip04.encrypt(
|
||||
normalizedActor,
|
||||
plaintext
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to encrypt watch history with extension:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
try {
|
||||
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
|
||||
await this.ensureSessionActor(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to ensure session actor while encrypting watch history:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const privateKey = this.sessionActor?.privateKey;
|
||||
if (privateKey && window?.NostrTools?.nip04?.encrypt) {
|
||||
try {
|
||||
ciphertext = await window.NostrTools.nip04.encrypt(
|
||||
privateKey,
|
||||
normalizedActor,
|
||||
plaintext
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to encrypt watch history with session key:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
return { ok: false, error: "encryption-unavailable" };
|
||||
}
|
||||
|
||||
return { ok: true, ciphertext };
|
||||
}
|
||||
|
||||
async decryptWatchHistoryEvent(pointerEvent, actorPubkey) {
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" && actorPubkey.trim()
|
||||
? actorPubkey.trim().toLowerCase()
|
||||
: "";
|
||||
if (!normalizedActor) {
|
||||
return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) };
|
||||
}
|
||||
|
||||
const ciphertext =
|
||||
pointerEvent && typeof pointerEvent.content === "string"
|
||||
? pointerEvent.content.trim()
|
||||
: "";
|
||||
|
||||
if (!ciphertext) {
|
||||
return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) };
|
||||
}
|
||||
|
||||
const normalizedLogged =
|
||||
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
|
||||
|
||||
if (
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
window?.nostr?.nip04?.decrypt
|
||||
) {
|
||||
try {
|
||||
const plaintext = await window.nostr.nip04.decrypt(
|
||||
normalizedActor,
|
||||
ciphertext
|
||||
);
|
||||
const parsed = parseWatchHistoryPayload(plaintext);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to decrypt watch history with extension:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
|
||||
await this.ensureSessionActor(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to ensure session actor while decrypting watch history:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const privateKey = this.sessionActor?.privateKey;
|
||||
if (privateKey && window?.NostrTools?.nip04?.decrypt) {
|
||||
try {
|
||||
const plaintext = await window.NostrTools.nip04.decrypt(
|
||||
privateKey,
|
||||
normalizedActor,
|
||||
ciphertext
|
||||
);
|
||||
const parsed = parseWatchHistoryPayload(plaintext);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to decrypt watch history with session key:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: 0, items: extractPointerItemsFromEvent(pointerEvent) };
|
||||
}
|
||||
|
||||
cancelWatchHistoryRepublish(actor) {
|
||||
const key =
|
||||
typeof actor === "string" && actor.trim()
|
||||
@@ -1037,20 +1280,37 @@ class NostrClient {
|
||||
? normalizedItems.slice(0, WATCH_HISTORY_MAX_ITEMS)
|
||||
: normalizedItems;
|
||||
|
||||
const tags = [["d", WATCH_HISTORY_LIST_IDENTIFIER]];
|
||||
for (const item of trimmedItems) {
|
||||
const tag = pointerToTag(item);
|
||||
if (tag) {
|
||||
tags.push(tag);
|
||||
const payloadItems = trimmedItems.map((item) => ({
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
}));
|
||||
|
||||
const encryptionResult = await this.encryptWatchHistoryPayload(
|
||||
normalizedActor,
|
||||
{ version: 1, items: payloadItems }
|
||||
);
|
||||
if (!encryptionResult.ok) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Unable to encrypt watch history snapshot:",
|
||||
encryptionResult.error
|
||||
);
|
||||
}
|
||||
return encryptionResult;
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", WATCH_HISTORY_LIST_IDENTIFIER],
|
||||
["encrypted", "nip04"],
|
||||
];
|
||||
|
||||
const event = {
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
pubkey: normalizedActor,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
content: encryptionResult.ciphertext,
|
||||
};
|
||||
|
||||
let signedEvent;
|
||||
@@ -1518,8 +1778,11 @@ class NostrClient {
|
||||
}
|
||||
|
||||
const previousEntry = this.watchHistoryCache.get(actor) || null;
|
||||
const remoteItems = pointerEvent
|
||||
? extractPointerItemsFromEvent(pointerEvent)
|
||||
const decryptedPayload = pointerEvent
|
||||
? await this.decryptWatchHistoryEvent(pointerEvent, actor)
|
||||
: { version: 0, items: [] };
|
||||
const remoteItems = Array.isArray(decryptedPayload.items)
|
||||
? decryptedPayload.items
|
||||
: [];
|
||||
|
||||
let items = remoteItems;
|
||||
@@ -1535,7 +1798,11 @@ class NostrClient {
|
||||
pointerForEntry = storedPointerEvent;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
const shouldFallback =
|
||||
(!pointerEvent && !items.length) ||
|
||||
(pointerEvent && decryptedPayload.version === 0 && !items.length);
|
||||
|
||||
if (shouldFallback) {
|
||||
if (previousEntry?.items?.length) {
|
||||
items = previousEntry.items;
|
||||
usedFallback = true;
|
||||
|
||||
+1
-5
@@ -6,11 +6,7 @@
|
||||
<div id="watchHistoryLoading" class="flex justify-center py-12">
|
||||
<div class="h-10 w-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div
|
||||
id="watchHistoryGrid"
|
||||
class="hidden"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem;"
|
||||
></div>
|
||||
<div id="watchHistoryGrid" class="watch-history-grid hidden"></div>
|
||||
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
|
||||
Your watch history is empty. Watch some videos to populate this list.
|
||||
</p>
|
||||
|
||||
Reference in new issue
Block a user