diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 12ee330a..1484248e 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -6,7 +6,7 @@ import rehypeRaw from 'rehype-raw' import rehypePrism from 'rehype-prism-plus' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import 'prismjs/themes/prism-tomorrow.css' -import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons' +import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons' import { nip19 } from 'nostr-tools' import { getNostrUrl } from '../config/nostrGateways' import { RELAYS } from '../config/relays' @@ -88,7 +88,9 @@ const ContentPanel: React.FC = ({ const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false) const [showCheckAnimation, setShowCheckAnimation] = useState(false) const [showArticleMenu, setShowArticleMenu] = useState(false) + const [showVideoMenu, setShowVideoMenu] = useState(false) const articleMenuRef = useRef(null) + const videoMenuRef = useRef(null) const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool) const { finalHtml, relevantHighlights } = useHighlightedContent({ @@ -114,18 +116,22 @@ const ContentPanel: React.FC = ({ // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (articleMenuRef.current && !articleMenuRef.current.contains(event.target as Node)) { + const target = event.target as Node + if (articleMenuRef.current && !articleMenuRef.current.contains(target)) { setShowArticleMenu(false) } + if (videoMenuRef.current && !videoMenuRef.current.contains(target)) { + setShowVideoMenu(false) + } } - if (showArticleMenu) { + if (showArticleMenu || showVideoMenu) { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } } - }, [showArticleMenu]) + }, [showArticleMenu, showVideoMenu]) const readingStats = useMemo(() => { const content = markdown || html || '' @@ -151,7 +157,7 @@ const ContentPanel: React.FC = ({ const ss = String(seconds).padStart(2, '0') return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` } - const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type) + // Get article links for menu const getArticleLinks = () => { @@ -181,6 +187,8 @@ const ContentPanel: React.FC = ({ setShowArticleMenu(!showArticleMenu) } + const toggleVideoMenu = () => setShowVideoMenu(v => !v) + const handleOpenPortal = () => { if (articleLinks) { window.open(articleLinks.portal, '_blank', 'noopener,noreferrer') @@ -195,6 +203,75 @@ const ContentPanel: React.FC = ({ setShowArticleMenu(false) } + // Video actions + const buildNativeVideoUrl = (url: string): string | null => { + try { + const u = new URL(url) + const host = u.hostname + if (host.includes('youtube.com')) { + const id = u.searchParams.get('v') + return id ? `youtube://watch?v=${id}` : `youtube://${u.pathname}${u.search}` + } + if (host === 'youtu.be') { + const id = u.pathname.replace('/', '') + return id ? `youtube://watch?v=${id}` : 'youtube://' + } + if (host.includes('vimeo.com')) { + const id = u.pathname.split('/').filter(Boolean)[0] + return id ? `vimeo://app.vimeo.com/videos/${id}` : 'vimeo://' + } + if (host.includes('dailymotion.com') || host === 'dai.ly') { + // dailymotion.com/video/ or dai.ly/ + const parts = u.pathname.split('/').filter(Boolean) + const id = host === 'dai.ly' ? parts[0] : (parts[1] || '') + return id ? `dailymotion://video/${id}` : 'dailymotion://' + } + return null + } catch { + return null + } + } + + const handleOpenVideoExternal = () => { + if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer') + setShowVideoMenu(false) + } + + const handleOpenVideoNative = () => { + if (!selectedUrl) return + const native = buildNativeVideoUrl(selectedUrl) + if (native) { + window.location.href = native + } else { + window.location.href = selectedUrl + } + setShowVideoMenu(false) + } + + const handleCopyVideoUrl = async () => { + try { + if (selectedUrl) await navigator.clipboard.writeText(selectedUrl) + } catch (e) { + console.warn('Clipboard copy failed', e) + } finally { + setShowVideoMenu(false) + } + } + + const handleShareVideoUrl = async () => { + try { + if (selectedUrl && (navigator as unknown as { share?: (d: ShareData) => Promise }).share) { + await (navigator as unknown as { share: (d: ShareData) => Promise }).share({ title: title || 'Video', url: selectedUrl }) + } else if (selectedUrl) { + await navigator.clipboard.writeText(selectedUrl) + } + } catch (e) { + console.warn('Share failed', e) + } finally { + setShowVideoMenu(false) + } + } + // Check if article is already marked as read when URL/article changes useEffect(() => { const checkReadStatus = async () => { @@ -339,6 +416,37 @@ const ContentPanel: React.FC = ({ onDuration={(d) => setVideoDurationSec(Math.floor(d))} /> +
+
+ + {showVideoMenu && ( +
+ + + + +
+ )} +
+
{activeAccount && (