mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 02:12:26 +00:00
Merge pull request #355 from PR0M3TH3AN/codex/refactor-watch-history-handling-into-controller
Refactor watch history handling into controller
This commit is contained in:
5 files changed
+663
-258
No files matched your search
@@ -1,10 +1,6 @@
|
||||
// js/app.js
|
||||
|
||||
import {
|
||||
nostrClient,
|
||||
normalizePointerInput,
|
||||
pointerKey as derivePointerKey,
|
||||
} from "./nostr.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { torrentClient } from "./webtorrent.js";
|
||||
import {
|
||||
isDevMode,
|
||||
@@ -40,6 +36,7 @@ import AuthService from "./services/authService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import { initQuickR2Upload } from "./r2-quick.js";
|
||||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||
import WatchHistoryController from "./ui/watchHistoryController.js";
|
||||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import {
|
||||
@@ -209,6 +206,17 @@ class Application {
|
||||
this.registerSubscriptionsFeed();
|
||||
this.registerWatchHistoryFeed();
|
||||
|
||||
this.watchHistoryController = new WatchHistoryController({
|
||||
watchHistoryService,
|
||||
nostrClient,
|
||||
showError: (message) => this.showError(message),
|
||||
showSuccess: (message) => this.showSuccess(message),
|
||||
dropWatchHistoryMetadata: (pointerKey) =>
|
||||
this.dropWatchHistoryMetadata(pointerKey),
|
||||
getActivePubkey: () =>
|
||||
typeof this.pubkey === "string" && this.pubkey ? this.pubkey : "",
|
||||
});
|
||||
|
||||
this.playbackService =
|
||||
services.playbackService ||
|
||||
new PlaybackService({
|
||||
@@ -3366,244 +3374,41 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
buildPointerFromDataset(dataset = {}) {
|
||||
if (!dataset || typeof dataset !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeValue = typeof dataset.pointerType === "string" ? dataset.pointerType : "";
|
||||
const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : "";
|
||||
const value =
|
||||
typeof dataset.pointerValue === "string" && dataset.pointerValue.trim()
|
||||
? dataset.pointerValue.trim()
|
||||
: "";
|
||||
|
||||
if (!normalizedType || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointer = { type: normalizedType, value };
|
||||
|
||||
if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) {
|
||||
pointer.relay = dataset.pointerRelay.trim();
|
||||
}
|
||||
|
||||
if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) {
|
||||
const parsed = Number.parseInt(dataset.pointerWatchedAt, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
pointer.watchedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataset.pointerSession === "true") {
|
||||
pointer.session = true;
|
||||
}
|
||||
|
||||
return pointer;
|
||||
}
|
||||
|
||||
async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) {
|
||||
const pointer = this.buildPointerFromDataset(dataset);
|
||||
const pointerKeyValue =
|
||||
typeof dataset.pointerKey === "string" && dataset.pointerKey.trim()
|
||||
? dataset.pointerKey.trim()
|
||||
: pointer
|
||||
? derivePointerKey(normalizePointerInput(pointer)) || ""
|
||||
: "";
|
||||
|
||||
if (!pointerKeyValue) {
|
||||
this.showError("Unable to determine which history entry to remove.");
|
||||
if (!this.watchHistoryController) {
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
removed: {
|
||||
pointer,
|
||||
pointerKey: pointerKeyValue,
|
||||
},
|
||||
reason: dataset.reason || "remove-item",
|
||||
};
|
||||
|
||||
let card = null;
|
||||
if (trigger instanceof HTMLElement) {
|
||||
card = trigger.closest(".video-card");
|
||||
if (card instanceof HTMLElement) {
|
||||
card.dataset.historyRemovalPending = "true";
|
||||
card.classList.add("opacity-60");
|
||||
card.classList.add("pointer-events-none");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handleWatchHistoryRemoval(payload);
|
||||
await this.watchHistoryController.removeEntry({
|
||||
dataset,
|
||||
trigger,
|
||||
removeCard: dataset.removeCard === "true",
|
||||
reason: dataset.reason || "remove-item",
|
||||
});
|
||||
} catch (error) {
|
||||
if (card instanceof HTMLElement) {
|
||||
delete card.dataset.historyRemovalPending;
|
||||
card.classList.remove("opacity-60", "pointer-events-none");
|
||||
}
|
||||
if (!error?.handled) {
|
||||
this.showError("Failed to remove from history. Please try again.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (card instanceof HTMLElement) {
|
||||
delete card.dataset.historyRemovalPending;
|
||||
card.classList.remove("opacity-60", "pointer-events-none");
|
||||
if (dataset.removeCard === "true") {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleWatchHistoryRemoval(payload = {}) {
|
||||
if (!watchHistoryService?.isEnabled?.()) {
|
||||
if (!this.watchHistoryController) {
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "remove-item";
|
||||
|
||||
const actorCandidate =
|
||||
typeof payload.actor === "string" && payload.actor.trim()
|
||||
? payload.actor.trim()
|
||||
: typeof this.pubkey === "string" && this.pubkey.trim()
|
||||
? this.pubkey.trim()
|
||||
: typeof nostrClient?.sessionActor?.pubkey === "string" &&
|
||||
nostrClient.sessionActor.pubkey
|
||||
? nostrClient.sessionActor.pubkey
|
||||
: "";
|
||||
|
||||
const removedPointerRaw =
|
||||
payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null;
|
||||
const removedPointerNormalized = normalizePointerInput(removedPointerRaw);
|
||||
let removedPointerKey =
|
||||
typeof payload?.removed?.pointerKey === "string"
|
||||
? payload.removed.pointerKey
|
||||
: "";
|
||||
if (!removedPointerKey && removedPointerNormalized) {
|
||||
removedPointerKey = derivePointerKey(removedPointerNormalized) || "";
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
this.dropWatchHistoryMetadata(removedPointerKey);
|
||||
}
|
||||
|
||||
const normalizeEntry = (entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const pointer = normalizePointerInput(entry.pointer || entry);
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
if (Number.isFinite(entry.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.watchedAt);
|
||||
} else if (Number.isFinite(entry.pointer?.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.pointer.watchedAt);
|
||||
}
|
||||
if (entry.pointer?.session === true || entry.session === true) {
|
||||
pointer.session = true;
|
||||
}
|
||||
return pointer;
|
||||
};
|
||||
|
||||
let normalizedItems = null;
|
||||
|
||||
if (Array.isArray(payload.items) && payload.items.length) {
|
||||
normalizedItems = payload.items.map(normalizeEntry).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!normalizedItems) {
|
||||
try {
|
||||
const latest = await watchHistoryService.loadLatest(actorCandidate);
|
||||
normalizedItems = Array.isArray(latest)
|
||||
? latest.map(normalizeEntry).filter(Boolean)
|
||||
: [];
|
||||
} catch (error) {
|
||||
this.showError("Failed to load watch history. Please try again.");
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
normalizedItems = normalizedItems.filter((entry) => {
|
||||
try {
|
||||
return derivePointerKey(entry) !== removedPointerKey;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.showSuccess("Removing from history…");
|
||||
|
||||
try {
|
||||
const snapshotResult = await watchHistoryService.snapshot(normalizedItems, {
|
||||
actor: actorCandidate || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
try {
|
||||
await nostrClient.updateWatchHistoryList(normalizedItems, {
|
||||
actorPubkey: actorCandidate || undefined,
|
||||
replace: true,
|
||||
source: reason,
|
||||
});
|
||||
} catch (updateError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistory] Failed to update local watch history list:",
|
||||
updateError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.showSuccess(
|
||||
"Removed from encrypted history. Relay sync may take a moment.",
|
||||
);
|
||||
|
||||
return { handledToasts: true, snapshot: snapshotResult };
|
||||
} catch (error) {
|
||||
let message = "Failed to remove from history. Please try again.";
|
||||
if (error?.result?.retryable) {
|
||||
message =
|
||||
"Removal will retry once encrypted history is accepted by your relays.";
|
||||
}
|
||||
this.showError(message);
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
|
||||
}
|
||||
|
||||
flushWatchHistory(reason = "session-end", context = "watch-history") {
|
||||
if (!watchHistoryService?.isEnabled?.()) {
|
||||
if (!this.watchHistoryController) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
const result = watchHistoryService.snapshot(undefined, { reason });
|
||||
return Promise.resolve(result).catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Watch history flush failed:`, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Failed to queue watch history flush:`, error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return this.watchHistoryController.flush(reason, context);
|
||||
}
|
||||
|
||||
getActiveViewIdentityKey() {
|
||||
|
||||
+66
-3
@@ -572,7 +572,9 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
});
|
||||
return engine.run("watch-history", { runtime });
|
||||
}
|
||||
const items = await watchHistoryService.loadLatest(actorInput);
|
||||
const items = await watchHistoryService.loadLatest(actorInput, {
|
||||
allowStale: true,
|
||||
});
|
||||
const normalized = normalizeHistoryItems(items);
|
||||
return { items: normalized, metadata: { engine: "service-fallback" } };
|
||||
},
|
||||
@@ -606,8 +608,9 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
batchSize = WATCH_HISTORY_BATCH_SIZE,
|
||||
remove = (payload) => {
|
||||
const app = getAppInstance();
|
||||
if (app?.handleWatchHistoryRemoval) {
|
||||
return app.handleWatchHistoryRemoval(payload);
|
||||
const controller = app?.watchHistoryController;
|
||||
if (controller?.handleWatchHistoryRemoval) {
|
||||
return controller.handleWatchHistoryRemoval(payload);
|
||||
}
|
||||
return defaultRemoveHandler(payload);
|
||||
},
|
||||
@@ -653,6 +656,12 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
featureEnabled: syncEnabled || localSupported,
|
||||
};
|
||||
|
||||
let rendererRef = null;
|
||||
let fingerprintRefreshQueued = false;
|
||||
const scheduleTask =
|
||||
typeof queueMicrotask === "function"
|
||||
? queueMicrotask
|
||||
: (callback) => Promise.resolve().then(callback);
|
||||
let elements = {
|
||||
view: null,
|
||||
grid: null,
|
||||
@@ -732,6 +741,7 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
}
|
||||
}
|
||||
subscriptions.clear();
|
||||
fingerprintRefreshQueued = false;
|
||||
}
|
||||
|
||||
function clearToasts() {
|
||||
@@ -906,6 +916,50 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function queueFingerprintRefresh() {
|
||||
if (!fingerprintRefreshQueued) {
|
||||
return;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!rendererRef) {
|
||||
return;
|
||||
}
|
||||
fingerprintRefreshQueued = false;
|
||||
void rendererRef.refresh({ force: false });
|
||||
}
|
||||
|
||||
function subscribeToFingerprintUpdates() {
|
||||
if (typeof watchHistoryService.subscribe !== "function") {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = watchHistoryService.subscribe(
|
||||
"fingerprint",
|
||||
(payload) => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
payload.actor &&
|
||||
state.actor &&
|
||||
payload.actor !== state.actor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fingerprintRefreshQueued = true;
|
||||
if (!state.isLoading) {
|
||||
scheduleTask(() => {
|
||||
queueFingerprintRefresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
subscriptions.add(unsubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoadingState(loading) {
|
||||
state.isLoading = loading;
|
||||
if (elements.loading) {
|
||||
@@ -1545,6 +1599,11 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
result = null;
|
||||
}
|
||||
setLoadingState(false);
|
||||
if (fingerprintRefreshQueued) {
|
||||
scheduleTask(() => {
|
||||
queueFingerprintRefresh();
|
||||
});
|
||||
}
|
||||
const feedItems = Array.isArray(result?.items) ? result.items : [];
|
||||
const normalized = [];
|
||||
state.metadataCache.clear();
|
||||
@@ -1643,6 +1702,7 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
bindMetadataToggle();
|
||||
subscribeToMetadataPreference();
|
||||
subscribeToRepublishEvents();
|
||||
subscribeToFingerprintUpdates();
|
||||
updateInfoCallout();
|
||||
state.metadataStorageEnabled =
|
||||
typeof watchHistoryService.shouldStoreMetadata === "function"
|
||||
@@ -1786,6 +1846,8 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
state.cursor = 0;
|
||||
state.hasMore = false;
|
||||
state.metadataCache.clear();
|
||||
rendererRef = null;
|
||||
fingerprintRefreshQueued = false;
|
||||
},
|
||||
async handleRemove(pointerKeyValue) {
|
||||
if (!pointerKeyValue) {
|
||||
@@ -1883,6 +1945,7 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
rendererRef = renderer;
|
||||
return renderer;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import {
|
||||
normalizePointerInput,
|
||||
pointerKey as derivePointerKey,
|
||||
} from "../nostr.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function resolveCardElement(trigger) {
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = trigger.closest(".video-card");
|
||||
return candidate instanceof HTMLElement ? candidate : null;
|
||||
}
|
||||
|
||||
function safeInvoke(callback, payload) {
|
||||
if (typeof callback !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] onStateChange handler failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class WatchHistoryController {
|
||||
constructor({
|
||||
watchHistoryService,
|
||||
nostrClient,
|
||||
showError,
|
||||
showSuccess,
|
||||
dropWatchHistoryMetadata,
|
||||
getActivePubkey,
|
||||
} = {}) {
|
||||
this.watchHistoryService = watchHistoryService || null;
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.showError = typeof showError === "function" ? showError : noop;
|
||||
this.showSuccess = typeof showSuccess === "function" ? showSuccess : noop;
|
||||
this.dropWatchHistoryMetadata =
|
||||
typeof dropWatchHistoryMetadata === "function"
|
||||
? dropWatchHistoryMetadata
|
||||
: noop;
|
||||
this.getActivePubkey =
|
||||
typeof getActivePubkey === "function" ? getActivePubkey : () => "";
|
||||
}
|
||||
|
||||
buildPointerFromDataset(dataset = {}) {
|
||||
if (!dataset || typeof dataset !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeValue =
|
||||
typeof dataset.pointerType === "string" ? dataset.pointerType : "";
|
||||
const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : "";
|
||||
const value =
|
||||
typeof dataset.pointerValue === "string" && dataset.pointerValue.trim()
|
||||
? dataset.pointerValue.trim()
|
||||
: "";
|
||||
|
||||
if (!normalizedType || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointer = { type: normalizedType, value };
|
||||
|
||||
if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) {
|
||||
pointer.relay = dataset.pointerRelay.trim();
|
||||
}
|
||||
|
||||
if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) {
|
||||
const parsed = Number.parseInt(dataset.pointerWatchedAt, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
pointer.watchedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataset.pointerSession === "true") {
|
||||
pointer.session = true;
|
||||
}
|
||||
|
||||
return pointer;
|
||||
}
|
||||
|
||||
resolvePointerKey(pointer, explicitKey) {
|
||||
if (typeof explicitKey === "string" && explicitKey.trim()) {
|
||||
return explicitKey.trim();
|
||||
}
|
||||
|
||||
const normalized = normalizePointerInput(pointer);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return derivePointerKey(normalized) || "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] Failed to derive pointer key:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
applyCardState(card, { status, removeCard } = {}) {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
card.dataset.historyRemovalPending = "true";
|
||||
card.classList.add("opacity-60");
|
||||
card.classList.add("pointer-events-none");
|
||||
return;
|
||||
}
|
||||
|
||||
delete card.dataset.historyRemovalPending;
|
||||
card.classList.remove("opacity-60", "pointer-events-none");
|
||||
|
||||
if (status === "removed" && removeCard) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
|
||||
resolveActor(actor) {
|
||||
if (typeof actor === "string" && actor.trim()) {
|
||||
return actor.trim();
|
||||
}
|
||||
|
||||
const candidate = this.getActivePubkey();
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
|
||||
const sessionPubkey = this.nostrClient?.sessionActor?.pubkey;
|
||||
if (typeof sessionPubkey === "string" && sessionPubkey.trim()) {
|
||||
return sessionPubkey.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async removeEntry(options = {}) {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
dataset = {},
|
||||
pointer: pointerInput = null,
|
||||
pointerKey: explicitPointerKey = "",
|
||||
reason: providedReason,
|
||||
trigger = null,
|
||||
removeCard = false,
|
||||
actor,
|
||||
items,
|
||||
onStateChange,
|
||||
} = options;
|
||||
|
||||
const pointer = pointerInput || this.buildPointerFromDataset(dataset);
|
||||
const pointerKey = this.resolvePointerKey(pointer, explicitPointerKey || dataset.pointerKey);
|
||||
|
||||
if (!pointerKey) {
|
||||
this.showError("Unable to determine which history entry to remove.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof providedReason === "string" && providedReason.trim()
|
||||
? providedReason.trim()
|
||||
: typeof dataset.reason === "string" && dataset.reason.trim()
|
||||
? dataset.reason.trim()
|
||||
: "remove-item";
|
||||
|
||||
const card = resolveCardElement(trigger);
|
||||
this.applyCardState(card, { status: "pending" });
|
||||
safeInvoke(onStateChange, { status: "pending", card, removeCard });
|
||||
|
||||
try {
|
||||
const result = await this.handleWatchHistoryRemoval({
|
||||
actor,
|
||||
items,
|
||||
reason,
|
||||
removed: {
|
||||
pointer,
|
||||
pointerKey,
|
||||
},
|
||||
});
|
||||
|
||||
this.applyCardState(card, { status: removeCard ? "removed" : "idle", removeCard });
|
||||
safeInvoke(onStateChange, {
|
||||
status: removeCard ? "removed" : "idle",
|
||||
card,
|
||||
removeCard,
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.applyCardState(card, { status: "idle" });
|
||||
safeInvoke(onStateChange, { status: "error", card, error, removeCard });
|
||||
|
||||
if (!error?.handled) {
|
||||
this.showError("Failed to remove from history. Please try again.");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWatchHistoryRemoval(payload = {}) {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "remove-item";
|
||||
|
||||
const actorCandidate = this.resolveActor(payload.actor);
|
||||
|
||||
const removedPointerRaw =
|
||||
payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null;
|
||||
const removedPointerNormalized = normalizePointerInput(removedPointerRaw);
|
||||
let removedPointerKey =
|
||||
typeof payload?.removed?.pointerKey === "string"
|
||||
? payload.removed.pointerKey
|
||||
: "";
|
||||
if (!removedPointerKey && removedPointerNormalized) {
|
||||
removedPointerKey = derivePointerKey(removedPointerNormalized) || "";
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
this.dropWatchHistoryMetadata(removedPointerKey);
|
||||
}
|
||||
|
||||
const normalizeEntry = (entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const pointer = normalizePointerInput(entry.pointer || entry);
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
if (Number.isFinite(entry.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.watchedAt);
|
||||
} else if (Number.isFinite(entry.pointer?.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.pointer.watchedAt);
|
||||
}
|
||||
if (entry.pointer?.session === true || entry.session === true) {
|
||||
pointer.session = true;
|
||||
}
|
||||
return pointer;
|
||||
};
|
||||
|
||||
let normalizedItems = null;
|
||||
|
||||
if (Array.isArray(payload.items) && payload.items.length) {
|
||||
normalizedItems = payload.items.map(normalizeEntry).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!normalizedItems) {
|
||||
try {
|
||||
const latest = await this.watchHistoryService.loadLatest(
|
||||
actorCandidate,
|
||||
{ allowStale: false },
|
||||
);
|
||||
normalizedItems = Array.isArray(latest)
|
||||
? latest.map(normalizeEntry).filter(Boolean)
|
||||
: [];
|
||||
} catch (error) {
|
||||
this.showError("Failed to load watch history. Please try again.");
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
normalizedItems = normalizedItems.filter((entry) => {
|
||||
try {
|
||||
return derivePointerKey(entry) !== removedPointerKey;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.showSuccess("Removing from history…");
|
||||
|
||||
try {
|
||||
const snapshotResult = await this.watchHistoryService.snapshot(normalizedItems, {
|
||||
actor: actorCandidate || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.nostrClient?.updateWatchHistoryList?.(normalizedItems, {
|
||||
actorPubkey: actorCandidate || undefined,
|
||||
replace: true,
|
||||
source: reason,
|
||||
});
|
||||
} catch (updateError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] Failed to update local watch history list:",
|
||||
updateError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.showSuccess(
|
||||
"Removed from encrypted history. Relay sync may take a moment.",
|
||||
);
|
||||
|
||||
return { handledToasts: true, snapshot: snapshotResult };
|
||||
} catch (error) {
|
||||
let message = "Failed to remove from history. Please try again.";
|
||||
if (error?.result?.retryable) {
|
||||
message =
|
||||
"Removal will retry once encrypted history is accepted by your relays.";
|
||||
}
|
||||
this.showError(message);
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
flush(reason = "session-end", context = "watch-history") {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
const result = this.watchHistoryService.snapshot(undefined, { reason });
|
||||
return Promise.resolve(result).catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Watch history flush failed:`, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Failed to queue watch history flush:`, error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-33
@@ -1200,12 +1200,61 @@ async function snapshot(items, options = {}) {
|
||||
return run;
|
||||
}
|
||||
|
||||
async function loadLatest(actorInput) {
|
||||
function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) {
|
||||
if (!actorKey) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
if (cacheEntry && typeof cacheEntry === "object" && cacheEntry.promise) {
|
||||
return cacheEntry.promise;
|
||||
}
|
||||
|
||||
const promise = nostrClient
|
||||
.resolveWatchHistory(actorKey, { forceRefresh: true })
|
||||
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
|
||||
.then(() => {
|
||||
const latest = state.fingerprintCache.get(actorKey);
|
||||
return latest?.items || [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to load watch history:", error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
const entry = state.fingerprintCache.get(actorKey);
|
||||
if (entry) {
|
||||
delete entry.promise;
|
||||
}
|
||||
});
|
||||
|
||||
console.info("[watchHistoryService] Triggered nostr watch list refresh.", {
|
||||
actor: actorKey,
|
||||
});
|
||||
|
||||
const baseEntry = cacheEntry && typeof cacheEntry === "object" ? cacheEntry : {};
|
||||
state.fingerprintCache.set(actorKey, {
|
||||
...baseEntry,
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function loadLatest(actorInput, options = {}) {
|
||||
const actorKey = resolveActorKey(actorInput);
|
||||
if (!actorKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? options : {};
|
||||
// Only callers that can react to a later fingerprint update should opt into
|
||||
// stale cache reads; everyone else waits for the fresh list so they do not
|
||||
// miss entries.
|
||||
const allowStale = normalizedOptions.allowStale === true;
|
||||
|
||||
if (!isFeatureEnabled(actorKey)) {
|
||||
if (!state.restored) {
|
||||
restoreQueueState();
|
||||
@@ -1231,12 +1280,13 @@ async function loadLatest(actorInput) {
|
||||
cacheExpiresAt: cacheEntry?.expiresAt || null,
|
||||
}
|
||||
);
|
||||
if (
|
||||
cacheEntry?.items &&
|
||||
cacheEntry.expiresAt &&
|
||||
cacheEntry.expiresAt > now &&
|
||||
Array.isArray(cacheEntry.items)
|
||||
) {
|
||||
const hasCachedItems = Array.isArray(cacheEntry?.items);
|
||||
const cacheIsFresh =
|
||||
hasCachedItems &&
|
||||
cacheEntry?.expiresAt &&
|
||||
cacheEntry.expiresAt > now;
|
||||
|
||||
if (cacheIsFresh) {
|
||||
console.info(
|
||||
"[watchHistoryService] Returning cached watch list items.",
|
||||
{
|
||||
@@ -1254,42 +1304,35 @@ async function loadLatest(actorInput) {
|
||||
actor: actorKey,
|
||||
}
|
||||
);
|
||||
if (allowStale && hasCachedItems) {
|
||||
console.info(
|
||||
"[watchHistoryService] Returning stale watch list items while refresh completes.",
|
||||
{
|
||||
actor: actorKey,
|
||||
itemCount: cacheEntry.items.length,
|
||||
}
|
||||
);
|
||||
return cacheEntry.items;
|
||||
}
|
||||
return cacheEntry.promise;
|
||||
}
|
||||
|
||||
const promise = nostrClient
|
||||
.resolveWatchHistory(actorKey, { forceRefresh: true })
|
||||
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
|
||||
.then(() => {
|
||||
const latest = state.fingerprintCache.get(actorKey);
|
||||
return latest?.items || [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to load watch history:", error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
const entry = state.fingerprintCache.get(actorKey);
|
||||
if (entry) {
|
||||
delete entry.promise;
|
||||
}
|
||||
});
|
||||
if (!allowStale || !hasCachedItems) {
|
||||
return scheduleWatchHistoryRefresh(actorKey, cacheEntry);
|
||||
}
|
||||
|
||||
const refreshPromise = scheduleWatchHistoryRefresh(actorKey, cacheEntry);
|
||||
refreshPromise.catch(() => {});
|
||||
|
||||
console.info(
|
||||
"[watchHistoryService] Triggered nostr watch list refresh.",
|
||||
"[watchHistoryService] Returning stale watch list items while refresh is pending.",
|
||||
{
|
||||
actor: actorKey,
|
||||
itemCount: cacheEntry?.items?.length || 0,
|
||||
}
|
||||
);
|
||||
|
||||
state.fingerprintCache.set(actorKey, {
|
||||
...(cacheEntry || {}),
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
return cacheEntry.items || [];
|
||||
}
|
||||
|
||||
async function getFingerprint(actorInput) {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } =
|
||||
const {
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
} =
|
||||
await import("../js/config.js");
|
||||
const {
|
||||
getWatchHistoryV2Enabled,
|
||||
@@ -986,6 +990,128 @@ async function testWatchHistoryServiceIntegration() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testWatchHistoryStaleCacheRefresh() {
|
||||
console.log("Running watch history stale cache refresh test...");
|
||||
|
||||
const actor = "stale-cache-actor";
|
||||
const originalResolve = nostrClient.resolveWatchHistory;
|
||||
const originalFingerprint = nostrClient.getWatchHistoryFingerprint;
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
watchHistoryService.resetProgress(actor);
|
||||
|
||||
nostrClient.getWatchHistoryFingerprint = async (_actor, items) => {
|
||||
const values = (Array.isArray(items) ? items : []).map((entry) => {
|
||||
if (!entry) {
|
||||
return "";
|
||||
}
|
||||
if (typeof entry.value === "string") {
|
||||
return entry.value;
|
||||
}
|
||||
if (
|
||||
entry.pointer &&
|
||||
typeof entry.pointer.value === "string"
|
||||
) {
|
||||
return entry.pointer.value;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
return `fingerprint:${values.join("|")}`;
|
||||
};
|
||||
|
||||
const firstItems = [
|
||||
{ type: "e", value: "watch-history-old", watchedAt: 1_700_000_000 },
|
||||
];
|
||||
|
||||
let resolveCallCount = 0;
|
||||
nostrClient.resolveWatchHistory = async (requestedActor) => {
|
||||
resolveCallCount += 1;
|
||||
assert.equal(
|
||||
requestedActor,
|
||||
actor,
|
||||
"initial load should request the expected actor",
|
||||
);
|
||||
return firstItems;
|
||||
};
|
||||
|
||||
await watchHistoryService.loadLatest(actor, { allowStale: false });
|
||||
|
||||
const baseNow = originalDateNow();
|
||||
Date.now = () => baseNow + WATCH_HISTORY_CACHE_TTL_MS + 1;
|
||||
|
||||
const refreshedItems = [
|
||||
{ type: "e", value: "watch-history-new", watchedAt: 1_700_000_600 },
|
||||
];
|
||||
|
||||
let refreshResolve;
|
||||
const refreshGate = new Promise((resolve) => {
|
||||
refreshResolve = resolve;
|
||||
});
|
||||
|
||||
resolveCallCount = 0;
|
||||
nostrClient.resolveWatchHistory = async (requestedActor) => {
|
||||
resolveCallCount += 1;
|
||||
assert.equal(
|
||||
requestedActor,
|
||||
actor,
|
||||
"refresh should continue targeting the same actor",
|
||||
);
|
||||
await refreshGate;
|
||||
return refreshedItems;
|
||||
};
|
||||
|
||||
const staleResult = await watchHistoryService.loadLatest(actor, {
|
||||
allowStale: true,
|
||||
});
|
||||
assert.equal(
|
||||
resolveCallCount,
|
||||
1,
|
||||
"stale load should trigger a single background refresh",
|
||||
);
|
||||
assert.equal(
|
||||
staleResult.length,
|
||||
firstItems.length,
|
||||
"stale load should return cached entries immediately",
|
||||
);
|
||||
const staleValue =
|
||||
staleResult[0]?.value || staleResult[0]?.pointer?.value || "";
|
||||
assert.equal(
|
||||
staleValue,
|
||||
"watch-history-old",
|
||||
"stale load should surface the cached pointer value",
|
||||
);
|
||||
|
||||
refreshResolve();
|
||||
await refreshGate;
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
nostrClient.resolveWatchHistory = async () => {
|
||||
throw new Error("loadLatest should rely on refreshed cache");
|
||||
};
|
||||
|
||||
const freshResult = await watchHistoryService.loadLatest(actor);
|
||||
assert.equal(
|
||||
freshResult.length,
|
||||
refreshedItems.length,
|
||||
"fresh load should surface refreshed watch history entries",
|
||||
);
|
||||
const freshValue =
|
||||
freshResult[0]?.value || freshResult[0]?.pointer?.value || "";
|
||||
assert.equal(
|
||||
freshValue,
|
||||
"watch-history-new",
|
||||
"fresh load should include the updated pointer",
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
nostrClient.resolveWatchHistory = originalResolve;
|
||||
nostrClient.getWatchHistoryFingerprint = originalFingerprint;
|
||||
watchHistoryService.resetProgress(actor);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWatchHistoryLocalFallbackWhenDisabled() {
|
||||
console.log("Running watch history local fallback test...");
|
||||
|
||||
@@ -1199,6 +1325,7 @@ await testPublishSnapshotFailureRetry();
|
||||
await testWatchHistoryPartialRelayRetry();
|
||||
await testResolveWatchHistoryBatchingWindow();
|
||||
await testWatchHistoryServiceIntegration();
|
||||
await testWatchHistoryStaleCacheRefresh();
|
||||
await testWatchHistoryLocalFallbackWhenDisabled();
|
||||
await testWatchHistorySyncEnabledForLoggedInUsers();
|
||||
await testWatchHistoryAppLoginFallback();
|
||||
|
||||
Reference in new issue
Block a user