Merge pull request #2151 from PR0M3TH3AN/claude/refactor-app-coordinators-vwgBe

Refactor Application into coordinator modules
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-06 16:00:38 -05:00
commit 31c99f06e6
8 files changed
+6438 -5200

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
# Application Coordinator Architecture
The `Application` class (`js/app.js`) acts as the **composition root** for bitvid.
Business logic that previously lived directly on `Application` has been extracted
into **coordinator modules** under `js/app/`. `Application` retains thin
delegator methods so that every public call-site continues to work unchanged.
## Coordinator Modules
| Module | File | Responsibility |
|--------|------|----------------|
| Feed | `js/app/feedCoordinator.js` | Feed registration, runtime building, refresh, video loading, URL-health helpers, feed telemetry |
| Playback | `js/app/playbackCoordinator.js` | URL-first + magnet fallback pipeline, probing, torrent status, video timestamps |
| Auth/Session | `js/app/authSessionCoordinator.js` | Signer lifecycle, profile switching, relay/block/wallet management, cleanup |
| Modal | `js/app/modalCoordinator.js` | Modal open/close, view-count subscriptions, watch-history metadata, playback logging |
| Moderation | `js/app/moderationCoordinator.js` | Moderation settings, video decoration, override/block/hide actions |
A shared helper (`js/app/bindCoordinator.js`) binds every method returned by a
coordinator factory to the `Application` instance so that `this` works as expected.
## Dependency Injection
Each coordinator is a factory function that receives a `deps` object containing
every module-level dependency it needs. Coordinators **never import globals**
themselves; all references come through the closure created by `deps`:
```javascript
export function createFeedCoordinator(deps) {
const { devLogger, nostrClient, watchHistoryService, ... } = deps;
return {
async loadVideos(forceFetch = false) {
devLogger.log("Starting loadVideos..."); // closure var, not import
// `this` is the Application instance (bound by caller)
this.checkRelayHealthWarning();
// ...
},
};
}
```
`Application._initCoordinators()` creates all five coordinators and injects their
dependencies from the module scope of `js/app.js`. It is called eagerly in the
constructor and idempotently from every delegator, so test harnesses that use
`Object.create(Application.prototype)` also work.
## Delegation Pattern
Every extracted method has a matching thin delegator on `Application`:
```javascript
class Application {
async loadVideos(...args) {
this._initCoordinators();
return this._feed.loadVideos(...args);
}
}
```
This preserves the public API surface. Callers (views, services, test harnesses)
do not need to know about coordinators.
## Old-to-New Mapping
| Old location (`Application` method) | New location |
|--------------------------------------|--------------|
| `registerRecentFeed`, `registerForYouFeed`, `registerKidsFeed`, `registerExploreFeed`, `registerSubscriptionsFeed`, `registerWatchHistoryFeed` | `feedCoordinator` |
| `buildForYouFeedRuntime`, `buildExploreFeedRuntime`, `buildRecentFeedRuntime`, `buildKidsFeedRuntime` | `feedCoordinator` |
| `refreshForYouFeed`, `refreshKidsFeed`, `refreshExploreFeed`, `refreshRecentFeed` | `feedCoordinator` |
| `loadVideos`, `loadForYouVideos`, `loadKidsVideos`, `loadExploreVideos`, `loadOlderVideos` | `feedCoordinator` |
| `renderVideoList`, `refreshVideoDiscussionCounts` | `feedCoordinator` |
| `checkRelayHealthWarning`, `hasOlderVersion` | `feedCoordinator` |
| Feed telemetry (`getFeedTelemetryState`, `emitFeedImpressions`, `recordFeedClick`, ...) | `feedCoordinator` |
| `playHttp`, `playViaWebTorrent`, `playVideoWithFallback` | `playbackCoordinator` |
| `playVideoByEventId`, `playVideoWithoutEvent` | `playbackCoordinator` |
| `probeUrl`, `probeUrlWithVideoElement`, `checkUrlParams` | `playbackCoordinator` |
| `autoplayModalVideo`, `startTorrentStatusMirrors` | `playbackCoordinator` |
| `buildModalTimestampPayload`, `resolveVideoPostedAt`, `ensureModalPostedTimestamp` | `playbackCoordinator` |
| `shouldDeferModeratedPlayback`, `resumePendingModeratedPlayback` | `playbackCoordinator` |
| `handleAuthLogin`, `handleAuthLogout`, `requestLogout` | `authSessionCoordinator` |
| `handleBlocksLoaded`, `handleRelaysLoaded`, `scheduleRelayUiRefresh` | `authSessionCoordinator` |
| `cleanup`, `waitForCleanup`, `clearActiveIntervals` | `authSessionCoordinator` |
| `handleProfileSwitchRequest`, `waitForIdentityRefresh`, `handleProfileLogoutRequest` | `authSessionCoordinator` |
| `handleProfileRelayOperation`, `handleProfileBlocklistMutation`, `handleProfileAdminMutation` | `authSessionCoordinator` |
| `handleProfileWalletPersist`, `handleProfileWalletTest`, `handleProfileWalletDisconnect` | `authSessionCoordinator` |
| `showModalWithPoster`, `hideModal`, `ensureVideoModalReady` | `modalCoordinator` |
| `subscribeModalViewCount`, `teardownModalViewCountSubscription` | `modalCoordinator` |
| `preparePlaybackLogging`, `teardownVideoElement` | `modalCoordinator` |
| `persistWatchHistoryMetadataForVideo`, `handleRemoveHistoryAction` | `modalCoordinator` |
| `setShareButtonState`, `getShareUrlBase` | `modalCoordinator` |
| `handleModerationSettingsChange`, `refreshVisibleModerationUi` | `moderationCoordinator` |
| `decorateVideoModeration`, `initializeModerationActionController` | `moderationCoordinator` |
| `handleModerationOverride`, `handleModerationBlock`, `handleModerationHide` | `moderationCoordinator` |
| `normalizeModerationSettings`, `getActiveModerationThresholds` | `moderationCoordinator` |
## Adding New Methods
When adding a method that belongs to an existing coordinator:
1. Add the method to the coordinator's return object.
2. Add a thin delegator on `Application` that calls `this._initCoordinators()`
then delegates to the coordinator.
3. If the method uses a new module-level import, add it to the coordinator's
`deps` destructuring and pass it from `_initCoordinators()`.
+586 -5200
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
// js/app/bindCoordinator.js
/**
* Binds every method on a coordinator object to the given Application instance.
*
* Coordinator factories return plain objects whose methods reference `this`
* (the Application) and close over injected `deps`. This helper produces a
* mirror object where every function-valued property is bound to `app`, so
* callers can invoke `coordinator.method()` without worrying about context.
*
* @param {object} app - The Application instance to bind to.
* @param {object} raw - The object returned by a coordinator factory.
* @returns {object} A new object with every method bound to `app`.
*/
export default function bindCoordinator(app, raw) {
const bound = Object.create(null);
for (const key of Object.keys(raw)) {
const value = raw[key];
bound[key] = typeof value === "function" ? value.bind(app) : value;
}
return bound;
}
File diff suppressed because it is too large. Load diff
+647
View File
@@ -0,0 +1,647 @@
// 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,
pointerArrayToKey,
pointerKey,
getCanonicalDesignSystemMode,
BITVID_WEBSITE_URL,
} = deps;
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;
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);
}
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;
}
});
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 "";
}
},
};
}
+322
View File
@@ -0,0 +1,322 @@
// js/app/moderationCoordinator.js
/**
* Moderation setting update flow, decoration, and action 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 createModerationCoordinator(deps) {
const {
devLogger,
ModerationActionController,
setModerationOverride,
clearModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
} = deps;
return {
async handleModerationSettingsChange({ settings, skipRefresh = false } = {}) {
const normalized = this.normalizeModerationSettings(settings);
this.moderationSettings = normalized;
const feedContext = {
feedName: this.feedName || "",
feedVariant: this.feedVariant || "",
};
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video, feedContext);
}
}
}
if (
this.videoListView &&
Array.isArray(this.videoListView.videoCardInstances)
) {
for (const card of this.videoListView.videoCardInstances) {
if (!card || typeof card.refreshModerationUi !== "function") {
continue;
}
if (card.video && typeof card.video === "object") {
this.decorateVideoModeration(card.video, feedContext);
}
try {
card.refreshModerationUi();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh moderation UI:",
error,
);
}
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video, feedContext);
}
}
}
if (this.currentVideo && typeof this.currentVideo === "object") {
this.decorateVideoModeration(this.currentVideo, feedContext);
}
this.moderationDecorator.updateSettings(normalized);
if (!skipRefresh) {
try {
await this.onVideosShouldRefresh({ reason: "moderation-settings-change" });
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh videos after moderation settings change:",
error,
);
}
}
return normalized;
},
refreshVisibleModerationUi({ reason } = {}) {
const context = reason ? ` after ${reason}` : "";
const feedContext = {
feedName: this.feedName || "",
feedVariant: this.feedVariant || "",
};
const redecorateVideo = (video) => {
if (!video || typeof video !== "object") {
return;
}
try {
this.decorateVideoModeration(video, feedContext);
} catch (error) {
devLogger.warn(
`[Application] Failed to decorate video moderation${context}:`,
error,
);
}
};
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
redecorateVideo(video);
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
redecorateVideo(video);
}
}
if (this.videoListView && Array.isArray(this.videoListView.videoCardInstances)) {
for (const card of this.videoListView.videoCardInstances) {
if (!card || typeof card !== "object") {
continue;
}
if (card.video && typeof card.video === "object") {
redecorateVideo(card.video);
}
if (typeof card.refreshModerationUi === "function") {
try {
card.refreshModerationUi();
} catch (error) {
devLogger.warn(
`[Application] Failed to refresh moderation UI on card${context}:`,
error,
);
}
}
}
}
if (this.currentVideo && typeof this.currentVideo === "object") {
redecorateVideo(this.currentVideo);
try {
this.videoModal?.refreshActiveVideoModeration?.({ video: this.currentVideo });
} catch (error) {
devLogger.warn(
`[Application] Failed to refresh video modal moderation UI${context}:`,
error,
);
}
}
},
deriveModerationReportType(summary) {
return this.moderationDecorator.deriveModerationReportType(summary);
},
deriveModerationTrustedCount(summary, reportType) {
return this.moderationDecorator.deriveModerationTrustedCount(summary, reportType);
},
getReporterDisplayName(pubkey) {
return this.moderationDecorator.getReporterDisplayName(pubkey);
},
normalizeModerationSettings(settings = null) {
return this.moderationDecorator.normalizeModerationSettings(settings);
},
getActiveModerationThresholds() {
this.moderationSettings = this.moderationDecorator.normalizeModerationSettings(this.moderationSettings);
return { ...this.moderationSettings };
},
decorateVideoModeration(video, feedContext = {}) {
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
if (
video &&
video.pubkey &&
this.isAuthorBlocked(video.pubkey) &&
decorated &&
decorated.moderation
) {
decorated.moderation.viewerMuted = true;
decorated.moderation.hidden = true;
decorated.moderation.hideReason = "viewer-block";
}
return decorated;
},
initializeModerationActionController() {
if (this.moderationActionController) {
return this.moderationActionController;
}
this.moderationActionController = new ModerationActionController({
services: {
setModerationOverride,
clearModerationOverride,
userBlocks,
},
selectors: {
getVideoById: (id) =>
this.videosMap instanceof Map && id ? this.videosMap.get(id) : null,
getCurrentVideo: () => this.currentVideo,
},
actions: {
decorateVideoModeration: (video) => this.decorateVideoModeration(video),
resumePlayback: (video) => this.resumePendingModeratedPlayback(video),
refreshVideos: (payload) => this.onVideosShouldRefresh(payload),
showStatus: (message, options) => this.showStatus(message, options),
showError: (message) => this.showError(message),
describeBlockError: (error) => this.describeUserBlockActionError(error),
},
auth: {
isLoggedIn: () => this.isUserLoggedIn(),
getViewerPubkey: () => this.pubkey,
normalizePubkey: (value) => this.normalizeHexPubkey(value),
},
ui: {
refreshCardModerationUi: (card, options) =>
this.refreshCardModerationUi(card, options),
dispatchModerationEvent: (eventName, detail) =>
this.dispatchModerationEvent(eventName, detail),
},
});
return this.moderationActionController;
},
refreshCardModerationUi(card, { reason } = {}) {
if (!card || typeof card.refreshModerationUi !== "function") {
return false;
}
try {
card.refreshModerationUi();
return true;
} catch (error) {
const suffix = reason ? ` ${reason}` : "";
devLogger.warn(
`[Application] Failed to refresh moderation UI${suffix}:`,
error,
);
return false;
}
},
dispatchModerationEvent(eventName, detail = {}) {
const doc =
(this.videoModal && this.videoModal.document) ||
(typeof document !== "undefined" ? document : null);
if (!doc || typeof doc.dispatchEvent !== "function") {
return false;
}
try {
doc.dispatchEvent(new CustomEvent(eventName, { detail }));
return true;
} catch (error) {
const eventLabels = {
"video:moderation-override": "moderation override event",
"video:moderation-block": "moderation block event",
"video:moderation-hide": "moderation hide event",
};
const label = eventLabels[eventName] || eventName;
devLogger.warn(`[Application] Failed to dispatch ${label}:`, error);
return false;
}
},
handleModerationOverride(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleOverride(payload);
},
async handleModerationBlock(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleBlock(payload);
},
handleModerationHide(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleHide(payload);
},
getVideoAddressPointer(video) {
if (
this.discussionCountService &&
typeof this.discussionCountService.getVideoAddressPointer === "function"
) {
return this.discussionCountService.getVideoAddressPointer(video);
}
return buildVideoAddressPointer(video, { defaultKind: VIDEO_EVENT_KIND });
},
};
}
File diff suppressed because it is too large. Load diff