mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
629 lines
15 KiB
JavaScript
629 lines
15 KiB
JavaScript
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,
|
|
clearAuthorModerationOverride:
|
|
typeof services.clearAuthorModerationOverride === "function"
|
|
? services.clearAuthorModerationOverride
|
|
: 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);
|
|
|
|
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);
|
|
|
|
const target = this.resolveTargetVideo(video);
|
|
this.clearViewerOverride(target);
|
|
this.clearHideBypass(target);
|
|
this.decorateVideo(target);
|
|
|
|
if (target !== video) {
|
|
this.clearViewerOverride(video);
|
|
this.clearHideBypass(video);
|
|
this.decorateVideo(video);
|
|
}
|
|
|
|
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);
|
|
// The same Restore control is used after either a per-video override or
|
|
// an "Always show creator" override. Clear both scopes so the visible
|
|
// state can actually return to the default moderation decision.
|
|
this.clearAuthorModerationOverride(video);
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
getModerationOverrideDescriptor(video) {
|
|
if (!video || typeof video !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const eventId = video.id;
|
|
if (!eventId) {
|
|
return null;
|
|
}
|
|
|
|
const rawAuthor =
|
|
typeof video.pubkey === "string"
|
|
? video.pubkey
|
|
: typeof video.author?.pubkey === "string"
|
|
? video.author.pubkey
|
|
: "";
|
|
const normalizedAuthor =
|
|
rawAuthor && this.auth.normalizePubkey
|
|
? this.auth.normalizePubkey(rawAuthor)
|
|
: rawAuthor;
|
|
|
|
return {
|
|
eventId,
|
|
authorPubkey: normalizedAuthor || "",
|
|
};
|
|
}
|
|
|
|
persistModerationOverride(video) {
|
|
if (!this.services.setModerationOverride) {
|
|
return;
|
|
}
|
|
|
|
const descriptor = this.getModerationOverrideDescriptor(video);
|
|
if (!descriptor) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.services.setModerationOverride(descriptor, {
|
|
showAnyway: true,
|
|
updatedAt: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[ModerationActionController] Failed to persist moderation override:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
clearModerationOverride(video) {
|
|
if (!this.services.clearModerationOverride) {
|
|
return;
|
|
}
|
|
|
|
const descriptor = this.getModerationOverrideDescriptor(video);
|
|
if (!descriptor) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.services.clearModerationOverride(descriptor);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[ModerationActionController] Failed to clear moderation override:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
clearAuthorModerationOverride(video) {
|
|
if (!this.services.clearAuthorModerationOverride) {
|
|
return;
|
|
}
|
|
|
|
const descriptor = this.getModerationOverrideDescriptor(video);
|
|
if (!descriptor?.authorPubkey) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.services.clearAuthorModerationOverride(descriptor.authorPubkey);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[ModerationActionController] Failed to clear author 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 "";
|
|
}
|
|
}
|
|
}
|