diff --git a/components/profile-modal.html b/components/profile-modal.html
index 3c3728e0..efe700ce 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -303,9 +303,8 @@
diff --git a/css/style.css b/css/style.css
index de146c6e..3d23f26b 100644
--- a/css/style.css
+++ b/css/style.css
@@ -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);
diff --git a/js/app.js b/js/app.js
index 6a16a6d6..3a954fb3 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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 =
diff --git a/js/historyView.js b/js/historyView.js
index ffbad480..0b508d7f 100644
--- a/js/historyView.js
+++ b/js/historyView.js
@@ -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);
},
diff --git a/js/nostr.js b/js/nostr.js
index 7533dc61..e0aca71c 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -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;
diff --git a/views/history.html b/views/history.html
index fa69bf07..795e5ae5 100644
--- a/views/history.html
+++ b/views/history.html
@@ -6,11 +6,7 @@
-
+
Your watch history is empty. Watch some videos to populate this list.