diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 3d6789d5..b56ea6ba 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -3450,6 +3450,81 @@ export class VideoModal { } } + // Delegated click handler for the share popover's menu-action buttons + // (Copy URL / Copy Magnet / Copy CDN / Share on Nostr). The forced-source + // test links self-wire their own copy, so they are skipped here. + bindShareMenuActions(panel) { + if (!panel || typeof panel.addEventListener !== "function") { + return; + } + panel.addEventListener("click", (event) => { + const target = event.target; + const button = + target && typeof target.closest === "function" + ? target.closest("button[data-action]") + : null; + if (!button || button.disabled) { + return; + } + const action = button.dataset?.action || ""; + if (action === "copy-webtorrent-url" || action === "copy-cdn-url") { + return; // self-wired in createVideoShareMenuPanel + } + event.preventDefault(); + event.stopPropagation(); + this.handleShareMenuAction(action); + this.modalSharePopover?.close?.(); + }); + } + + handleShareMenuAction(action) { + const video = this.activeVideo; + switch (action) { + case "copy-magnet": + this.dispatch("video:copy-magnet", { video }); + break; + case "copy-cdn": + this.dispatch("video:copy-cdn", { video }); + break; + case "share-nostr": + this.dispatch("video:share-nostr", { + video, + trigger: this.shareBtn || null, + }); + break; + case "share": + // "Copy URL" — the shareable deep link to this video. + this.dispatch("video:copy-url", { + video, + url: this.buildShareUrl(video), + }); + break; + default: + break; + } + } + + // Plain `?v=` share link (no forced playback source). + buildShareUrl(video) { + const eventId = + video && typeof video.id === "string" ? video.id.trim() : ""; + if (!eventId) { + return ""; + } + const win = this.window || globalThis; + const neventEncode = win?.NostrTools?.nip19?.neventEncode; + const loc = win?.location; + if (typeof neventEncode !== "function" || !loc) { + return ""; + } + try { + const nevent = neventEncode({ id: eventId }); + return `${loc.origin}${loc.pathname}?v=${encodeURIComponent(nevent)}`; + } catch (error) { + return ""; + } + } + setupModalSharePopover() { if (!this.playerModal || !this.shareBtn) { return; @@ -3483,6 +3558,10 @@ export class VideoModal { this.buildForcedSourceShareUrl(this.activeVideo, playback), }); if (panel) { + // The share menu's Copy URL / Copy Magnet / Copy CDN / Share on Nostr + // items only set `data-action` (like the ⋯ menu) — unlike the ⋯ menu, + // nothing wired their clicks, so they were dead. Delegate them here. + this.bindShareMenuActions(panel); container.appendChild(panel); } return panel; diff --git a/js/ui/videoModalController.js b/js/ui/videoModalController.js index 68bff16d..efce18f8 100644 --- a/js/ui/videoModalController.js +++ b/js/ui/videoModalController.js @@ -69,6 +69,10 @@ export default class VideoModalController { this.handleCopyCdn(event); }); + this.videoModal.addEventListener("video:copy-url", (event) => { + this.handleCopyUrl(event); + }); + this.videoModal.addEventListener("video:copy-magnet", () => { if (this.handleCopyMagnetCallback) { this.handleCopyMagnetCallback(); @@ -115,6 +119,28 @@ export default class VideoModalController { }); } + handleCopyUrl(event) { + const url = event?.detail?.url || ""; + if (!url) { + if (this.showError) { + this.showError("Could not generate a share link."); + } + return; + } + navigator.clipboard + .writeText(url) + .then(() => { + if (this.showSuccess) { + this.showSuccess("Video link copied to clipboard!"); + } + }) + .catch(() => { + if (this.showError) { + this.showError("Failed to copy the link."); + } + }); + } + handleSourceSwitch(event) { const detail = event?.detail || {}; const { source } = detail; diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs index 803d9e3f..0cb0e930 100644 --- a/tests/video-modal-controllers.test.mjs +++ b/tests/video-modal-controllers.test.mjs @@ -308,3 +308,76 @@ test("reaction click with no matching button does not dispatch", async (t) => { modal.handleReactionClick({}); assert.equal(fired, false, "no reaction event for an unrelated/empty target"); }); + +// Share popover (reported: "Copy URL / Copy Magnet / Copy CDN don't work"). The +// share menu buttons only set data-action (like the ⋯ menu), but unlike the ⋯ +// menu nothing wired their clicks, so they were dead. handleShareMenuAction now +// routes each to the event videoModalController already handles. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-video-modal-share-menu-routing +// given: "a video modal with an active video" +// when: "handleShareMenuAction runs for a share-menu action" +// then: "it dispatches the matching video:* event; forced-source actions do not re-route" +// determinism_controls: +// - "JSDOM modal via setupModal; synchronous dispatch" +// relaxation: +// did_relax_any_assertion: false + +test("share menu Copy Magnet routes to video:copy-magnet", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + modal.activeVideo = { id: "e".repeat(64), url: "https://x/v.mp4", magnet: "" }; + const events = []; + modal.addEventListener("video:copy-magnet", (e) => events.push(e.detail)); + modal.handleShareMenuAction("copy-magnet"); + assert.equal(events.length, 1, "Copy Magnet dispatches video:copy-magnet"); + assert.equal(events[0].video.id, "e".repeat(64)); +}); + +test("share menu Copy CDN routes to video:copy-cdn", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + modal.activeVideo = { id: "e".repeat(64), url: "https://x/v.mp4" }; + const events = []; + modal.addEventListener("video:copy-cdn", (e) => events.push(e.detail)); + modal.handleShareMenuAction("copy-cdn"); + assert.equal(events.length, 1, "Copy CDN dispatches video:copy-cdn"); + assert.equal(events[0].video.url, "https://x/v.mp4"); +}); + +test("share menu Copy URL routes to video:copy-url", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + modal.activeVideo = { id: "e".repeat(64) }; + const events = []; + modal.addEventListener("video:copy-url", (e) => events.push(e.detail)); + modal.handleShareMenuAction("share"); + assert.equal(events.length, 1, "Copy URL dispatches video:copy-url"); + assert.equal(typeof events[0].url, "string"); +}); + +test("share menu Share on Nostr routes to video:share-nostr", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + modal.activeVideo = { id: "e".repeat(64) }; + const events = []; + modal.addEventListener("video:share-nostr", (e) => events.push(e.detail)); + modal.handleShareMenuAction("share-nostr"); + assert.equal(events.length, 1, "Share on Nostr dispatches video:share-nostr"); +}); + +test("share menu forced-source actions do not re-route through handleShareMenuAction", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + modal.activeVideo = { id: "e".repeat(64) }; + let fired = false; + ["video:copy-magnet", "video:copy-cdn", "video:copy-url", "video:share-nostr"].forEach( + (name) => modal.addEventListener(name, () => { fired = true; }) + ); + modal.handleShareMenuAction("copy-cdn-url"); + modal.handleShareMenuAction("copy-webtorrent-url"); + assert.equal(fired, false, "forced-source links self-wire; not routed here"); +});