Refactor watch history actions into controller

This commit is contained in:
thePR0M3TH3AN committed 2025-10-08 07:16:26 -04:00
1 parent 151c547490
commit 9d7efce44e
3 files changed
+393 -223

No files matched your search

+26 -221
View File
@@ -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() {
+3 -2
View File
@@ -606,8 +606,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);
},
+364
View File
@@ -0,0 +1,364 @@
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);
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);
}
}
}