Files
bitvid/js/app/modalCoordinator.js
T
thePR0M3TH3ANandClaude Fable 5 8d45f25962 zaps: make the card + modal zap counter always visible (0 sats when none)
Diagnosis (live, verified): the zap-totals pipeline is correct end to end —
a seeded receipt queries and sums to the right sats — but of 310 kind-9735
receipts reachable on the relays, ZERO reference any bitvid video, so every
badge was legitimately hidden. That reads as "broken" and profile zaps to a
Lightning address never attach to a video, so hidden-when-zero left the
feature invisible in practice.

Show the counter always, like the view counter it sits beside: "0 sats"
until a receipt lands, then the summed total. Card binder and modal render
no longer gate on sats>0; the badge is revealed on bind/open and re-hidden
on modal close (no video open). Live-checked: 158/158 cards render the
badge. Tests spec-corrected with integrity notes.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:43:22 -04:00

716 lines
21 KiB
JavaScript

// js/app/modalCoordinator.js
/**
* Modal open/close lifecycle and controller routing.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
*/
export function createModalCoordinator(deps) {
const {
devLogger,
nostrClient,
recordVideoViewApi,
torrentClient,
watchHistoryService,
isWatchHistoryDebugEnabled,
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
pointerArrayToKey,
pointerKey,
getCanonicalDesignSystemMode,
BITVID_WEBSITE_URL,
} = deps;
// Zap-total badge helpers are LOCAL closures, NOT coordinator methods:
// coordinator methods run with `this` bound to the Application, so a
// cross-method `this.helper()` call only works when app.js also adds an
// explicit delegate for it — a silent landmine (a missing delegate here
// threw on EVERY modal open and broke video playback entirely).
let modalZapTotalUnsub = null;
function teardownModalZapTotal() {
if (typeof modalZapTotalUnsub === "function") {
try {
modalZapTotalUnsub();
} catch (error) {
devLogger.warn("[zapTotals] Failed to tear down modal listener:", error);
}
}
modalZapTotalUnsub = null;
const el =
typeof document !== "undefined"
? document.getElementById("videoZapTotal")
: null;
if (el) {
el.classList.add("hidden");
const text = el.querySelector("[data-zap-total-text]");
if (text) {
text.textContent = "";
}
}
}
// Orange sats badge on the modal's meta row (right side; views stay left).
// Mirrors the card badge: cached total now (also schedules the batched
// receipt fetch), re-rendered when a batch lands, hidden while zero.
function subscribeModalZapTotal(pointer) {
if (
typeof requestVideoZapTotal !== "function" ||
typeof getVideoZapTotalSnapshot !== "function" ||
typeof onZapTotalsChanged !== "function"
) {
return;
}
const el =
typeof document !== "undefined"
? document.getElementById("videoZapTotal")
: null;
if (!el || !pointer) {
return;
}
// Always visible while a video is open (mirrors the view counter); a zero
// is a real value, not noise to hide.
const render = () => {
const sats = getVideoZapTotalSnapshot(pointer);
const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
const text = el.querySelector("[data-zap-total-text]");
if (text) {
text.textContent = `${formatViewCount(value)} sats`;
}
el.classList.remove("hidden");
};
requestVideoZapTotal(pointer);
render();
modalZapTotalUnsub = onZapTotalsChanged(render);
}
return {
normalizeModalTrigger(candidate) {
if (!candidate) {
return null;
}
const doc =
(this.videoModal && this.videoModal.document) ||
(typeof document !== "undefined" ? document : null);
const isElement =
typeof candidate === "object" &&
candidate !== null &&
typeof candidate.nodeType === "number" &&
candidate.nodeType === 1 &&
typeof candidate.focus === "function";
if (!isElement) {
return null;
}
if (doc && typeof doc.contains === "function" && !doc.contains(candidate)) {
return null;
}
return candidate;
},
setLastModalTrigger(candidate) {
this.lastModalTrigger = this.normalizeModalTrigger(candidate);
return this.lastModalTrigger;
},
getDesignSystemMode() {
return getCanonicalDesignSystemMode();
},
isDesignSystemNew() {
return true;
},
/**
* Show the modal and set the "Please stand by" poster on the video.
*/
async showModalWithPoster(video = this.currentVideo, options = {}) {
const result = await this.videoModalController.showModalWithPoster(video, options);
this.cacheTorrentStatusNodes();
return result;
},
applyModalLoadingPoster() {
this.videoModalController.applyModalLoadingPoster();
},
forceRemoveModalPoster(reason = "manual-clear") {
return this.videoModalController.forceRemoveModalPoster(reason);
},
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
const result = await this.videoModalController.ensureVideoModalReady({ ensureVideoElement });
this.modalVideo = result.videoElement;
return result;
},
formatViewCountLabel(total) {
const value = Number.isFinite(total) ? Number(total) : 0;
const label = value === 1 ? "view" : "views";
return `${formatViewCount(value)} ${label}`;
},
pruneDetachedViewCountElements() {
if (this.videoListView) {
this.videoListView.pruneDetachedViewCountElements();
}
},
teardownAllViewCountSubscriptions() {
if (this.videoListView) {
this.videoListView.teardownAllViewCountSubscriptions();
}
},
teardownModalViewCountSubscription() {
if (typeof this.modalViewCountUnsub === "function") {
try {
this.modalViewCountUnsub();
} catch (error) {
devLogger.warn("[viewCount] Failed to tear down modal subscription:", error);
}
}
this.modalViewCountUnsub = null;
teardownModalZapTotal();
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
}
},
subscribeModalViewCount(pointer, pointerKey) {
const viewEl = this.videoModal?.getViewCountElement() || null;
if (!viewEl) {
return;
}
this.teardownModalViewCountSubscription();
if (!pointer || !pointerKey) {
return;
}
if (this.videoModal) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
this.videoModal.setViewCountPointer(pointerKey);
}
subscribeModalZapTotal(pointer);
try {
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
const latestViewEl = this.videoModal?.getViewCountElement() || null;
if (!latestViewEl) {
return;
}
if (Number.isFinite(total)) {
const numeric = Number(total);
if (this.videoModal) {
const label = this.formatViewCountLabel(numeric);
this.videoModal.updateViewCountLabel(
partial ? `${label} (partial)` : label
);
}
latestViewEl.dataset.viewCountState = partial ? "partial" : "ready";
return;
}
if (status === "hydrating") {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
}
latestViewEl.dataset.viewCountState = "hydrating";
} else {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
}
latestViewEl.dataset.viewCountState = status;
}
}, { exact: true });
this.modalViewCountUnsub = () => {
try {
unsubscribeFromVideoViewCount(pointer, token);
} catch (error) {
devLogger.warn(
"[viewCount] Failed to unsubscribe modal view counter:",
error
);
} finally {
this.modalViewCountUnsub = null;
}
};
} catch (error) {
devLogger.warn("[viewCount] Failed to subscribe modal view counter:", error);
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
}
}
},
async hideModal() {
// 1) Clear timers/listeners immediately so playback stats stop updating
this.cancelPendingViewLogging();
this.clearActiveIntervals();
this.removeTorrentStatusVisibilityHandlers();
this.teardownModalViewCountSubscription();
if (this.reactionController) {
this.reactionController.unsubscribe();
}
this.pendingModeratedPlayback = null;
if (
this.videoModal &&
typeof this.videoModal.clearSimilarContent === "function"
) {
try {
this.videoModal.clearSimilarContent();
} catch (error) {
devLogger.warn("[hideModal] Failed to clear similar content:", error);
}
}
// 2) Close the modal UI right away so the user gets instant feedback
const modalVideoElement =
(this.videoModal &&
typeof this.videoModal.getVideoElement === "function" &&
this.videoModal.getVideoElement()) ||
this.modalVideo ||
null;
if (modalVideoElement) {
try {
modalVideoElement.pause();
modalVideoElement.removeAttribute("src");
modalVideoElement.load();
} catch (error) {
devLogger.warn("[hideModal] Failed to reset modal video element:", error);
}
}
if (this.videoModal) {
try {
this.videoModal.close();
} catch (error) {
devLogger.warn("[hideModal] Failed to close video modal immediately:", error);
}
}
this.lastModalTrigger = null;
this.currentMagnetUri = null;
this.clearTorrentStatusNodes();
// 3) Kick off heavy cleanup work asynchronously. We still await it so
// callers that depend on teardown finishing behave the same, but the
// user-visible UI is already closed.
const performCleanup = async () => {
try {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
}
} catch (err) {
devLogger.warn("[hideModal] webtorrent cancel fetch failed:", err);
}
await this.cleanup({
preserveSubscriptions: true,
preserveObservers: true,
preserveModals: true,
});
};
// 4) Remove only `?v=` but **keep** the hash
const url = new URL(window.location.href);
url.searchParams.delete("v"); // remove ?v= param
const newUrl = url.pathname + url.search + url.hash;
window.history.replaceState({}, "", newUrl);
try {
await performCleanup();
} catch (error) {
devLogger.error("[hideModal] Cleanup failed:", error);
}
},
cancelPendingViewLogging() {
this.watchHistoryTelemetry?.cancelPlaybackLogging?.();
},
resetViewLoggingState() {
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
},
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
return;
}
if (
!pointerInfo ||
!pointerInfo.key ||
typeof watchHistoryService?.setLocalMetadata !== "function"
) {
return;
}
if (!video || typeof video !== "object") {
return;
}
const metadata = {
video: {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
},
};
try {
watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
} catch (error) {
devLogger.warn(
"[watchHistory] Failed to persist local metadata for pointer:",
pointerInfo.key,
error,
);
}
},
dropWatchHistoryMetadata(pointerKey) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.dropMetadata(pointerKey);
return;
}
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
if (typeof watchHistoryService?.removeLocalMetadata !== "function") {
return;
}
try {
watchHistoryService.removeLocalMetadata(pointerKey);
} catch (error) {
devLogger.warn(
"[watchHistory] Failed to remove cached metadata for pointer:",
pointerKey,
error,
);
}
},
async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) {
if (!this.watchHistoryController) {
this.showError("Watch history sync is not available right now.");
return;
}
try {
await this.watchHistoryController.removeEntry({
dataset,
trigger,
removeCard: dataset.removeCard === "true",
reason: dataset.reason || "remove-item",
});
} catch (error) {
if (!error?.handled) {
this.showError("Failed to remove from history. Please try again.");
}
}
},
async handleWatchHistoryRemoval(payload = {}) {
if (!this.watchHistoryTelemetry) {
this.showError("Watch history sync is not available right now.");
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryTelemetry.handleRemoval(payload);
},
flushWatchHistory(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryTelemetry) {
return Promise.resolve();
}
return this.watchHistoryTelemetry.flush(reason, context);
},
getActiveViewIdentityKey() {
if (!this.watchHistoryTelemetry) {
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
return this.watchHistoryTelemetry.getActiveViewIdentityKey();
},
deriveViewIdentityKeyFromEvent(event) {
if (!this.watchHistoryTelemetry) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event);
},
buildViewCooldownKey(pointerKey, identityKey) {
if (!this.watchHistoryTelemetry) {
const normalizedPointerKey =
typeof pointerKey === "string" && pointerKey.trim()
? pointerKey.trim()
: "";
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity =
typeof identityKey === "string" && identityKey.trim()
? identityKey.trim().toLowerCase()
: "";
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
return this.watchHistoryTelemetry.buildViewCooldownKey(
pointerKey,
identityKey
);
},
preparePlaybackLogging(videoEl) {
if (!this.watchHistoryTelemetry) {
this.cancelPendingViewLogging();
return;
}
const pointer = this.currentVideoPointer;
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
this.watchHistoryTelemetry.cancelPlaybackLogging();
return;
}
this.watchHistoryTelemetry.preparePlaybackLogging({
videoElement: videoEl,
pointer,
pointerKey,
video: this.currentVideo,
});
},
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
if (!videoElement) {
this.log(
`[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.`
);
return videoElement;
}
const safe = (fn) => {
try {
fn();
} catch (err) {
devLogger.warn("[teardownVideoElement]", err);
}
};
const describeSource = () => {
try {
return videoElement.currentSrc || videoElement.src || "<unset>";
} catch (err) {
return "<unavailable>";
}
};
this.log(
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
);
safe(() => videoElement.pause());
safe(() => {
videoElement.removeAttribute("src");
videoElement.src = "";
});
safe(() => {
videoElement.srcObject = null;
});
safe(() => {
if ("crossOrigin" in videoElement) {
videoElement.crossOrigin = null;
}
if (videoElement.hasAttribute("crossorigin")) {
videoElement.removeAttribute("crossorigin");
}
});
safe(() => {
if (typeof videoElement.load === "function") {
videoElement.load();
}
});
if (!replaceNode || !videoElement.parentNode) {
this.log(
`[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).`
);
return videoElement;
}
const parent = videoElement.parentNode;
const clone = videoElement.cloneNode(false);
if (clone.dataset && "autoplayBound" in clone.dataset) {
delete clone.dataset.autoplayBound;
}
if (clone.hasAttribute("data-autoplay-bound")) {
clone.removeAttribute("data-autoplay-bound");
}
safe(() => {
clone.removeAttribute("src");
clone.src = "";
});
safe(() => {
clone.srcObject = null;
});
safe(() => {
if ("crossOrigin" in clone) {
clone.crossOrigin = null;
}
if (clone.hasAttribute("crossorigin")) {
clone.removeAttribute("crossorigin");
}
});
clone.autoplay = videoElement.autoplay;
clone.controls = videoElement.controls;
clone.loop = videoElement.loop;
clone.muted = videoElement.muted;
clone.defaultMuted = videoElement.defaultMuted;
clone.preload = videoElement.preload;
clone.playsInline = videoElement.playsInline;
clone.poster = "";
if (clone.hasAttribute("poster")) {
clone.removeAttribute("poster");
}
let replaced = false;
safe(() => {
parent.replaceChild(clone, videoElement);
replaced = true;
});
if (!replaced) {
return videoElement;
}
safe(() => {
if (typeof clone.load === "function") {
clone.load();
}
});
this.log(
`[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).`
);
return clone;
},
resetTorrentStats() {
try {
if (this.videoModal && typeof this.videoModal.resetStats === "function") {
this.videoModal.resetStats();
} else {
devLogger.info(
"[Application] resetTorrentStats: videoModal.resetStats not available \u2014 skipping."
);
}
} catch (err) {
devLogger.warn("[Application] resetTorrentStats failed", err);
}
},
setShareButtonState(enabled) {
if (this.videoModal) {
this.videoModal.setShareEnabled(enabled);
}
},
getShareUrlBase() {
if (typeof BITVID_WEBSITE_URL === "string" && BITVID_WEBSITE_URL) {
// Ensure no trailing slash for consistency if desired, though buildShareUrlFromNevent
// appends ?v=... so trailing slash is fine if URL ctor handles it.
// BITVID_WEBSITE_URL usually has a trailing slash in config, but let's be safe.
return BITVID_WEBSITE_URL.replace(/\/$/, "");
}
try {
const current = new URL(window.location.href);
// If we are in the embed, we want to strip that filename.
if (current.pathname.endsWith("/embed.html")) {
return `${current.origin}${current.pathname.replace(/\/embed\.html$/, "")}`;
}
return `${current.origin}${current.pathname}`;
} catch (err) {
const origin = window.location?.origin || "";
const pathname = window.location?.pathname || "";
if (origin || pathname) {
return `${origin}${pathname}`;
}
const href = window.location?.href || "";
if (href) {
const base = href.split(/[?#]/)[0];
if (base) {
return base;
}
}
devLogger.warn("Unable to determine share URL base:", err);
return "";
}
},
};
}