diff --git a/js/app.js b/js/app.js index fb22b9ad..48da3666 100644 --- a/js/app.js +++ b/js/app.js @@ -142,6 +142,7 @@ import { } from "./state/cache.js"; import ApplicationBootstrap from "./ui/applicationBootstrap.js"; import VideoModalCommentController from "./ui/videoModalCommentController.js"; +import ModerationActionController from "./services/moderationActionController.js"; const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args); @@ -234,6 +235,8 @@ class Application { } } ); + + this.initializeModerationActionController(); } get modalVideo() { @@ -5881,306 +5884,113 @@ class Application { return video; } - handleModerationOverride({ video, card }) { - if (!video || typeof video !== "object" || !video.id) { - return false; + initializeModerationActionController() { + if (this.moderationActionController) { + return this.moderationActionController; } - try { - setModerationOverride(video.id, { - showAnyway: true, - updatedAt: Date.now(), - }); - } catch (error) { - devLogger.warn("[Application] Failed to persist moderation override:", error); - } + 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), + }, + }); - const storedVideo = - this.videosMap instanceof Map && video.id ? this.videosMap.get(video.id) : null; - const target = storedVideo || video; - - if (target) { - if (target.moderation && typeof target.moderation === "object") { - if (target.moderation.hidden) { - delete target.moderation.hidden; - } - if (target.moderation.hideReason) { - delete target.moderation.hideReason; - } - if (target.moderation.hideCounts) { - delete target.moderation.hideCounts; - } - if (target.moderation.hideBypass) { - delete target.moderation.hideBypass; - } - } - this.decorateVideoModeration(target); - } - - if (this.currentVideo && this.currentVideo.id === video.id) { - if (this.currentVideo.moderation && typeof this.currentVideo.moderation === "object") { - if (this.currentVideo.moderation.hidden) { - delete this.currentVideo.moderation.hidden; - } - if (this.currentVideo.moderation.hideReason) { - delete this.currentVideo.moderation.hideReason; - } - if (this.currentVideo.moderation.hideCounts) { - delete this.currentVideo.moderation.hideCounts; - } - if (this.currentVideo.moderation.hideBypass) { - delete this.currentVideo.moderation.hideBypass; - } - } - this.decorateVideoModeration(this.currentVideo); - } - - if (card && typeof card.refreshModerationUi === "function") { - try { - card.refreshModerationUi(); - } catch (error) { - devLogger.warn("[Application] Failed to refresh moderation UI:", error); - } - } - - const doc = - (this.videoModal && this.videoModal.document) || - (typeof document !== "undefined" ? document : null); - if (doc && typeof doc.dispatchEvent === "function") { - try { - doc.dispatchEvent( - new CustomEvent("video:moderation-override", { - detail: { video: target }, - }), - ); - } catch (eventError) { - devLogger.warn( - "[Application] Failed to dispatch moderation override event:", - eventError, - ); - } - } - - this.resumePendingModeratedPlayback(target); - - return true; + return this.moderationActionController; } - async handleModerationBlock({ video, card }) { - if (!video || typeof video !== "object" || !video.id) { - return false; - } - - if (!this.isUserLoggedIn()) { - this.showStatus("Log in to block accounts.", { showSpinner: false }); - return false; - } - - const viewerHex = this.normalizeHexPubkey(this.pubkey); - if (!viewerHex) { - this.showError("Select a profile before blocking accounts."); - return false; - } - - const targetHex = this.normalizeHexPubkey(video?.pubkey); - if (!targetHex) { - this.showError("Unable to determine which account to block."); - return false; - } - - if (viewerHex === targetHex) { - this.showError("You cannot block yourself."); + refreshCardModerationUi(card, { reason } = {}) { + if (!card || typeof card.refreshModerationUi !== "function") { return false; } try { - await userBlocks.ensureLoaded(viewerHex); + card.refreshModerationUi(); + return true; } catch (error) { + const suffix = reason ? ` ${reason}` : ""; devLogger.warn( - "[Application] Failed to load block list before blocking:", + `[Application] Failed to refresh moderation UI${suffix}:`, error, ); - this.showError("Unable to load your block list. Please try again."); return false; } - - let alreadyBlocked = - typeof userBlocks.isBlocked === "function" && - userBlocks.isBlocked(targetHex); - let blockApplied = false; - - if (!alreadyBlocked) { - try { - const result = await userBlocks.addBlock(targetHex, viewerHex); - alreadyBlocked = true; - blockApplied = result?.already !== true; - } catch (error) { - const message = - (typeof this.describeUserBlockActionError === "function" - ? this.describeUserBlockActionError(error) - : "") || "Failed to block this creator. Please try again."; - this.showError(message); - devLogger.warn("[Application] Failed to block creator:", error); - return false; - } - } - - if (alreadyBlocked) { - const statusMessage = blockApplied - ? "Creator blocked. Their videos will disappear from your feed." - : "Creator already blocked. Their videos will disappear from your feed."; - this.showStatus(statusMessage, { showSpinner: false }); - } - - try { - clearModerationOverride(video.id); - } catch (error) { - devLogger.warn("[Application] Failed to clear moderation override:", error); - } - - const storedVideo = - this.videosMap instanceof Map && video.id ? this.videosMap.get(video.id) : null; - const target = storedVideo || video; - - const resetModerationState = (subject) => { - if (!subject || typeof subject !== "object") { - return; - } - const moderation = - subject.moderation && typeof subject.moderation === "object" - ? subject.moderation - : null; - if (!moderation) { - return; - } - if (moderation.viewerOverride) { - delete moderation.viewerOverride; - } - if (moderation.hideBypass) { - delete moderation.hideBypass; - } - }; - - if (target) { - resetModerationState(target); - this.decorateVideoModeration(target); - } - - if (this.currentVideo && this.currentVideo.id === video.id) { - resetModerationState(this.currentVideo); - this.decorateVideoModeration(this.currentVideo); - } - - if (card && typeof card.refreshModerationUi === "function") { - try { - card.refreshModerationUi(); - } catch (error) { - devLogger.warn( - "[Application] Failed to refresh moderation UI after block:", - error, - ); - } - } - - const doc = - (this.videoModal && this.videoModal.document) || - (typeof document !== "undefined" ? document : null); - if (doc && typeof doc.dispatchEvent === "function") { - try { - const detail = { video: target }; - doc.dispatchEvent(new CustomEvent("video:moderation-block", { detail })); - doc.dispatchEvent(new CustomEvent("video:moderation-hide", { detail })); - } catch (eventError) { - devLogger.warn( - "[Application] Failed to dispatch moderation block event:", - eventError, - ); - } - } - - try { - await this.onVideosShouldRefresh({ reason: "user-block-update" }); - } catch (error) { - devLogger.warn( - "[Application] Failed to refresh videos after block:", - error, - ); - } - - return true; } - handleModerationHide({ video, card }) { - if (!video || typeof video !== "object" || !video.id) { + dispatchModerationEvent(eventName, detail = {}) { + const doc = + (this.videoModal && this.videoModal.document) || + (typeof document !== "undefined" ? document : null); + + if (!doc || typeof doc.dispatchEvent !== "function") { return false; } try { - clearModerationOverride(video.id); + doc.dispatchEvent(new CustomEvent(eventName, { detail })); + return true; } catch (error) { - devLogger.warn("[Application] Failed to clear moderation override:", 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; } - const storedVideo = - this.videosMap instanceof Map && video.id - ? this.videosMap.get(video.id) - : null; - const target = storedVideo || video; + return controller.handleOverride(payload); + } - const resetOverrideState = (subject) => { - if (!subject || typeof subject !== "object") { - return; - } - const moderation = - subject.moderation && typeof subject.moderation === "object" - ? subject.moderation - : null; - if (!moderation) { - return; - } - if (moderation.viewerOverride) { - delete moderation.viewerOverride; - } - }; - - if (target) { - resetOverrideState(target); - this.decorateVideoModeration(target); + async handleModerationBlock(payload = {}) { + const controller = this.initializeModerationActionController(); + if (!controller) { + return false; } - if (this.currentVideo && this.currentVideo.id === video.id) { - resetOverrideState(this.currentVideo); - this.decorateVideoModeration(this.currentVideo); + return controller.handleBlock(payload); + } + + handleModerationHide(payload = {}) { + const controller = this.initializeModerationActionController(); + if (!controller) { + return false; } - if (card && typeof card.refreshModerationUi === "function") { - try { - card.refreshModerationUi(); - } catch (error) { - devLogger.warn( - "[Application] Failed to refresh moderation UI after hide:", - error, - ); - } - } - - const doc = - (this.videoModal && this.videoModal.document) || - (typeof document !== "undefined" ? document : null); - if (doc && typeof doc.dispatchEvent === "function") { - try { - doc.dispatchEvent( - new CustomEvent("video:moderation-hide", { detail: { video: target } }), - ); - } catch (eventError) { - devLogger.warn( - "[Application] Failed to dispatch moderation hide event:", - eventError, - ); - } - } - - return true; + return controller.handleHide(payload); } getVideoAddressPointer(video) { @@ -9188,6 +8998,18 @@ class Application { this.teardownModalViewCountSubscription(); this.videoModalReadyPromise = null; + if (this.moderationActionController) { + try { + this.moderationActionController.destroy(); + } catch (error) { + devLogger.warn( + "[Application] Failed to destroy moderation action controller:", + error, + ); + } + this.moderationActionController = null; + } + if (this.statusAutoHideHandle) { const ownerDocument = this.statusContainer?.ownerDocument || diff --git a/js/services/moderationActionController.js b/js/services/moderationActionController.js new file mode 100644 index 00000000..9b88e8c1 --- /dev/null +++ b/js/services/moderationActionController.js @@ -0,0 +1,557 @@ +import { devLogger } from "../utils/logger.js"; + +export default class ModerationActionController { + constructor({ + services = {}, + selectors = {}, + actions = {}, + auth = {}, + ui = {}, + } = {}) { + this.services = { + setModerationOverride: + typeof services.setModerationOverride === "function" + ? services.setModerationOverride + : null, + clearModerationOverride: + typeof services.clearModerationOverride === "function" + ? services.clearModerationOverride + : null, + userBlocks: + services.userBlocks && typeof services.userBlocks === "object" + ? services.userBlocks + : null, + }; + + this.selectors = { + getVideoById: + typeof selectors.getVideoById === "function" + ? selectors.getVideoById + : null, + getCurrentVideo: + typeof selectors.getCurrentVideo === "function" + ? selectors.getCurrentVideo + : null, + }; + + this.actions = { + decorateVideoModeration: + typeof actions.decorateVideoModeration === "function" + ? actions.decorateVideoModeration + : null, + resumePlayback: + typeof actions.resumePlayback === "function" + ? actions.resumePlayback + : null, + refreshVideos: + typeof actions.refreshVideos === "function" + ? actions.refreshVideos + : null, + showStatus: + typeof actions.showStatus === "function" ? actions.showStatus : null, + showError: + typeof actions.showError === "function" ? actions.showError : null, + describeBlockError: + typeof actions.describeBlockError === "function" + ? actions.describeBlockError + : null, + }; + + this.auth = { + isLoggedIn: + typeof auth.isLoggedIn === "function" ? auth.isLoggedIn : null, + getViewerPubkey: + typeof auth.getViewerPubkey === "function" + ? auth.getViewerPubkey + : null, + normalizePubkey: + typeof auth.normalizePubkey === "function" + ? auth.normalizePubkey + : null, + }; + + this.ui = { + refreshCardModerationUi: + typeof ui.refreshCardModerationUi === "function" + ? ui.refreshCardModerationUi + : null, + dispatchModerationEvent: + typeof ui.dispatchModerationEvent === "function" + ? ui.dispatchModerationEvent + : null, + }; + + this.destroyed = false; + } + + destroy() { + this.destroyed = true; + } + + handleOverride({ video, card } = {}) { + if (this.destroyed) { + return false; + } + + if (!video || typeof video !== "object" || !video.id) { + return false; + } + + this.persistModerationOverride(video.id); + + const target = this.resolveTargetVideo(video); + this.clearHideState(target); + this.decorateVideo(target); + + const current = this.getCurrentVideo(); + if (current && current.id === video.id) { + this.clearHideState(current); + this.decorateVideo(current); + } + + this.refreshCard(card, { reason: "" }); + this.dispatchModerationEvent("video:moderation-override", { video: target }); + + if (target) { + this.resumePlayback(target); + } + + return true; + } + + async handleBlock({ video, card } = {}) { + if (this.destroyed) { + return false; + } + + if (!video || typeof video !== "object" || !video.id) { + return false; + } + + const { userBlocks } = this.services; + if (!userBlocks) { + devLogger.warn( + "[ModerationActionController] Block action requested but userBlocks service is unavailable.", + ); + return false; + } + + if (!this.isUserLoggedIn()) { + this.showStatus("Log in to block accounts.", { showSpinner: false }); + return false; + } + + const viewerHex = this.getViewerHex(); + if (!viewerHex) { + this.showError("Select a profile before blocking accounts."); + return false; + } + + const targetHex = this.getTargetHex(video); + if (!targetHex) { + this.showError("Unable to determine which account to block."); + return false; + } + + if (viewerHex === targetHex) { + this.showError("You cannot block yourself."); + return false; + } + + try { + await userBlocks.ensureLoaded(viewerHex); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to load block list before blocking:", + error, + ); + this.showError("Unable to load your block list. Please try again."); + return false; + } + + let alreadyBlocked = + typeof userBlocks.isBlocked === "function" && + userBlocks.isBlocked(targetHex); + let blockApplied = false; + + if (!alreadyBlocked) { + try { + const result = await userBlocks.addBlock(targetHex, viewerHex); + alreadyBlocked = true; + blockApplied = result?.already !== true; + } catch (error) { + const message = + this.describeBlockError(error) || + "Failed to block this creator. Please try again."; + this.showError(message); + devLogger.warn( + "[ModerationActionController] Failed to block creator:", + error, + ); + return false; + } + } + + if (alreadyBlocked) { + const statusMessage = blockApplied + ? "Creator blocked. Their videos will disappear from your feed." + : "Creator already blocked. Their videos will disappear from your feed."; + this.showStatus(statusMessage, { showSpinner: false }); + } + + this.clearModerationOverride(video.id); + + const target = this.resolveTargetVideo(video); + this.clearViewerOverride(target); + this.clearHideBypass(target); + this.decorateVideo(target); + + const current = this.getCurrentVideo(); + if (current && current.id === video.id) { + this.clearViewerOverride(current); + this.clearHideBypass(current); + this.decorateVideo(current); + } + + this.refreshCard(card, { reason: "after block" }); + + const detail = { video: target }; + this.dispatchModerationEvent("video:moderation-block", detail); + this.dispatchModerationEvent("video:moderation-hide", detail); + + await this.refreshVideosAfterBlock(); + + return true; + } + + handleHide({ video, card } = {}) { + if (this.destroyed) { + return false; + } + + if (!video || typeof video !== "object" || !video.id) { + return false; + } + + this.clearModerationOverride(video.id); + + const target = this.resolveTargetVideo(video); + this.clearViewerOverride(target); + this.decorateVideo(target); + + const current = this.getCurrentVideo(); + if (current && current.id === video.id) { + this.clearViewerOverride(current); + this.decorateVideo(current); + } + + this.refreshCard(card, { reason: "after hide" }); + this.dispatchModerationEvent("video:moderation-hide", { video: target }); + + return true; + } + + resolveTargetVideo(video) { + if (!video || typeof video !== "object" || !video.id) { + return null; + } + + if (!this.selectors.getVideoById) { + return video; + } + + try { + const stored = this.selectors.getVideoById(video.id); + return stored || video; + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to resolve video by id:", + error, + ); + return video; + } + } + + getCurrentVideo() { + if (!this.selectors.getCurrentVideo) { + return null; + } + + try { + return this.selectors.getCurrentVideo(); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to resolve current video:", + error, + ); + return null; + } + } + + clearHideState(video) { + const moderation = this.getModerationState(video); + if (!moderation) { + return; + } + + if (moderation.hidden) { + delete moderation.hidden; + } + if (moderation.hideReason) { + delete moderation.hideReason; + } + if (moderation.hideCounts) { + delete moderation.hideCounts; + } + if (moderation.hideBypass) { + delete moderation.hideBypass; + } + } + + clearViewerOverride(video) { + const moderation = this.getModerationState(video); + if (moderation?.viewerOverride) { + delete moderation.viewerOverride; + } + } + + clearHideBypass(video) { + const moderation = this.getModerationState(video); + if (moderation?.hideBypass) { + delete moderation.hideBypass; + } + } + + getModerationState(video) { + if (!video || typeof video !== "object") { + return null; + } + + const moderation = video.moderation; + if (!moderation || typeof moderation !== "object") { + return null; + } + + return moderation; + } + + decorateVideo(video) { + if (!video || !this.actions.decorateVideoModeration) { + return; + } + + try { + this.actions.decorateVideoModeration(video); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to decorate video moderation:", + error, + ); + } + } + + refreshCard(card, { reason } = {}) { + if (!card) { + return; + } + + if (this.ui.refreshCardModerationUi) { + this.ui.refreshCardModerationUi(card, { reason }); + return; + } + + if (typeof card.refreshModerationUi === "function") { + try { + card.refreshModerationUi(); + } catch (error) { + const suffix = reason ? ` ${reason}` : ""; + devLogger.warn( + `[ModerationActionController] Failed to refresh moderation UI${suffix}:`, + error, + ); + } + } + } + + dispatchModerationEvent(eventName, detail = {}) { + if (this.ui.dispatchModerationEvent) { + this.ui.dispatchModerationEvent(eventName, detail); + return; + } + + const doc = + typeof document !== "undefined" && document + ? document + : null; + if (!doc || typeof doc.dispatchEvent !== "function") { + return; + } + + try { + doc.dispatchEvent(new CustomEvent(eventName, { detail })); + } catch (error) { + devLogger.warn( + `[ModerationActionController] Failed to dispatch ${eventName}:`, + error, + ); + } + } + + resumePlayback(video) { + if (!video || !this.actions.resumePlayback) { + return; + } + + try { + this.actions.resumePlayback(video); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to resume moderated playback:", + error, + ); + } + } + + async refreshVideosAfterBlock() { + if (!this.actions.refreshVideos) { + return; + } + + try { + await this.actions.refreshVideos({ reason: "user-block-update" }); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to refresh videos after block:", + error, + ); + } + } + + persistModerationOverride(eventId) { + if (!eventId || !this.services.setModerationOverride) { + return; + } + + try { + this.services.setModerationOverride(eventId, { + showAnyway: true, + updatedAt: Date.now(), + }); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to persist moderation override:", + error, + ); + } + } + + clearModerationOverride(eventId) { + if (!eventId || !this.services.clearModerationOverride) { + return; + } + + try { + this.services.clearModerationOverride(eventId); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to clear moderation override:", + error, + ); + } + } + + isUserLoggedIn() { + if (!this.auth.isLoggedIn) { + return false; + } + + try { + return this.auth.isLoggedIn(); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to evaluate login state:", + error, + ); + return false; + } + } + + getViewerHex() { + if (!this.auth.getViewerPubkey || !this.auth.normalizePubkey) { + return null; + } + + try { + const pubkey = this.auth.getViewerPubkey(); + return pubkey ? this.auth.normalizePubkey(pubkey) : null; + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to resolve viewer pubkey:", + error, + ); + return null; + } + } + + getTargetHex(video) { + if (!video || !this.auth.normalizePubkey) { + return null; + } + + try { + return this.auth.normalizePubkey(video?.pubkey); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to normalize target pubkey:", + error, + ); + return null; + } + } + + showStatus(message, options) { + if (!this.actions.showStatus) { + return; + } + + try { + this.actions.showStatus(message, options); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to show status message:", + error, + ); + } + } + + showError(message) { + if (!this.actions.showError) { + return; + } + + try { + this.actions.showError(message); + } catch (error) { + devLogger.warn( + "[ModerationActionController] Failed to show error message:", + error, + ); + } + } + + describeBlockError(error) { + if (!this.actions.describeBlockError) { + return ""; + } + + try { + return this.actions.describeBlockError(error); + } catch (describeError) { + devLogger.warn( + "[ModerationActionController] Failed to describe block error:", + describeError, + ); + return ""; + } + } +}