@ -12,7 +12,8 @@ import { useMuteList } from '@/providers/MuteListProvider'
@@ -12,7 +12,8 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BIG_RELAY_URLS , FAST_READ_RELAY_URLS , FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { Bell , BellOff , Code , Copy , Link , SatelliteDish , Trash2 , TriangleAlert , Pin , FileDown , Globe , BookOpen , Highlighter } from 'lucide-react'
import { nip66Service } from '@/services/nip66.service'
import { Bell , BellOff , Code , Copy , Link , SatelliteDish , Trash2 , TriangleAlert , Pin , FileDown , Globe , BookOpen } from 'lucide-react'
import { Event , kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo , useState , useEffect , useContext } from 'react'
@ -45,7 +46,6 @@ interface UseMenuActionsProps {
@@ -45,7 +46,6 @@ interface UseMenuActionsProps {
setIsRawEventDialogOpen : ( open : boolean ) = > void
setIsReportDialogOpen : ( open : boolean ) = > void
isSmallScreen : boolean
openHighlightEditor ? : ( highlightData : import ( '../PostEditor/HighlightEditor' ) . HighlightData , eventContent? : string ) = > void
}
export function useMenuActions ( {
@ -55,7 +55,6 @@ export function useMenuActions({
@@ -55,7 +55,6 @@ export function useMenuActions({
setIsRawEventDialogOpen ,
setIsReportDialogOpen ,
isSmallScreen ,
openHighlightEditor
} : UseMenuActionsProps ) {
const { t } = useTranslation ( )
// Use useContext directly to avoid error if provider is not available
@ -70,6 +69,27 @@ export function useMenuActions({
@@ -70,6 +69,27 @@ export function useMenuActions({
. . . favoriteRelays . map ( url = > normalizeUrl ( url ) || url )
] ) )
} , [ currentBrowsingRelayUrls , favoriteRelays ] )
/** All available relays: current feed, favorites, relay sets, defaults (BIG, FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo ( ( ) = > {
const urls = [
. . . currentBrowsingRelayUrls . map ( url = > normalizeUrl ( url ) || url ) ,
. . . favoriteRelays . map ( url = > normalizeUrl ( url ) || url ) ,
. . . relaySets . flatMap ( set = > set . relayUrls . map ( url = > normalizeUrl ( url ) || url ) ) ,
. . . BIG_RELAY_URLS . map ( url = > normalizeUrl ( url ) || url ) ,
. . . FAST_READ_RELAY_URLS . map ( url = > normalizeUrl ( url ) || url ) ,
. . . FAST_WRITE_RELAY_URLS . map ( url = > normalizeUrl ( url ) || url )
] . filter ( Boolean ) as string [ ]
return Array . from ( new Set ( urls ) )
} , [ currentBrowsingRelayUrls , favoriteRelays , relaySets ] )
/** Number of relays in NIP-66 monitoring list (async); used for "All active relays" label. */
const [ monitoringListRelayCount , setMonitoringListRelayCount ] = useState < number | null > ( null )
useEffect ( ( ) = > {
nip66Service . getPublicLivelyRelayUrls ( ) . then ( ( urls ) = > {
setMonitoringListRelayCount ( urls ? . length ? ? 0 )
} )
} , [ ] )
const { mutePubkeyPublicly , mutePubkeyPrivately , unmutePubkey , mutePubkeySet } = useMuteList ( )
const isMuted = useMemo ( ( ) = > mutePubkeySet . has ( event . pubkey ) , [ mutePubkeySet , event ] )
@ -240,28 +260,93 @@ export function useMenuActions({
@@ -240,28 +260,93 @@ export function useMenuActions({
} , [ event . id , event . kind ] )
const broadcastSubMenu : SubMenuAction [ ] = useMemo ( ( ) = > {
const items = [ ]
const items : SubMenuAction [ ] = [ ]
// All available relays (local, favorite, relay sets, default/fast) — success if at least 1 accepts
if ( allAvailableRelayUrls . length > 0 ) {
items . push ( {
label : < div className = "text-left" > { t ( 'All available relays' ) } ( { allAvailableRelayUrls . length } ) < / div > ,
onClick : async ( ) = > {
closeDrawer ( )
const promise = client . publishEvent ( allAvailableRelayUrls , event ) . then ( ( result ) = > {
if ( result . successCount < 1 ) {
throw new Error ( t ( 'No relay accepted the event' ) )
}
return result
} )
toast . promise ( promise , {
loading : t ( 'Republishing...' ) ,
success : ( ) = > t ( 'Successfully republish to all available relays' ) ,
error : ( err ) = > t ( 'Failed to republish to all available relays: {{error}}' , { error : err.message } )
} )
}
} )
}
// All active relays (NIP-66 monitoring list); if none available, fallback to all available relays. Success: 5+ when using monitoring list, else 1+.
const activeRelayCount =
monitoringListRelayCount !== null
? ( monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length )
: null
items . push ( {
label : (
< div className = "text-left" >
{ t ( 'All active relays (monitoring list)' ) }
{ activeRelayCount !== null && ` ( ${ activeRelayCount } ) ` }
< / div >
) ,
onClick : async ( ) = > {
closeDrawer ( )
const promise = ( async ( ) = > {
let relays = await nip66Service . getPublicLivelyRelayUrls ( )
const usedMonitoringList = ! ! relays ? . length
if ( ! relays ? . length ) {
relays = allAvailableRelayUrls
}
if ( ! relays ? . length ) {
throw new Error ( t ( 'No relays available' ) )
}
const result = await client . publishEvent ( relays , event )
const minRequired = usedMonitoringList ? 5 : 1
if ( result . successCount < minRequired ) {
throw new Error (
usedMonitoringList
? t ( 'Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".' , { count : result.successCount } )
: t ( 'No relay accepted the event' )
)
}
return result
} ) ( )
toast . promise ( promise , {
loading : t ( 'Republishing...' ) ,
success : ( ) = > t ( 'Successfully republish to all active relays' ) ,
error : ( err ) = > t ( 'Failed to republish to all active relays: {{error}}' , { error : err.message } )
} )
} ,
separator : items.length > 0
} )
if ( pubkey && event . pubkey === pubkey ) {
items . push ( {
label : < div className = "text-left" > { t ( 'Write relays' ) } < / div > ,
separator : items.length > 0 ,
onClick : async ( ) = > {
closeDrawer ( )
const promise = async ( ) = > {
const promise = ( async ( ) = > {
const relays = await client . determineTargetRelays ( event )
if ( relays ? . length ) {
await client . publishEvent ( relays , event )
if ( ! relays ? . length ) {
throw new Error ( t ( 'No write relays configured' ) )
}
}
const result = await client . publishEvent ( relays , event )
if ( result . successCount < 1 ) {
throw new Error ( t ( 'No relay accepted the event' ) )
}
return result
} ) ( )
toast . promise ( promise , {
loading : t ( 'Republishing...' ) ,
success : ( ) = > {
return t ( 'Successfully republish to your write relays' )
} ,
error : ( err ) = > {
return t ( 'Failed to republish to your write relays: {{error}}' , {
error : err.message
} )
}
success : ( ) = > t ( 'Successfully republish to your write relays' ) ,
error : ( err ) = > t ( 'Failed to republish to your write relays: {{error}}' , { error : err.message } )
} )
}
} )
@ -275,18 +360,19 @@ export function useMenuActions({
@@ -275,18 +360,19 @@ export function useMenuActions({
label : < div className = "text-left truncate" > { set . name } < / div > ,
onClick : async ( ) = > {
closeDrawer ( )
const promise = client . publishEvent ( set . relayUrls , event )
const promise = client . publishEvent ( set . relayUrls , event ) . then ( ( result ) = > {
if ( result . successCount < 1 ) {
throw new Error ( t ( 'No relay accepted the event' ) )
}
return result
} )
toast . promise ( promise , {
loading : t ( 'Republishing...' ) ,
success : ( ) = > {
return t ( 'Successfully republish to relay set: {{name}}' , { name : set.name } )
} ,
error : ( err ) = > {
return t ( 'Failed to republish to relay set: {{name}}. Error: {{error}}' , {
name : set.name ,
error : err.message
} )
}
success : ( ) = > t ( 'Successfully republish to relay set: {{name}}' , { name : set.name } ) ,
error : ( err ) = > t ( 'Failed to republish to relay set: {{name}}. Error: {{error}}' , {
name : set.name ,
error : err.message
} )
} )
} ,
separator : index === 0
@ -305,18 +391,19 @@ export function useMenuActions({
@@ -305,18 +391,19 @@ export function useMenuActions({
) ,
onClick : async ( ) = > {
closeDrawer ( )
const promise = client . publishEvent ( [ relay ] , event )
const promise = client . publishEvent ( [ relay ] , event ) . then ( ( result ) = > {
if ( result . successCount < 1 ) {
throw new Error ( t ( 'Relay did not accept the event' ) )
}
return result
} )
toast . promise ( promise , {
loading : t ( 'Republishing...' ) ,
success : ( ) = > {
return t ( 'Successfully republish to relay: {{url}}' , { url : simplifyUrl ( relay ) } )
} ,
error : ( err ) = > {
return t ( 'Failed to republish to relay: {{url}}. Error: {{error}}' , {
url : simplifyUrl ( relay ) ,
error : err.message
} )
}
success : ( ) = > t ( 'Successfully republish to relay: {{url}}' , { url : simplifyUrl ( relay ) } ) ,
error : ( err ) = > t ( 'Failed to republish to relay: {{url}}. Error: {{error}}' , {
url : simplifyUrl ( relay ) ,
error : err.message
} )
} )
} ,
separator : index === 0
@ -325,7 +412,7 @@ export function useMenuActions({
@@ -325,7 +412,7 @@ export function useMenuActions({
}
return items
} , [ pubkey , relayUrls , relaySets ] )
} , [ pubkey , relayUrls , relaySets , allAvailableRelayUrls , monitoringListRelayCount , event , closeDrawer , t ] )
// Check if this is an article-type event
const isArticleType = useMemo ( ( ) = > {
@ -369,21 +456,6 @@ export function useMenuActions({
@@ -369,21 +456,6 @@ export function useMenuActions({
}
} , [ isArticleType , event , dTag ] )
// Check if this is an OP event that can be highlighted
const isOPEvent = useMemo ( ( ) = > {
return (
event . kind === kinds . ShortTextNote || // 1
event . kind === kinds . LongFormArticle || // 30023
event . kind === ExtendedKind . WIKI_ARTICLE || // 30818
event . kind === ExtendedKind . WIKI_ARTICLE_MARKDOWN || // 30817
event . kind === ExtendedKind . PUBLICATION || // 30040
event . kind === ExtendedKind . PUBLICATION_CONTENT || // 30041
event . kind === ExtendedKind . DISCUSSION || // 11
event . kind === ExtendedKind . COMMENT || // 1111
( event . kind === kinds . Zap && ( event . tags . some ( tag = > tag [ 0 ] === 'e' ) || event . tags . some ( tag = > tag [ 0 ] === 'a' ) ) ) // Zap receipt
)
} , [ event . kind , event . tags ] )
const menuActions : MenuAction [ ] = useMemo ( ( ) = > {
// Export functions for articles
const exportAsMarkdown = ( ) = > {
@ -553,554 +625,6 @@ export function useMenuActions({
@@ -553,554 +625,6 @@ export function useMenuActions({
} )
}
// Add "Create Highlight" action for OP events
if ( isOPEvent && openHighlightEditor ) {
actions . push ( {
icon : Highlighter ,
label : t ( 'Create Highlight' ) ,
onClick : ( ) = > {
try {
// Get selected text and paragraph context
const selection = window . getSelection ( )
let selectedText = ''
let paragraphContext = ''
if ( selection && selection . rangeCount > 0 && ! selection . isCollapsed ) {
const range = selection . getRangeAt ( 0 )
// Helper function to check if an element is a UI element that should be excluded
const isUIElement = ( element : Element | null ) : boolean = > {
if ( ! element ) return false
const tagName = element . tagName ? . toLowerCase ( )
const className = element . className || ''
const id = element . id || ''
// Exclude common UI elements
const uiTags = [ 'nav' , 'header' , 'footer' , 'aside' , 'button' , 'menu' , 'dialog' , 'form' , 'input' , 'select' , 'textarea' ]
if ( uiTags . includes ( tagName ) ) return true
// Exclude elements with UI-related classes
const uiClassPatterns = [
/sidebar/i ,
/navbar/i ,
/menu/i ,
/header/i ,
/footer/i ,
/titlebar/i ,
/button/i ,
/dialog/i ,
/modal/i ,
/drawer/i ,
/toolbar/i ,
/action/i ,
/control/i
]
if ( uiClassPatterns . some ( pattern = > pattern . test ( className ) || pattern . test ( id ) ) ) return true
// Exclude elements with role attributes that indicate UI
const role = element . getAttribute ( 'role' )
if ( role && [ 'navigation' , 'banner' , 'contentinfo' , 'complementary' , 'dialog' , 'button' , 'menubar' , 'menu' ] . includes ( role ) ) {
return true
}
return false
}
// Find the article content container (element with 'prose' class)
// This is where the actual article content is rendered
let articleContainer : Element | null = null
let container : Node | null = range . commonAncestorContainer
// Walk up the DOM tree to find the article container
while ( container && container . nodeType !== Node . ELEMENT_NODE ) {
container = container . parentNode
}
if ( container ) {
let current : Element | null = container as Element
while ( current ) {
// Check if this element is the article content container
const className = current . className || ''
if ( typeof className === 'string' && className . includes ( 'prose' ) ) {
articleContainer = current
break
}
// Also check parent elements
current = current . parentElement
}
}
// If we couldn't find the article container, try to find it by looking for the event's note container
if ( ! articleContainer ) {
// Try to find the note container by searching for elements that might contain the event
const allElements = document . querySelectorAll ( '[data-event-id], [data-note-id], .note-content, article' )
for ( const el of allElements ) {
if ( el . contains ( range . startContainer ) && el . contains ( range . endContainer ) ) {
// Check if this element has prose class or contains prose elements
const hasProse = el . classList . contains ( 'prose' ) || el . querySelector ( '.prose' )
if ( hasProse ) {
articleContainer = el . querySelector ( '.prose' ) || el
break
}
}
}
}
// Verify that the selection is within the article content and not in UI elements
let startElement : Element | null = null
let endElement : Element | null = null
if ( range . startContainer . nodeType === Node . ELEMENT_NODE ) {
startElement = range . startContainer as Element
} else {
startElement = range . startContainer . parentElement
}
if ( range . endContainer . nodeType === Node . ELEMENT_NODE ) {
endElement = range . endContainer as Element
} else {
endElement = range . endContainer . parentElement
}
// Check if selection includes UI elements
let current : Element | null = startElement
let hasUIElements = false
while ( current && current !== articleContainer ? . parentElement ) {
if ( isUIElement ( current ) ) {
hasUIElements = true
break
}
current = current . parentElement
}
if ( ! hasUIElements && endElement ) {
current = endElement
while ( current && current !== articleContainer ? . parentElement ) {
if ( isUIElement ( current ) ) {
hasUIElements = true
break
}
current = current . parentElement
}
}
// If selection includes UI elements, show error
if ( hasUIElements ) {
toast . error ( t ( 'Please select text only from the article content, not from menus or UI elements' ) )
return
}
// If we found an article container, verify selection is within it
if ( articleContainer && ! articleContainer . contains ( range . startContainer ) ) {
toast . error ( t ( 'Please select text only from the article content, not from menus or UI elements' ) )
return
}
// Create a new range that only includes content from the article
const contentRange = range . cloneRange ( )
// If we have an article container, try to constrain the range to it
// This helps ensure we only capture article content, not UI elements
if ( articleContainer ) {
try {
// Verify both start and end are within article container
const rangeStart = range . startContainer
const rangeEnd = range . endContainer
// If start is not in article container, try to adjust it
if ( ! articleContainer . contains ( rangeStart ) ) {
// This shouldn't happen if our check above worked, but handle it anyway
logger . warn ( 'Selection start is outside article container' , {
hasArticleContainer : ! ! articleContainer
} )
// Try to find the first text node in the article container
const walker = document . createTreeWalker (
articleContainer ,
NodeFilter . SHOW_TEXT ,
null
)
let node = walker . nextNode ( )
if ( node ) {
contentRange . setStart ( node , 0 )
} else {
// No text nodes in article container, reject selection
toast . error ( t ( 'Please select text from the article content' ) )
return
}
}
// If end is not in article container, try to adjust it
if ( ! articleContainer . contains ( rangeEnd ) ) {
logger . warn ( 'Selection end is outside article container' , {
hasArticleContainer : ! ! articleContainer
} )
// Try to find the last text node in the article container
const walker = document . createTreeWalker (
articleContainer ,
NodeFilter . SHOW_TEXT ,
null
)
let lastNode : Node | null = null
let node = walker . nextNode ( )
while ( node ) {
lastNode = node
node = walker . nextNode ( )
}
if ( lastNode && lastNode . textContent ) {
contentRange . setEnd ( lastNode , lastNode . textContent . length )
}
}
} catch ( e ) {
// If range manipulation fails, log and continue with original range
// But we've already validated it's not in UI elements
logger . warn ( 'Failed to constrain range to article container' , { error : e } )
}
}
// Get the selected text from the constrained range
selectedText = contentRange . toString ( ) . trim ( )
// Filter out common UI text patterns that might have been captured
const uiTextPatterns = [
/^(Home|Explore|Discussions|Notifications|Search|Profile|Settings|Post|Back|Follow|Following|Relays|Posts|Articles|Media|Pins|Bookmarks|Interests|All Types|Translate)$/i ,
/^(@|#|wss?:\/\/)/ , // Usernames, hashtags, relay URLs at start
/^(npub1|note1|nevent1|naddr1)/i // Nostr identifiers at start
]
// Check if selected text looks like UI text
if ( uiTextPatterns . some ( pattern = > pattern . test ( selectedText ) ) ) {
toast . error ( t ( 'Please select text from the article content, not from UI elements' ) )
return
}
// Find the actual paragraph element (<p> tag) containing the selection
// We want the specific paragraph, not a parent container
let container2 : Node | null = contentRange . commonAncestorContainer
// Walk up the DOM tree to find a paragraph element
while ( container2 && container2 . nodeType !== Node . ELEMENT_NODE ) {
container2 = container2 . parentNode
}
let paragraphElement : Element | null = null
if ( container2 ) {
let current : Element | null = container2 as Element
// First pass: look specifically for a <p> tag or header
while ( current ) {
// Skip UI elements
if ( isUIElement ( current ) ) {
current = current . parentElement
continue
}
const tagName = current . tagName ? . toLowerCase ( )
// Prioritize finding actual paragraph tags or headers
if ( tagName === 'p' || ( tagName ? . startsWith ( 'h' ) && /^h[1-6]$/ . test ( tagName ) ) ) {
// Found a paragraph or header tag - this is what we want
if ( current . contains ( contentRange . startContainer ) && current . contains ( contentRange . endContainer ) ) {
paragraphElement = current
break
}
}
current = current . parentElement
}
// If we didn't find a <p> or header tag, try to find the closest text-containing element
// but only as a last resort, and make sure it's not a large container
if ( ! paragraphElement && container2 ) {
current = container2 as Element
while ( current ) {
if ( isUIElement ( current ) ) {
current = current . parentElement
continue
}
const tagName = current . tagName ? . toLowerCase ( )
// Only use div/article/section if it's small and doesn't have many paragraph children
if ( ( tagName === 'div' || tagName === 'article' || tagName === 'section' ) &&
current . contains ( contentRange . startContainer ) && current . contains ( contentRange . endContainer ) ) {
// Make sure it's within the article container
if ( ! articleContainer || articleContainer . contains ( current ) ) {
// Count how many paragraph children it has
const paragraphChildren = Array . from ( current . children ) . filter (
child = > {
const childTag = child . tagName ? . toLowerCase ( )
return ( childTag === 'p' || childTag ? . startsWith ( 'h' ) ) && ! isUIElement ( child )
}
)
// Only use this as paragraph element if it has very few paragraph children (1-2)
// This prevents using large containers that hold the entire article
if ( paragraphChildren . length <= 2 ) {
paragraphElement = current
break
}
}
}
current = current . parentElement
}
}
}
// If we found a paragraph element, get its text content and the paragraph above/below it
// But filter out any UI elements from the paragraph context
if ( paragraphElement ) {
const tagName = paragraphElement . tagName ? . toLowerCase ( )
const isHeader = tagName ? . startsWith ( 'h' ) && /^h[1-6]$/ . test ( tagName )
// Get text content of current element (paragraph or header), but exclude UI elements
const walker = document . createTreeWalker (
paragraphElement ,
NodeFilter . SHOW_TEXT ,
{
acceptNode : ( node ) = > {
// Check if the text node's parent is a UI element
let parent = node . parentElement
while ( parent && parent !== paragraphElement ) {
if ( isUIElement ( parent ) ) {
return NodeFilter . FILTER_REJECT
}
parent = parent . parentElement
}
return NodeFilter . FILTER_ACCEPT
}
}
)
const textNodes : string [ ] = [ ]
let node = walker . nextNode ( )
while ( node ) {
if ( node . textContent ) {
textNodes . push ( node . textContent )
}
node = walker . nextNode ( )
}
const currentElementText = textNodes . join ( '' ) . trim ( )
// For headers, get the following paragraph. For paragraphs, get the one above.
let contextParagraphText = ''
if ( articleContainer ) {
// Get all content elements (p, h1-h6) within the article container, in DOM order
const allElements = Array . from ( articleContainer . querySelectorAll ( 'p, h1, h2, h3, h4, h5, h6' ) )
. filter ( el = > {
// Filter out UI elements
if ( isUIElement ( el ) ) return false
// Only include elements that are within the article container
return articleContainer . contains ( el )
} )
// Find the index of the current element
const currentIndex = allElements . indexOf ( paragraphElement )
if ( isHeader ) {
// For headers: get the next paragraph after the header
if ( currentIndex >= 0 && currentIndex < allElements . length - 1 ) {
// Look for the next paragraph (not header) after this header
for ( let i = currentIndex + 1 ; i < allElements . length ; i ++ ) {
const nextElement = allElements [ i ]
const nextTagName = nextElement . tagName ? . toLowerCase ( )
if ( nextTagName === 'p' && ! isUIElement ( nextElement ) ) {
// Found the next paragraph
const nextWalker = document . createTreeWalker (
nextElement ,
NodeFilter . SHOW_TEXT ,
{
acceptNode : ( node ) = > {
let parent = node . parentElement
while ( parent && parent !== nextElement ) {
if ( isUIElement ( parent ) ) {
return NodeFilter . FILTER_REJECT
}
parent = parent . parentElement
}
return NodeFilter . FILTER_ACCEPT
}
}
)
const nextTextNodes : string [ ] = [ ]
let nextNode = nextWalker . nextNode ( )
while ( nextNode ) {
if ( nextNode . textContent ) {
nextTextNodes . push ( nextNode . textContent )
}
nextNode = nextWalker . nextNode ( )
}
contextParagraphText = nextTextNodes . join ( '' ) . trim ( )
break
}
// If we hit another header before a paragraph, stop looking
if ( nextTagName ? . startsWith ( 'h' ) ) {
break
}
}
}
} else {
// For paragraphs: get the previous paragraph or header
if ( currentIndex > 0 ) {
const previousElement = allElements [ currentIndex - 1 ]
if ( previousElement && ! isUIElement ( previousElement ) ) {
// Get text from previous element, excluding UI elements
const prevWalker = document . createTreeWalker (
previousElement ,
NodeFilter . SHOW_TEXT ,
{
acceptNode : ( node ) = > {
let parent = node . parentElement
while ( parent && parent !== previousElement ) {
if ( isUIElement ( parent ) ) {
return NodeFilter . FILTER_REJECT
}
parent = parent . parentElement
}
return NodeFilter . FILTER_ACCEPT
}
}
)
const prevTextNodes : string [ ] = [ ]
let prevNode = prevWalker . nextNode ( )
while ( prevNode ) {
if ( prevNode . textContent ) {
prevTextNodes . push ( prevNode . textContent )
}
prevNode = prevWalker . nextNode ( )
}
contextParagraphText = prevTextNodes . join ( '' ) . trim ( )
}
}
}
} else {
// Fallback: if no article container, use sibling elements
if ( isHeader ) {
// For headers: find next sibling paragraph
let nextSibling = paragraphElement . nextElementSibling
while ( nextSibling ) {
if ( isUIElement ( nextSibling ) ) {
nextSibling = nextSibling . nextElementSibling
continue
}
const nextTagName = nextSibling . tagName ? . toLowerCase ( )
if ( nextTagName === 'p' ) {
const nextText = nextSibling . textContent ? . trim ( ) || ''
if ( nextText ) {
contextParagraphText = nextText
}
break
}
// Stop if we hit another header
if ( nextTagName ? . startsWith ( 'h' ) ) {
break
}
nextSibling = nextSibling . nextElementSibling
}
} else {
// For paragraphs: find previous sibling
let prevSibling = paragraphElement . previousElementSibling
while ( prevSibling ) {
if ( isUIElement ( prevSibling ) ) {
prevSibling = prevSibling . previousElementSibling
continue
}
const prevTagName = prevSibling . tagName ? . toLowerCase ( )
if ( prevTagName === 'p' || prevTagName ? . startsWith ( 'h' ) ) {
const prevText = prevSibling . textContent ? . trim ( ) || ''
if ( prevText ) {
contextParagraphText = prevText
}
break
}
prevSibling = prevSibling . previousElementSibling
}
}
}
// Combine context paragraph and current element
if ( contextParagraphText ) {
if ( isHeader ) {
// Header followed by paragraph
paragraphContext = ` ${ currentElementText } \ n \ n ${ contextParagraphText } `
} else {
// Previous paragraph/header followed by current paragraph
paragraphContext = ` ${ contextParagraphText } \ n \ n ${ currentElementText } `
}
} else {
// Just the current element
paragraphContext = currentElementText
}
} else {
// Fallback: if we couldn't find a paragraph element, just use the selected text
// Don't try to expand too much - just use what was selected
paragraphContext = selectedText
}
}
// Final validation: ensure we have valid selected text
if ( ! selectedText || selectedText . length === 0 ) {
toast . error ( t ( 'Please select some text from the article to highlight' ) )
return
}
// For addressable events (publications, long-form articles with d-tag), use naddr
// For regular events, use nevent
let sourceValue : string
let sourceHexId : string | undefined
if ( kinds . isAddressableKind ( event . kind ) || kinds . isReplaceableKind ( event . kind ) ) {
// Generate naddr for addressable/replaceable events
const dTag = event . tags . find ( tag = > tag [ 0 ] === 'd' ) ? . [ 1 ] || ''
if ( dTag ) {
const relays = event . tags
. filter ( tag = > tag [ 0 ] === 'relay' )
. map ( tag = > tag [ 1 ] )
. filter ( Boolean )
try {
sourceValue = nip19 . naddrEncode ( {
kind : event.kind ,
pubkey : event.pubkey ,
identifier : dTag ,
relays : relays.length > 0 ? relays : undefined
} )
sourceHexId = undefined // naddr doesn't have a single hex ID
} catch ( error ) {
logger . error ( 'Error generating naddr for highlight' , { error } )
// Fallback to nevent
sourceValue = getNoteBech32Id ( event )
sourceHexId = event . id
}
} else {
// No d-tag, use nevent
sourceValue = getNoteBech32Id ( event )
sourceHexId = event . id
}
} else {
// Regular event, use nevent
sourceValue = getNoteBech32Id ( event )
sourceHexId = event . id
}
const highlightData : import ( '../PostEditor/HighlightEditor' ) . HighlightData = {
sourceType : 'nostr' ,
sourceValue ,
sourceHexId ,
context : paragraphContext || undefined
}
// Use selected text as content if available, otherwise use event content
const content = selectedText || event . content
openHighlightEditor ( highlightData , content )
} catch ( error ) {
logger . error ( 'Error creating highlight from event' , { error , eventId : event.id } )
toast . error ( t ( 'Failed to create highlight' ) )
}
} ,
separator : true
} )
}
actions . push ( {
icon : Code ,
label : t ( 'View raw event' ) ,
@ -1277,7 +801,6 @@ export function useMenuActions({
@@ -1277,7 +801,6 @@ export function useMenuActions({
pubkey ,
isMuted ,
isSmallScreen ,
openHighlightEditor ,
broadcastSubMenu ,
closeDrawer ,
showSubMenuActions ,