Files
bitvid/js/ui/watchHistoryController.js
T
thePR0M3TH3ANandClaude Opus 4.8 0756dd36d8 fix(watch-history): allow logged-out (local) removal instead of "disabled"
The logged-out delete error: handleWatchHistoryRemoval threw
"watch-history-disabled" whenever the service wasn't synced (isEnabled() false) —
which is the NORMAL state for a logged-out user — so local deletes errored with
"Watch history sync is not available right now" and never touched the local
queue. It conflated "not synced to relays" with "disabled".

Now it only rejects when neither synced NOR local history is supported. For a
local-only actor it proceeds with the snapshot (replace), which rewrites the
local queue (replaceLocalQueue), and SKIPS the relay publish (gated on synced) so
a session-actor list is never pushed to relays. Success copy reflects local vs
synced.

Tests + mutation-verified: local-only removal isn't rejected, snapshots with
replace, and does NOT publish to relays; synced removal still publishes; truly
disabled (no sync, no local) is still rejected. Reverting the gate fails the
local cases.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 19:00:51 -04:00

417 lines
12 KiB
JavaScript

import { isDevMode } from "../config.js";
import {
normalizePointerInput,
pointerKey as derivePointerKey,
} from "../nostr/watchHistory.js";
import { normalizeDesignSystemContext } from "../designSystem.js";
import { devLogger } from "../utils/logger.js";
import { logWatchHistoryDebug } from "../watchHistoryDebug.js";
const noop = () => {};
function resolveCardElement(trigger) {
if (!(trigger instanceof HTMLElement)) {
return null;
}
const candidate = trigger.closest("[data-history-card]");
return candidate instanceof HTMLElement ? candidate : null;
}
function safeInvoke(callback, payload) {
if (typeof callback !== "function") {
return;
}
try {
callback(payload);
} catch (error) {
devLogger.warn(
"[watchHistoryController] onStateChange handler failed:",
error,
);
}
}
export default class WatchHistoryController {
constructor({
watchHistoryService,
nostrClient,
showError,
showSuccess,
dropWatchHistoryMetadata,
getActivePubkey,
designSystem,
} = {}) {
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 : () => "";
this.designSystem = normalizeDesignSystemContext(designSystem);
}
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) {
devLogger.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 = {}) {
const synced = this.watchHistoryService?.isEnabled?.() === true;
// Logged-out / session actors aren't synced to relays, but their history
// lives in the LOCAL queue — removal must still work there. Only reject when
// neither synced nor local history is available.
const localOnly =
!synced && this.watchHistoryService?.supportsLocalHistory?.() === true;
if (!synced && !localOnly) {
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) {
const message =
error?.code === "watch-history-extension-permission-denied"
? error.message
: "Failed to load watch history. Please try again.";
this.showError(message);
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;
}
});
}
logWatchHistoryDebug("watch-history:remove", "info", "removal requested", {
removedPointerKey,
itemsSource: Array.isArray(payload.items) && payload.items.length
? "renderer"
: "loadLatest",
remainingCount: normalizedItems.length,
remainingValues: normalizedItems.map((p) => p?.value).slice(0, 20),
});
this.showSuccess("Removing from history…");
try {
const snapshotResult = await this.watchHistoryService.snapshot(normalizedItems, {
actor: actorCandidate || undefined,
reason,
// Removal must replace the list, not merge the removed item back in.
replace: true,
});
logWatchHistoryDebug("watch-history:remove", "info", "snapshot published", {
ok: snapshotResult?.ok,
empty: snapshotResult?.empty,
skipped: snapshotResult?.skipped,
});
// Only push to relays when synced. For local-only actors the snapshot
// above already rewrote the local queue (replaceLocalQueue); publishing a
// session-actor list to relays would be wrong.
if (synced) {
try {
const updateResult = await this.nostrClient?.updateWatchHistoryList?.(
normalizedItems,
{
actorPubkey: actorCandidate || undefined,
replace: true,
source: reason,
},
);
logWatchHistoryDebug("watch-history:remove", "info", "list update published", {
ok: updateResult?.ok,
finalItemCount: updateResult?.items?.length,
});
} catch (updateError) {
devLogger.warn(
"[watchHistoryController] Failed to update local watch history list:",
updateError,
);
logWatchHistoryDebug("watch-history:remove", "warn", "list update failed", {
error: updateError?.message || String(updateError),
});
}
}
this.showSuccess(
synced
? "Removed from encrypted history. Relay sync may take a moment."
: "Removed from your local history.",
);
return { handledToasts: true, snapshot: snapshotResult };
} catch (error) {
logWatchHistoryDebug("watch-history:remove", "warn", "removal publish failed", {
error: error?.message || String(error),
retryable: error?.result?.retryable,
result: error?.result || null,
});
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) => {
devLogger.warn(
`[${context}] Watch history flush failed:`,
error,
);
throw error;
});
} catch (error) {
devLogger.warn(
`[${context}] Failed to queue watch history flush:`,
error,
);
return Promise.reject(error);
}
}
}