diff --git a/js/app.js b/js/app.js index d35d15a5..16e6ec00 100644 --- a/js/app.js +++ b/js/app.js @@ -445,6 +445,29 @@ class Application { this.videoModal && typeof this.videoModal.addEventListener === "function" ) { + this.videoModal.addEventListener("video:share-nostr", () => { + this.showStatus( + "Sharing on Nostr is not implemented yet. Coming soon!", + ); + }); + + this.videoModal.addEventListener("video:copy-cdn", (event) => { + const video = event?.detail?.video || this.currentVideo; + const url = video?.url || ""; + if (!url) { + this.showError("No CDN link available to copy."); + return; + } + navigator.clipboard + .writeText(url) + .then(() => this.showSuccess("CDN link copied to clipboard!")) + .catch(() => this.showError("Failed to copy CDN link.")); + }); + + this.videoModal.addEventListener("video:copy-magnet", () => { + this.handleCopyMagnet(); + }); + this.videoModal.addEventListener("playback:switch-source", (event) => { const detail = event?.detail || {}; const { source } = detail; @@ -5094,11 +5117,6 @@ class Application { } } - setCopyMagnetState(enabled) { - if (this.videoModal) { - this.videoModal.setCopyEnabled(enabled); - } - } setShareButtonState(enabled) { if (this.videoModal) { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index e7968dfc..2ceedeac 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -1,6 +1,9 @@ import { createModalAccessibility } from "./modalAccessibility.js"; import createPopover from "../overlay/popoverEngine.js"; -import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js"; +import { + createVideoMoreMenuPanel, + createVideoShareMenuPanel, +} from "./videoMenuRenderers.js"; import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; @@ -661,6 +664,7 @@ export class VideoModal { this.modalZapDialogOpen = false; this.setupModalZapPopover(); this.setupModalMorePopover(); + this.setupModalSharePopover(); this.refreshActiveVideoModeration({ video: this.activeVideo }); @@ -2220,7 +2224,8 @@ export class VideoModal { if (this.copyMagnetBtn) { this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest); } - if (this.shareBtn) { + if (this.shareBtn && this.shareBtn.dataset.modalMenuHandler !== "true") { + this.shareBtn.dataset.modalMenuHandler = "true"; this.shareBtn.addEventListener("click", this.handleShareRequest); } @@ -2523,6 +2528,149 @@ export class VideoModal { this.modalMorePopover = popover; } + setupModalSharePopover() { + if (!this.shareBtn) { + this.modalSharePopover = null; + return; + } + + const documentRef = + this.shareBtn.ownerDocument || + this.document || + (typeof document !== "undefined" ? document : null); + + const render = ({ document: doc, close }) => { + const panel = this.buildModalShareMenuPanel({ document: doc, close }); + return panel; + }; + + const popover = createPopover(this.shareBtn, render, { + document: documentRef, + placement: "bottom-end", + restoreFocusOnClose: true, + }); + + if (!popover) { + this.modalSharePopover = null; + return; + } + + const originalOpen = popover.open?.bind(popover); + if (originalOpen) { + popover.open = async (...args) => { + this.refreshModalShareMenuPanel(); + return originalOpen(...args); + }; + } + + const originalDestroy = popover.destroy?.bind(popover); + if (originalDestroy) { + popover.destroy = (...args) => { + originalDestroy(...args); + if (this.modalSharePopover === popover) { + this.modalSharePopover = null; + } + }; + } + + this.shareBtn.dataset.shareMenuToggleBound = "true"; + this.modalSharePopover = popover; + } + + buildModalShareMenuPanel({ document: doc, close }) { + const isLoggedIn = + this.commentComposerState.reason !== "login-required" && + !this.commentComposerState.disabled; + + const hasMagnet = Boolean( + (typeof this.activeVideo?.magnet === "string" && + this.activeVideo.magnet.trim()) || + this.activeVideo?.torrentSupported + ); + + const hasCdn = Boolean( + typeof this.activeVideo?.url === "string" && this.activeVideo.url.trim() + ); + + const panel = createVideoShareMenuPanel({ + document: doc, + video: this.activeVideo, + isLoggedIn, + hasMagnet, + hasCdn, + context: "modal", + }); + + if (!panel) { + return null; + } + + const buttons = panel.querySelectorAll("button[data-action]"); + buttons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + const dataset = {}; + Object.entries(button.dataset || {}).forEach(([key, value]) => { + dataset[key] = value; + }); + if (!dataset.context) { + dataset.context = "modal"; + } + + const action = dataset.action || ""; + + // Map menu actions to events + if (action === "share") { + this.dispatch("video:share", { video: this.activeVideo }); + } else if (action === "share-nostr") { + this.dispatch("video:share-nostr", { video: this.activeVideo }); + } else if (action === "copy-magnet") { + this.dispatch("video:copy-magnet", { video: this.activeVideo }); + } else if (action === "copy-cdn") { + this.dispatch("video:copy-cdn", { video: this.activeVideo }); + } + + if (typeof close === "function") { + close({ reason: "action" }); + } + }); + }); + + return panel; + } + + refreshModalShareMenuPanel() { + if (!this.modalSharePopover) { + return; + } + + const existingPanel = this.modalSharePopover.getPanel?.(); + const documentRef = + existingPanel?.ownerDocument || + this.shareBtn?.ownerDocument || + this.document || + (typeof document !== "undefined" ? document : null); + + if (!documentRef) { + return; + } + + const nextPanel = this.buildModalShareMenuPanel({ + document: documentRef, + close: (options) => this.modalSharePopover?.close(options), + }); + + if (!nextPanel) { + return; + } + + if (existingPanel && existingPanel.parentNode) { + existingPanel.parentNode.replaceChild(nextPanel, existingPanel); + } + } + buildModalMoreMenuPanel({ document: doc, close }) { const panel = createVideoMoreMenuPanel({ document: doc, @@ -2675,10 +2823,19 @@ export class VideoModal { handleShareRequest(event) { event?.preventDefault?.(); + event?.stopPropagation?.(); if (this.shareBtn?.disabled) { return; } - this.dispatch("video:share", { video: this.activeVideo }); + if (this.modalSharePopover?.toggle) { + this.modalSharePopover.toggle(); + return; + } + if (this.modalSharePopover?.isOpen?.()) { + this.modalSharePopover.close?.(); + } else { + this.modalSharePopover?.open?.(); + } } handleCreatorNavigation(event) { diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index 7d6a5809..e66094ac 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -309,6 +309,66 @@ export function createVideoMoreMenuPanel({ return panel; } +export function createVideoShareMenuPanel({ + document: documentRef = null, + video = null, + isLoggedIn = false, + hasMagnet = false, + hasCdn = false, + designSystem = null, +} = {}) { + const doc = resolveDocument(documentRef); + if (!doc) { + return null; + } + normalizeDesignSystemContext(designSystem); + + const metadata = resolveVideoMetadata(video); + const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]); + panel.dataset.menu = "video-share"; + + appendMenuAction(doc, list, { + text: "Copy URL", + action: "share", + dataset: { eventId: metadata.id }, + }); + + const nostrBtn = appendMenuAction(doc, list, { + text: "Share on Nostr", + action: "share-nostr", + dataset: { eventId: metadata.id }, + }); + if (!isLoggedIn) { + nostrBtn.disabled = true; + nostrBtn.classList.add("opacity-50", "cursor-not-allowed"); + nostrBtn.title = "Log in to share on Nostr"; + } + + const magnetBtn = appendMenuAction(doc, list, { + text: "Copy Magnet", + action: "copy-magnet", + dataset: { eventId: metadata.id }, + }); + if (!hasMagnet) { + magnetBtn.disabled = true; + magnetBtn.classList.add("opacity-50", "cursor-not-allowed"); + magnetBtn.title = "No magnet link available"; + } + + const cdnBtn = appendMenuAction(doc, list, { + text: "Copy CDN Link", + action: "copy-cdn", + dataset: { eventId: metadata.id }, + }); + if (!hasCdn) { + cdnBtn.disabled = true; + cdnBtn.classList.add("opacity-50", "cursor-not-allowed"); + cdnBtn.title = "No CDN link available"; + } + + return panel; +} + export function createChannelProfileMenuPanel({ document: documentRef = null, context = "channel-profile", diff --git a/verification/debug_modal_failure.png b/verification/debug_modal_failure.png deleted file mode 100644 index c3512bdf..00000000 Binary files a/verification/debug_modal_failure.png and /dev/null differ diff --git a/verification/debug_modal_hidden.png b/verification/debug_modal_hidden.png deleted file mode 100644 index f04c0103..00000000 Binary files a/verification/debug_modal_hidden.png and /dev/null differ diff --git a/verification/debug_modal_not_visible.png b/verification/debug_modal_not_visible.png deleted file mode 100644 index b7ce350e..00000000 Binary files a/verification/debug_modal_not_visible.png and /dev/null differ diff --git a/verification/debug_post_click.png b/verification/debug_post_click.png deleted file mode 100644 index 71b5282c..00000000 Binary files a/verification/debug_post_click.png and /dev/null differ diff --git a/verification/debug_share_btn_fail.png b/verification/debug_share_btn_fail.png deleted file mode 100644 index cf83f89d..00000000 Binary files a/verification/debug_share_btn_fail.png and /dev/null differ diff --git a/verification/verify_share_menu.py b/verification/verify_share_menu.py deleted file mode 100644 index 36a4572c..00000000 --- a/verification/verify_share_menu.py +++ /dev/null @@ -1,131 +0,0 @@ -import sys -import os -import time -from playwright.sync_api import sync_playwright - -def verify_share_menu(page): - print("Navigating to app...") - page.goto("http://localhost:8000") - - # Wait for app to load - print("Waiting for app to load...") - try: - # Just wait for anything to indicate readiness, cards might be lazy loaded - page.wait_for_load_state("networkidle", timeout=5000) - # Check for card container - page.wait_for_selector("#videoList", timeout=5000) - except Exception as e: - print(f"Initial load wait error (ignoring): {e}") - - # Handle disclaimer if present - try: - print("Checking for disclaimer...") - disclaimer = page.query_selector("#disclaimerAgreeBtn") - if disclaimer and disclaimer.is_visible(): - print("Disclaimer found, clicking agree...") - page.click("#disclaimerAgreeBtn") - page.wait_for_selector("#disclaimerModal", state="hidden", timeout=3000) - print("Disclaimer dismissed.") - else: - print("No visible disclaimer found.") - except Exception as e: - print(f"Disclaimer check exception: {e}") - - # Force a refresh of the cards or wait explicitly - print("Waiting for any card...") - try: - page.wait_for_selector(".card", timeout=15000) - except Exception as e: - print(f"Failed to find cards: {e}") - page.screenshot(path="verification/debug_no_cards.png") - sys.exit(1) - - # Click the first video card to open the modal - print("Opening video modal...") - try: - # Use JS click as it is most reliable against overlays/scrolling - page.evaluate("() => { const card = document.querySelector(\".card\"); if(card) { card.scrollIntoView(); card.click(); } else { throw new Error(\"No card found\"); } }") - except Exception as e: - print(f"Click evaluation failed: {e}") - page.screenshot(path="verification/debug_click_fail.png") - sys.exit(1) - - # Wait for modal to open - print("Waiting for modal...") - try: - # Check if modal is visible - use a function that returns true immediately if visible - # We loop here to be safe and debug - for i in range(20): - is_visible = page.evaluate("() => { const el = document.getElementById(playerModal); return el && !el.hasAttribute(hidden) && !el.classList.contains(hidden); }") - if is_visible: - print("Modal is confirmed visible via JS.") - break - time.sleep(0.5) - else: - print("Modal did not become visible.") - page.screenshot(path="verification/debug_modal_not_visible.png") - # sys.exit(1) # Dont exit yet, check if its maybe just taking long or other issue - - except Exception as e: - print(f"Modal check failed: {e}") - sys.exit(1) - - # Verify "Copy Magnet" button is NOT in the main actions group - print("Verifying absence of Copy Magnet button in main UI...") - magnet_btn = page.query_selector("#copyMagnetBtn") - if magnet_btn and page.evaluate("el => !el.hasAttribute(\"hidden\") && !el.classList.contains(\"hidden\")", magnet_btn): - print("ERROR: Copy Magnet button is still visible in the main UI!") - sys.exit(1) - - # Find and click the Share button - print("Clicking Share button...") - try: - # Ensure share button is visible/clickable - # If modal wasnt visible to playwright but IS visible to JS, we might need force=True - page.click("#shareBtn", force=True) - except Exception as e: - print(f"Failed to click share button: {e}") - page.screenshot(path="verification/debug_share_btn_fail.png") - sys.exit(1) - - # Wait for the popover menu to appear - print("Waiting for Share menu...") - try: - page.wait_for_selector("[data-menu=\"video-share\"]", timeout=5000) - except Exception as e: - print(f"Share menu did not appear: {e}") - page.screenshot(path="verification/debug_share_menu_fail.png") - sys.exit(1) - - # Take a screenshot of the open menu - print("Taking screenshot...") - page.screenshot(path="verification/share_menu_verification.png") - - # Verify menu items - print("Verifying menu items...") - menu = page.locator("[data-menu=\"video-share\"]") - text_content = menu.inner_text() - print(f"Menu content: {text_content}") - - if "Copy URL" not in text_content: - print("ERROR: Copy URL item missing") - if "Share on Nostr" not in text_content: - print("ERROR: Share on Nostr item missing") - if "Copy Magnet" not in text_content: - print("ERROR: Copy Magnet item missing") - if "Copy CDN Link" not in text_content: - print("ERROR: Copy CDN Link item missing") - - print("Verification complete!") - -if __name__ == "__main__": - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - try: - verify_share_menu(page) - except Exception as e: - print(f"Verification failed: {e}") - sys.exit(1) - finally: - browser.close()