mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 09:22:24 +00:00
refactor(ai): extract EngagementController from js/app.js (agent)
Moved `handleRepostAction`, `handleMirrorAction`, and `handleEnsurePresenceAction` from `js/app.js` to `js/ui/engagementController.js`. This follows the AGENTS.md refactor direction for `bitvidApp` to controllers. - Extracted logic into `EngagementController`. - Added unit tests for `EngagementController`. - Wired `EngagementController` into `js/app.js`. - Preserved `currentVideoPointer` fallback logic in `derivePointerFromDataset`.
This commit is contained in:
1 parent
9b138dc5fa
commit
cd9418aa92
3 files changed
+597
-349
No files matched your search
@@ -150,6 +150,7 @@ import {
|
||||
urlHealthConstants,
|
||||
} from "./state/cache.js";
|
||||
import ApplicationBootstrap from "./ui/applicationBootstrap.js";
|
||||
import EngagementController from "./ui/engagementController.js";
|
||||
import SimilarContentController from "./ui/similarContentController.js";
|
||||
import VideoModalCommentController from "./ui/videoModalCommentController.js";
|
||||
import TorrentStatusController from "./ui/torrentStatusController.js";
|
||||
@@ -290,6 +291,21 @@ class Application {
|
||||
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
|
||||
});
|
||||
|
||||
this.engagementController = new EngagementController({
|
||||
services: {
|
||||
nostrClient,
|
||||
},
|
||||
ui: {
|
||||
showError: (msg) => this.showError(msg),
|
||||
showSuccess: (msg) => this.showSuccess(msg),
|
||||
showStatus: (msg, opts) => this.showStatus(msg, opts),
|
||||
},
|
||||
state: {
|
||||
getCurrentVideo: () => this.currentVideo,
|
||||
getCurrentVideoPointer: () => this.currentVideoPointer,
|
||||
},
|
||||
});
|
||||
|
||||
this.initializeModerationActionController();
|
||||
this.initializeSimilarContentController();
|
||||
}
|
||||
@@ -7031,37 +7047,6 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
derivePointerFromDataset(dataset = {}, context = "") {
|
||||
const type =
|
||||
typeof dataset.pointerType === "string" ? dataset.pointerType.trim() : "";
|
||||
const value =
|
||||
typeof dataset.pointerValue === "string" ? dataset.pointerValue.trim() : "";
|
||||
const relay =
|
||||
typeof dataset.pointerRelay === "string" ? dataset.pointerRelay.trim() : "";
|
||||
|
||||
if (type && value) {
|
||||
return relay ? [type, value, relay] : [type, value];
|
||||
}
|
||||
|
||||
if (
|
||||
context === "modal" &&
|
||||
Array.isArray(this.currentVideoPointer) &&
|
||||
this.currentVideoPointer.length >= 2
|
||||
) {
|
||||
return this.currentVideoPointer;
|
||||
}
|
||||
|
||||
if (
|
||||
context === "modal" &&
|
||||
Array.isArray(this.currentVideo?.pointer) &&
|
||||
this.currentVideo.pointer.length >= 2
|
||||
) {
|
||||
return this.currentVideo.pointer;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleMoreMenuAction(action, dataset = {}) {
|
||||
if (!this.moreMenuController) {
|
||||
return;
|
||||
@@ -7071,332 +7056,20 @@ class Application {
|
||||
}
|
||||
|
||||
async handleRepostAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
const fallbackEventId =
|
||||
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.showError("No event is available to repost.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = this.derivePointerFromDataset(dataset, context);
|
||||
|
||||
let author = typeof dataset.author === "string" ? dataset.author.trim() : "";
|
||||
if (!author && context === "modal" && this.currentVideo?.pubkey) {
|
||||
author = this.currentVideo.pubkey;
|
||||
}
|
||||
|
||||
const rawKindValue = (() => {
|
||||
if (typeof dataset.kind === "string" && dataset.kind.trim()) {
|
||||
const parsed = Number.parseInt(dataset.kind.trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
if (Number.isFinite(dataset.kind)) {
|
||||
return Number(dataset.kind);
|
||||
}
|
||||
if (Number.isFinite(this.currentVideo?.kind)) {
|
||||
return Number(this.currentVideo.kind);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const options = {
|
||||
pointer,
|
||||
pointerType: dataset.pointerType,
|
||||
pointerValue: dataset.pointerValue,
|
||||
pointerRelay: dataset.pointerRelay,
|
||||
authorPubkey: author,
|
||||
};
|
||||
|
||||
if (Number.isFinite(rawKindValue)) {
|
||||
options.kind = Math.floor(rawKindValue);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await nostrClient.repostEvent(targetEventId, options);
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "repost-failed";
|
||||
switch (code) {
|
||||
case "invalid-event-id":
|
||||
this.showError("No event is available to repost.");
|
||||
break;
|
||||
case "missing-actor":
|
||||
this.showError(
|
||||
"Cannot sign the repost right now. Please refresh and try again.",
|
||||
);
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.showError("Cannot reach relays right now. Please try again later.");
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.showError("No relay accepted the repost attempt.");
|
||||
break;
|
||||
case "signing-failed":
|
||||
this.showError("Failed to sign the repost. Please try again.");
|
||||
break;
|
||||
default:
|
||||
this.showError("Failed to repost the video. Please try again later.");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedCount = Array.isArray(result.summary?.accepted)
|
||||
? result.summary.accepted.length
|
||||
: 0;
|
||||
const relayCount =
|
||||
acceptedCount > 0
|
||||
? acceptedCount
|
||||
: Array.isArray(result.relays)
|
||||
? result.relays.length
|
||||
: acceptedCount;
|
||||
|
||||
const fragments = [];
|
||||
if (relayCount > 0) {
|
||||
fragments.push(
|
||||
`Reposted to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
|
||||
);
|
||||
} else {
|
||||
fragments.push("Reposted.");
|
||||
}
|
||||
|
||||
if (result.sessionActor) {
|
||||
fragments.push("Boost as session user.");
|
||||
}
|
||||
|
||||
this.showSuccess(fragments.join(" ").trim());
|
||||
} catch (error) {
|
||||
devLogger.warn("[app] Repost action failed:", error);
|
||||
this.showError("Failed to repost the video. Please try again later.");
|
||||
if (this.engagementController) {
|
||||
return this.engagementController.handleRepostAction(dataset);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMirrorAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
const fallbackEventId =
|
||||
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.showError("No event is available to mirror.");
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitUrl =
|
||||
typeof dataset.url === "string" && dataset.url.trim() ? dataset.url.trim() : "";
|
||||
const fallbackUrl =
|
||||
context === "modal" && typeof this.currentVideo?.url === "string"
|
||||
? this.currentVideo.url.trim()
|
||||
: "";
|
||||
const targetUrl = explicitUrl || fallbackUrl;
|
||||
|
||||
if (!targetUrl) {
|
||||
this.showError("This video does not expose a hosted URL to mirror.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMagnet =
|
||||
typeof dataset.magnet === "string" && dataset.magnet.trim()
|
||||
? dataset.magnet.trim()
|
||||
: "";
|
||||
const fallbackMagnet =
|
||||
context === "modal"
|
||||
? (this.currentVideo?.magnet || this.currentVideo?.originalMagnet || "")
|
||||
: "";
|
||||
const magnet = rawMagnet || fallbackMagnet;
|
||||
|
||||
const thumbnail =
|
||||
typeof dataset.thumbnail === "string" && dataset.thumbnail.trim()
|
||||
? dataset.thumbnail.trim()
|
||||
: context === "modal" && typeof this.currentVideo?.thumbnail === "string"
|
||||
? this.currentVideo.thumbnail.trim()
|
||||
: "";
|
||||
|
||||
const description =
|
||||
typeof dataset.description === "string" && dataset.description.trim()
|
||||
? dataset.description.trim()
|
||||
: context === "modal" && typeof this.currentVideo?.description === "string"
|
||||
? this.currentVideo.description.trim()
|
||||
: "";
|
||||
|
||||
const title =
|
||||
typeof dataset.title === "string" && dataset.title.trim()
|
||||
? dataset.title.trim()
|
||||
: context === "modal" && typeof this.currentVideo?.title === "string"
|
||||
? this.currentVideo.title.trim()
|
||||
: "";
|
||||
|
||||
const datasetPrivate =
|
||||
dataset.isPrivate === "true" || dataset.isPrivate === true ? true : false;
|
||||
const fallbackPrivate = context === "modal" && this.currentVideo?.isPrivate === true;
|
||||
const isPrivate = datasetPrivate || fallbackPrivate;
|
||||
|
||||
if (isPrivate) {
|
||||
this.showError("Mirroring is unavailable for private videos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
url: targetUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
title,
|
||||
isPrivate,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await nostrClient.mirrorVideoEvent(targetEventId, options);
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "mirror-failed";
|
||||
switch (code) {
|
||||
case "invalid-event-id":
|
||||
this.showError("No event is available to mirror.");
|
||||
break;
|
||||
case "missing-url":
|
||||
this.showError("This video does not expose a hosted URL to mirror.");
|
||||
break;
|
||||
case "missing-actor":
|
||||
this.showError(
|
||||
"Cannot sign the mirror right now. Please refresh and try again.",
|
||||
);
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.showError("Cannot reach relays right now. Please try again later.");
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.showError("No relay accepted the mirror attempt.");
|
||||
break;
|
||||
case "signing-failed":
|
||||
this.showError("Failed to sign the mirror. Please try again.");
|
||||
break;
|
||||
default:
|
||||
this.showError("Failed to mirror the video. Please try again later.");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedCount = Array.isArray(result.summary?.accepted)
|
||||
? result.summary.accepted.length
|
||||
: 0;
|
||||
const relayCount =
|
||||
acceptedCount > 0
|
||||
? acceptedCount
|
||||
: Array.isArray(result.relays)
|
||||
? result.relays.length
|
||||
: acceptedCount;
|
||||
|
||||
const fragments = [];
|
||||
if (relayCount > 0) {
|
||||
fragments.push(
|
||||
`Mirrored to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
|
||||
);
|
||||
} else {
|
||||
fragments.push("Mirrored.");
|
||||
}
|
||||
|
||||
if (result.sessionActor) {
|
||||
fragments.push("Boost as session user.");
|
||||
}
|
||||
|
||||
this.showSuccess(fragments.join(" ").trim());
|
||||
} catch (error) {
|
||||
devLogger.warn("[app] Mirror action failed:", error);
|
||||
this.showError("Failed to mirror the video. Please try again later.");
|
||||
if (this.engagementController) {
|
||||
return this.engagementController.handleMirrorAction(dataset);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEnsurePresenceAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
const fallbackEventId =
|
||||
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.showError("No event is available to rebroadcast.");
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitPubkey =
|
||||
typeof dataset.pubkey === "string" && dataset.pubkey.trim()
|
||||
? dataset.pubkey.trim()
|
||||
: "";
|
||||
const datasetAuthor =
|
||||
typeof dataset.author === "string" && dataset.author.trim()
|
||||
? dataset.author.trim()
|
||||
: "";
|
||||
const fallbackPubkey =
|
||||
context === "modal" && typeof this.currentVideo?.pubkey === "string"
|
||||
? this.currentVideo.pubkey
|
||||
: datasetAuthor;
|
||||
const targetPubkey = explicitPubkey || fallbackPubkey || "";
|
||||
|
||||
try {
|
||||
const result = await nostrClient.rebroadcastEvent(targetEventId, {
|
||||
pubkey: targetPubkey,
|
||||
});
|
||||
|
||||
if (result?.throttled) {
|
||||
const remainingMs = Math.max(0, Number(result?.cooldown?.remainingMs) || 0);
|
||||
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
||||
const message =
|
||||
remainingSeconds > 0
|
||||
? `Rebroadcast is cooling down. Try again in ${remainingSeconds}s.`
|
||||
: "Rebroadcast is cooling down. Try again soon.";
|
||||
this.showStatus(message);
|
||||
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
|
||||
window.setTimeout(() => {
|
||||
this.showStatus("");
|
||||
}, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "rebroadcast-failed";
|
||||
switch (code) {
|
||||
case "event-not-found":
|
||||
this.showError("Original event payload is unavailable. Reload and try again.");
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.showError("No relay accepted the rebroadcast attempt.");
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.showError("Cannot reach relays right now. Please try again later.");
|
||||
break;
|
||||
default:
|
||||
this.showError("Failed to rebroadcast. Please try again later.");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.alreadyPresent) {
|
||||
this.showSuccess("Relays already have this revision.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSuccess("Rebroadcast requested across relays.");
|
||||
} catch (error) {
|
||||
devLogger.warn("[app] Rebroadcast action failed:", error);
|
||||
this.showError("Failed to rebroadcast. Please try again later.");
|
||||
if (this.engagementController) {
|
||||
return this.engagementController.handleEnsurePresenceAction(dataset);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export default class EngagementController {
|
||||
constructor({ services = {}, ui = {}, state = {} } = {}) {
|
||||
this.services = {
|
||||
nostrClient: services.nostrClient,
|
||||
};
|
||||
this.ui = {
|
||||
showError: ui.showError || (() => {}),
|
||||
showSuccess: ui.showSuccess || (() => {}),
|
||||
showStatus: ui.showStatus || (() => {}),
|
||||
};
|
||||
this.state = {
|
||||
getCurrentVideo: state.getCurrentVideo || (() => null),
|
||||
getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null),
|
||||
};
|
||||
}
|
||||
|
||||
derivePointerFromDataset(dataset = {}, context = "") {
|
||||
const type =
|
||||
typeof dataset.pointerType === "string" ? dataset.pointerType.trim() : "";
|
||||
const value =
|
||||
typeof dataset.pointerValue === "string"
|
||||
? dataset.pointerValue.trim()
|
||||
: "";
|
||||
const relay =
|
||||
typeof dataset.pointerRelay === "string"
|
||||
? dataset.pointerRelay.trim()
|
||||
: "";
|
||||
|
||||
if (type && value) {
|
||||
return relay ? [type, value, relay] : [type, value];
|
||||
}
|
||||
|
||||
const currentVideoPointer = this.state.getCurrentVideoPointer();
|
||||
if (
|
||||
context === "modal" &&
|
||||
Array.isArray(currentVideoPointer) &&
|
||||
currentVideoPointer.length >= 2
|
||||
) {
|
||||
return currentVideoPointer;
|
||||
}
|
||||
|
||||
const currentVideo = this.state.getCurrentVideo();
|
||||
if (
|
||||
context === "modal" &&
|
||||
Array.isArray(currentVideo?.pointer) &&
|
||||
currentVideo.pointer.length >= 2
|
||||
) {
|
||||
return currentVideo.pointer;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleRepostAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
|
||||
const currentVideo = this.state.getCurrentVideo();
|
||||
const fallbackEventId =
|
||||
context === "modal" && currentVideo?.id ? currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.ui.showError("No event is available to repost.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = this.derivePointerFromDataset(dataset, context);
|
||||
|
||||
let author =
|
||||
typeof dataset.author === "string" ? dataset.author.trim() : "";
|
||||
if (!author && context === "modal" && currentVideo?.pubkey) {
|
||||
author = currentVideo.pubkey;
|
||||
}
|
||||
|
||||
const rawKindValue = (() => {
|
||||
if (typeof dataset.kind === "string" && dataset.kind.trim()) {
|
||||
const parsed = Number.parseInt(dataset.kind.trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
if (Number.isFinite(dataset.kind)) {
|
||||
return Number(dataset.kind);
|
||||
}
|
||||
if (Number.isFinite(currentVideo?.kind)) {
|
||||
return Number(currentVideo.kind);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const options = {
|
||||
pointer,
|
||||
pointerType: dataset.pointerType,
|
||||
pointerValue: dataset.pointerValue,
|
||||
pointerRelay: dataset.pointerRelay,
|
||||
authorPubkey: author,
|
||||
};
|
||||
|
||||
if (Number.isFinite(rawKindValue)) {
|
||||
options.kind = Math.floor(rawKindValue);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.services.nostrClient.repostEvent(
|
||||
targetEventId,
|
||||
options
|
||||
);
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "repost-failed";
|
||||
switch (code) {
|
||||
case "invalid-event-id":
|
||||
this.ui.showError("No event is available to repost.");
|
||||
break;
|
||||
case "missing-actor":
|
||||
this.ui.showError(
|
||||
"Cannot sign the repost right now. Please refresh and try again."
|
||||
);
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.ui.showError(
|
||||
"Cannot reach relays right now. Please try again later."
|
||||
);
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.ui.showError("No relay accepted the repost attempt.");
|
||||
break;
|
||||
case "signing-failed":
|
||||
this.ui.showError("Failed to sign the repost. Please try again.");
|
||||
break;
|
||||
default:
|
||||
this.ui.showError(
|
||||
"Failed to repost the video. Please try again later."
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedCount = Array.isArray(result.summary?.accepted)
|
||||
? result.summary.accepted.length
|
||||
: 0;
|
||||
const relayCount =
|
||||
acceptedCount > 0
|
||||
? acceptedCount
|
||||
: Array.isArray(result.relays)
|
||||
? result.relays.length
|
||||
: acceptedCount;
|
||||
|
||||
const fragments = [];
|
||||
if (relayCount > 0) {
|
||||
fragments.push(
|
||||
`Reposted to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`
|
||||
);
|
||||
} else {
|
||||
fragments.push("Reposted.");
|
||||
}
|
||||
|
||||
if (result.sessionActor) {
|
||||
fragments.push("Boost as session user.");
|
||||
}
|
||||
|
||||
this.ui.showSuccess(fragments.join(" ").trim());
|
||||
} catch (error) {
|
||||
devLogger.warn("[EngagementController] Repost action failed:", error);
|
||||
this.ui.showError("Failed to repost the video. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
async handleMirrorAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
|
||||
const currentVideo = this.state.getCurrentVideo();
|
||||
const fallbackEventId =
|
||||
context === "modal" && currentVideo?.id ? currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.ui.showError("No event is available to mirror.");
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitUrl =
|
||||
typeof dataset.url === "string" && dataset.url.trim()
|
||||
? dataset.url.trim()
|
||||
: "";
|
||||
const fallbackUrl =
|
||||
context === "modal" && typeof currentVideo?.url === "string"
|
||||
? currentVideo.url.trim()
|
||||
: "";
|
||||
const targetUrl = explicitUrl || fallbackUrl;
|
||||
|
||||
if (!targetUrl) {
|
||||
this.ui.showError("This video does not expose a hosted URL to mirror.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMagnet =
|
||||
typeof dataset.magnet === "string" && dataset.magnet.trim()
|
||||
? dataset.magnet.trim()
|
||||
: "";
|
||||
const fallbackMagnet =
|
||||
context === "modal"
|
||||
? currentVideo?.magnet || currentVideo?.originalMagnet || ""
|
||||
: "";
|
||||
const magnet = rawMagnet || fallbackMagnet;
|
||||
|
||||
const thumbnail =
|
||||
typeof dataset.thumbnail === "string" && dataset.thumbnail.trim()
|
||||
? dataset.thumbnail.trim()
|
||||
: context === "modal" && typeof currentVideo?.thumbnail === "string"
|
||||
? currentVideo.thumbnail.trim()
|
||||
: "";
|
||||
|
||||
const description =
|
||||
typeof dataset.description === "string" && dataset.description.trim()
|
||||
? dataset.description.trim()
|
||||
: context === "modal" && typeof currentVideo?.description === "string"
|
||||
? currentVideo.description.trim()
|
||||
: "";
|
||||
|
||||
const title =
|
||||
typeof dataset.title === "string" && dataset.title.trim()
|
||||
? dataset.title.trim()
|
||||
: context === "modal" && typeof currentVideo?.title === "string"
|
||||
? currentVideo.title.trim()
|
||||
: "";
|
||||
|
||||
const datasetPrivate =
|
||||
dataset.isPrivate === "true" || dataset.isPrivate === true ? true : false;
|
||||
const fallbackPrivate =
|
||||
context === "modal" && currentVideo?.isPrivate === true;
|
||||
const isPrivate = datasetPrivate || fallbackPrivate;
|
||||
|
||||
if (isPrivate) {
|
||||
this.ui.showError("Mirroring is unavailable for private videos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
url: targetUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
title,
|
||||
isPrivate,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.services.nostrClient.mirrorVideoEvent(
|
||||
targetEventId,
|
||||
options
|
||||
);
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "mirror-failed";
|
||||
switch (code) {
|
||||
case "invalid-event-id":
|
||||
this.ui.showError("No event is available to mirror.");
|
||||
break;
|
||||
case "missing-url":
|
||||
this.ui.showError(
|
||||
"This video does not expose a hosted URL to mirror."
|
||||
);
|
||||
break;
|
||||
case "missing-actor":
|
||||
this.ui.showError(
|
||||
"Cannot sign the mirror right now. Please refresh and try again."
|
||||
);
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.ui.showError(
|
||||
"Cannot reach relays right now. Please try again later."
|
||||
);
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.ui.showError("No relay accepted the mirror attempt.");
|
||||
break;
|
||||
case "signing-failed":
|
||||
this.ui.showError("Failed to sign the mirror. Please try again.");
|
||||
break;
|
||||
default:
|
||||
this.ui.showError(
|
||||
"Failed to mirror the video. Please try again later."
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedCount = Array.isArray(result.summary?.accepted)
|
||||
? result.summary.accepted.length
|
||||
: 0;
|
||||
const relayCount =
|
||||
acceptedCount > 0
|
||||
? acceptedCount
|
||||
: Array.isArray(result.relays)
|
||||
? result.relays.length
|
||||
: acceptedCount;
|
||||
|
||||
const fragments = [];
|
||||
if (relayCount > 0) {
|
||||
fragments.push(
|
||||
`Mirrored to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`
|
||||
);
|
||||
} else {
|
||||
fragments.push("Mirrored.");
|
||||
}
|
||||
|
||||
if (result.sessionActor) {
|
||||
fragments.push("Boost as session user.");
|
||||
}
|
||||
|
||||
this.ui.showSuccess(fragments.join(" ").trim());
|
||||
} catch (error) {
|
||||
devLogger.warn("[EngagementController] Mirror action failed:", error);
|
||||
this.ui.showError("Failed to mirror the video. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
async handleEnsurePresenceAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
|
||||
const currentVideo = this.state.getCurrentVideo();
|
||||
const fallbackEventId =
|
||||
context === "modal" && currentVideo?.id ? currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.ui.showError("No event is available to rebroadcast.");
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitPubkey =
|
||||
typeof dataset.pubkey === "string" && dataset.pubkey.trim()
|
||||
? dataset.pubkey.trim()
|
||||
: "";
|
||||
const datasetAuthor =
|
||||
typeof dataset.author === "string" && dataset.author.trim()
|
||||
? dataset.author.trim()
|
||||
: "";
|
||||
const fallbackPubkey =
|
||||
context === "modal" && typeof currentVideo?.pubkey === "string"
|
||||
? currentVideo.pubkey
|
||||
: datasetAuthor;
|
||||
const targetPubkey = explicitPubkey || fallbackPubkey || "";
|
||||
|
||||
try {
|
||||
const result = await this.services.nostrClient.rebroadcastEvent(
|
||||
targetEventId,
|
||||
{
|
||||
pubkey: targetPubkey,
|
||||
}
|
||||
);
|
||||
|
||||
if (result?.throttled) {
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
Number(result?.cooldown?.remainingMs) || 0
|
||||
);
|
||||
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
||||
const message =
|
||||
remainingSeconds > 0
|
||||
? `Rebroadcast is cooling down. Try again in ${remainingSeconds}s.`
|
||||
: "Rebroadcast is cooling down. Try again soon.";
|
||||
this.ui.showStatus(message);
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.setTimeout === "function"
|
||||
) {
|
||||
window.setTimeout(() => {
|
||||
this.ui.showStatus("");
|
||||
}, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "rebroadcast-failed";
|
||||
switch (code) {
|
||||
case "event-not-found":
|
||||
this.ui.showError(
|
||||
"Original event payload is unavailable. Reload and try again."
|
||||
);
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.ui.showError("No relay accepted the rebroadcast attempt.");
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.ui.showError(
|
||||
"Cannot reach relays right now. Please try again later."
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.ui.showError(
|
||||
"Failed to rebroadcast. Please try again later."
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.alreadyPresent) {
|
||||
this.ui.showSuccess("Relays already have this revision.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ui.showSuccess("Rebroadcast requested across relays.");
|
||||
} catch (error) {
|
||||
devLogger.warn("[EngagementController] Rebroadcast action failed:", error);
|
||||
this.ui.showError("Failed to rebroadcast. Please try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import EngagementController from "../../js/ui/engagementController.js";
|
||||
|
||||
test("EngagementController", async (t) => {
|
||||
let controller;
|
||||
let mockNostrClient;
|
||||
let mockShowError;
|
||||
let mockShowSuccess;
|
||||
let mockShowStatus;
|
||||
let mockGetCurrentVideo;
|
||||
let mockGetCurrentVideoPointer;
|
||||
|
||||
t.beforeEach(() => {
|
||||
mockNostrClient = {
|
||||
repostEvent: t.mock.fn(),
|
||||
mirrorVideoEvent: t.mock.fn(),
|
||||
rebroadcastEvent: t.mock.fn(),
|
||||
};
|
||||
mockShowError = t.mock.fn();
|
||||
mockShowSuccess = t.mock.fn();
|
||||
mockShowStatus = t.mock.fn();
|
||||
mockGetCurrentVideo = t.mock.fn();
|
||||
mockGetCurrentVideoPointer = t.mock.fn();
|
||||
|
||||
controller = new EngagementController({
|
||||
services: { nostrClient: mockNostrClient },
|
||||
ui: {
|
||||
showError: mockShowError,
|
||||
showSuccess: mockShowSuccess,
|
||||
showStatus: mockShowStatus,
|
||||
},
|
||||
state: {
|
||||
getCurrentVideo: mockGetCurrentVideo,
|
||||
getCurrentVideoPointer: mockGetCurrentVideoPointer
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("handleRepostAction - should show error if no event ID is available", async () => {
|
||||
await controller.handleRepostAction({});
|
||||
assert.equal(mockShowError.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["No event is available to repost."]);
|
||||
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
await t.test("handleRepostAction - should call repostEvent with correct parameters", async () => {
|
||||
const eventId = "test-event-id";
|
||||
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
|
||||
|
||||
await controller.handleRepostAction({ eventId, kind: "30078" });
|
||||
|
||||
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 1);
|
||||
const args = mockNostrClient.repostEvent.mock.calls[0].arguments;
|
||||
assert.equal(args[0], eventId);
|
||||
assert.equal(args[1].kind, 30078);
|
||||
|
||||
assert.equal(mockShowSuccess.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Reposted."]);
|
||||
});
|
||||
|
||||
await t.test("handleRepostAction - should handle failure from repostEvent", async () => {
|
||||
const eventId = "test-event-id";
|
||||
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: false, error: "signing-failed" }));
|
||||
|
||||
await controller.handleRepostAction({ eventId });
|
||||
|
||||
assert.equal(mockShowError.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["Failed to sign the repost. Please try again."]);
|
||||
});
|
||||
|
||||
await t.test("handleRepostAction - should use currentVideoPointer when in modal context", async () => {
|
||||
const eventId = "test-event-id";
|
||||
const videoPointer = ["nevent", "test-nevent", "wss://relay.example.com"];
|
||||
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
|
||||
|
||||
// In node:test, mocking return values is done via implementation if it's a mock function
|
||||
mockGetCurrentVideoPointer.mock.mockImplementation(() => videoPointer);
|
||||
|
||||
await controller.handleRepostAction({ eventId, context: "modal" });
|
||||
|
||||
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 1);
|
||||
const args = mockNostrClient.repostEvent.mock.calls[0].arguments;
|
||||
assert.deepEqual(args[1].pointer, videoPointer);
|
||||
});
|
||||
|
||||
await t.test("handleMirrorAction - should show error if video has no URL", async () => {
|
||||
const eventId = "test-event-id";
|
||||
await controller.handleMirrorAction({ eventId });
|
||||
assert.equal(mockShowError.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["This video does not expose a hosted URL to mirror."]);
|
||||
});
|
||||
|
||||
await t.test("handleMirrorAction - should call mirrorVideoEvent when URL is provided", async () => {
|
||||
const eventId = "test-event-id";
|
||||
const url = "https://example.com/video.mp4";
|
||||
mockNostrClient.mirrorVideoEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
|
||||
|
||||
await controller.handleMirrorAction({ eventId, url });
|
||||
|
||||
assert.equal(mockNostrClient.mirrorVideoEvent.mock.calls.length, 1);
|
||||
const args = mockNostrClient.mirrorVideoEvent.mock.calls[0].arguments;
|
||||
assert.equal(args[0], eventId);
|
||||
assert.equal(args[1].url, url);
|
||||
|
||||
assert.equal(mockShowSuccess.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Mirrored."]);
|
||||
});
|
||||
|
||||
await t.test("handleMirrorAction - should prevent mirroring private videos", async () => {
|
||||
const eventId = "test-event-id";
|
||||
const url = "https://example.com/video.mp4";
|
||||
await controller.handleMirrorAction({ eventId, url, isPrivate: true });
|
||||
|
||||
assert.equal(mockShowError.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["Mirroring is unavailable for private videos."]);
|
||||
assert.equal(mockNostrClient.mirrorVideoEvent.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
await t.test("handleEnsurePresenceAction - should show error if no event ID", async () => {
|
||||
await controller.handleEnsurePresenceAction({});
|
||||
assert.equal(mockShowError.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["No event is available to rebroadcast."]);
|
||||
});
|
||||
|
||||
await t.test("handleEnsurePresenceAction - should handle throttled response", async () => {
|
||||
const eventId = "test-event-id";
|
||||
mockNostrClient.rebroadcastEvent.mock.mockImplementation(async () => ({ throttled: true, cooldown: { remainingMs: 5000 } }));
|
||||
|
||||
global.window = { setTimeout: t.mock.fn() };
|
||||
|
||||
await controller.handleEnsurePresenceAction({ eventId });
|
||||
|
||||
assert.equal(mockShowStatus.mock.calls.length, 1);
|
||||
assert.match(mockShowStatus.mock.calls[0].arguments[0], /Rebroadcast is cooling down/);
|
||||
|
||||
delete global.window;
|
||||
});
|
||||
|
||||
await t.test("handleEnsurePresenceAction - should show success on successful rebroadcast", async () => {
|
||||
const eventId = "test-event-id";
|
||||
mockNostrClient.rebroadcastEvent.mock.mockImplementation(async () => ({ ok: true }));
|
||||
|
||||
await controller.handleEnsurePresenceAction({ eventId });
|
||||
|
||||
assert.equal(mockShowSuccess.mock.calls.length, 1);
|
||||
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Rebroadcast requested across relays."]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user