From cd9418aa92a8eebb31547c7b288664bf10806478 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:36:33 +0000 Subject: [PATCH] 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`. --- js/app.js | 371 ++------------------- js/ui/engagementController.js | 426 ++++++++++++++++++++++++ tests/ui/engagement-controller.test.mjs | 149 +++++++++ 3 files changed, 597 insertions(+), 349 deletions(-) create mode 100644 js/ui/engagementController.js create mode 100644 tests/ui/engagement-controller.test.mjs diff --git a/js/app.js b/js/app.js index e5c8110e..e553b8c0 100644 --- a/js/app.js +++ b/js/app.js @@ -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); } } diff --git a/js/ui/engagementController.js b/js/ui/engagementController.js new file mode 100644 index 00000000..b2c5c5c9 --- /dev/null +++ b/js/ui/engagementController.js @@ -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."); + } + } +} diff --git a/tests/ui/engagement-controller.test.mjs b/tests/ui/engagement-controller.test.mjs new file mode 100644 index 00000000..ca825fe2 --- /dev/null +++ b/tests/ui/engagement-controller.test.mjs @@ -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."]); + }); +});