mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #2151 from PR0M3TH3AN/claude/refactor-app-coordinators-vwgBe
Refactor Application into coordinator modules
This commit is contained in:
8 files changed
+6438
-5200
No files matched your search
@@ -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()`.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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 "";
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user