feat: add three-dot menu to articles and enhance highlight menus

- Add three-dot menu button at end of articles (before Mark as Read)
- Right-aligned menu with two options:
  - Open on Nostr (using nostr gateway/portal)
  - Open with Native App (using nostr: URI scheme)
- Add 'Open with Native App' option to highlight card menus
- Menu only appears for nostr-native articles (kind:30023)
- Styled consistently with highlight card menus
- Click outside to close menu functionality
This commit is contained in:
Gigi
2025-10-13 17:03:00 +02:00
parent cef359af29
commit 0fa5ac536b
3 changed files with 134 additions and 10 deletions

View File

@@ -1,11 +1,14 @@
import React, { useMemo, useState, useEffect } from 'react'
import React, { useMemo, useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
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, faCheck } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faCheck, faEllipsisH, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -82,6 +85,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -104,6 +109,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection
})
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (articleMenuRef.current && !articleMenuRef.current.contains(event.target as Node)) {
setShowArticleMenu(false)
}
}
if (showArticleMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
@@ -115,6 +136,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
// Get article links for menu
const getArticleLinks = () => {
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: currentArticle.pubkey,
identifier: dTag,
relays: relayHints
})
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
}
}
const articleLinks = getArticleLinks()
const handleMenuToggle = () => {
setShowArticleMenu(!showArticleMenu)
}
const handleOpenPortal = () => {
if (articleLinks) {
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
const handleOpenNative = () => {
if (articleLinks) {
window.location.href = articleLinks.native
}
setShowArticleMenu(false)
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -277,6 +340,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={articleMenuRef}>
<button
className="article-menu-btn"
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showArticleMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Mark as Read button */}
{activeAccount && (
<div className="mark-as-read-container">

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
@@ -123,7 +123,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
const getHighlightLink = () => {
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
@@ -136,10 +136,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
author: highlight.pubkey,
kind: 9802
})
return getNostrUrl(nevent)
return {
portal: getNostrUrl(nevent),
native: `nostr:${nevent}`
}
}
const highlightLink = getHighlightLink()
const highlightLinks = getHighlightLinks()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
@@ -283,9 +287,15 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowMenu(!showMenu)
}
const handleOpenExternal = (e: React.MouseEvent) => {
const handleOpenPortal = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(highlightLink, '_blank', 'noopener,noreferrer')
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
setShowMenu(false)
}
const handleOpenNative = (e: React.MouseEvent) => {
e.stopPropagation()
window.location.href = highlightLinks.native
setShowMenu(false)
}
@@ -364,11 +374,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleOpenExternal}
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="highlight-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
{canDelete && (
<button
className="highlight-menu-item highlight-menu-item-danger"

View File

@@ -35,8 +35,18 @@
.reader-html pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
/* Article menu */
.article-menu-container { display: flex; justify-content: flex-end; padding: 1.5rem 0 0.5rem; margin-top: 2rem; border-top: 1px solid #333; }
.article-menu-wrapper { position: relative; }
.article-menu-btn { background: none; border: 1px solid #444; color: #888; cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: #646cff; background: rgba(100, 108, 255, 0.1); border-color: #646cff; }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
/* Mark as Read button */
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 2rem; border-top: 1px solid #333; }
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 1rem; border-top: 1px solid #333; }
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: #2a2a2a; color: #ddd; border: 1px solid #444; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
.mark-as-read-btn:hover:not(:disabled) { background: #333; border-color: #555; transform: translateY(-1px); }
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }