You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
830 lines
29 KiB
830 lines
29 KiB
import { ExtendedKind } from '@/constants' |
|
import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' |
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
import { toNjump } from '@/lib/link' |
|
import logger from '@/lib/logger' |
|
import { pubkeyToNpub } from '@/lib/pubkey' |
|
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
|
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
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 { Event, kinds } from 'nostr-tools' |
|
import { nip19 } from 'nostr-tools' |
|
import { useMemo, useState, useEffect } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { toast } from 'sonner' |
|
import RelayIcon from '../RelayIcon' |
|
|
|
export interface SubMenuAction { |
|
label: React.ReactNode |
|
onClick: () => void |
|
className?: string |
|
separator?: boolean |
|
} |
|
|
|
export interface MenuAction { |
|
icon: React.ComponentType |
|
label: string |
|
onClick?: () => void |
|
className?: string |
|
separator?: boolean |
|
subMenu?: SubMenuAction[] |
|
} |
|
|
|
interface UseMenuActionsProps { |
|
event: Event |
|
closeDrawer: () => void |
|
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void |
|
setIsRawEventDialogOpen: (open: boolean) => void |
|
setIsReportDialogOpen: (open: boolean) => void |
|
isSmallScreen: boolean |
|
openHighlightEditor?: (highlightData: import('../PostEditor/HighlightEditor').HighlightData, eventContent?: string) => void |
|
} |
|
|
|
export function useMenuActions({ |
|
event, |
|
closeDrawer, |
|
showSubMenuActions, |
|
setIsRawEventDialogOpen, |
|
setIsReportDialogOpen, |
|
isSmallScreen, |
|
openHighlightEditor |
|
}: UseMenuActionsProps) { |
|
const { t } = useTranslation() |
|
const { pubkey, attemptDelete, publish } = useNostr() |
|
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() |
|
const { relaySets, favoriteRelays } = useFavoriteRelays() |
|
const relayUrls = useMemo(() => { |
|
return Array.from(new Set([ |
|
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), |
|
...favoriteRelays.map(url => normalizeUrl(url) || url) |
|
])) |
|
}, [currentBrowsingRelayUrls, favoriteRelays]) |
|
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() |
|
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event]) |
|
|
|
// Check if event is pinned |
|
const [isPinned, setIsPinned] = useState(false) |
|
|
|
useEffect(() => { |
|
const checkIfPinned = async () => { |
|
if (!pubkey) { |
|
setIsPinned(false) |
|
return |
|
} |
|
try { |
|
// Build comprehensive relay list for pin status check |
|
const allRelays = [ |
|
...(currentBrowsingRelayUrls || []), |
|
...(favoriteRelays || []), |
|
...BIG_RELAY_URLS, |
|
...FAST_READ_RELAY_URLS, |
|
...FAST_WRITE_RELAY_URLS |
|
] |
|
|
|
const normalizedRelays = allRelays |
|
.map(url => normalizeUrl(url)) |
|
.filter((url): url is string => !!url) |
|
|
|
const comprehensiveRelays = Array.from(new Set(normalizedRelays)) |
|
|
|
// Try to fetch pin list event from comprehensive relay list first |
|
let pinListEvent = null |
|
try { |
|
const pinListEvents = await client.fetchEvents(comprehensiveRelays, { |
|
authors: [pubkey], |
|
kinds: [10001], // Pin list kind |
|
limit: 1 |
|
}) |
|
pinListEvent = pinListEvents[0] || null |
|
} catch (error) { |
|
logger.component('PinStatus', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) |
|
pinListEvent = await client.fetchPinListEvent(pubkey) |
|
} |
|
|
|
if (pinListEvent) { |
|
const isEventPinned = pinListEvent.tags.some(tag => tag[0] === 'e' && tag[1] === event.id) |
|
setIsPinned(isEventPinned) |
|
} |
|
} catch (error) { |
|
logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message }) |
|
} |
|
} |
|
checkIfPinned() |
|
}, [pubkey, event.id, currentBrowsingRelayUrls, favoriteRelays]) |
|
|
|
const handlePinNote = async () => { |
|
if (!pubkey) return |
|
|
|
try { |
|
// Build comprehensive relay list for pin list fetching |
|
const allRelays = [ |
|
...(currentBrowsingRelayUrls || []), |
|
...(favoriteRelays || []), |
|
...BIG_RELAY_URLS, |
|
...FAST_READ_RELAY_URLS, |
|
...FAST_WRITE_RELAY_URLS |
|
] |
|
|
|
const normalizedRelays = allRelays |
|
.map(url => normalizeUrl(url)) |
|
.filter((url): url is string => !!url) |
|
|
|
const comprehensiveRelays = Array.from(new Set(normalizedRelays)) |
|
|
|
// Try to fetch pin list event from comprehensive relay list first |
|
let pinListEvent = null |
|
try { |
|
const pinListEvents = await client.fetchEvents(comprehensiveRelays, { |
|
authors: [pubkey], |
|
kinds: [10001], // Pin list kind |
|
limit: 1 |
|
}) |
|
pinListEvent = pinListEvents[0] || null |
|
} catch (error) { |
|
logger.component('PinNote', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) |
|
pinListEvent = await client.fetchPinListEvent(pubkey) |
|
} |
|
|
|
logger.component('PinNote', 'Current pin list event', { hasEvent: !!pinListEvent }) |
|
|
|
// Get existing event IDs, excluding the one we're toggling |
|
const existingEventIds = (pinListEvent?.tags || []) |
|
.filter(tag => tag[0] === 'e' && tag[1]) |
|
.map(tag => tag[1]) |
|
.filter(id => id !== event.id) |
|
|
|
logger.component('PinNote', 'Existing event IDs (excluding current)', { count: existingEventIds.length }) |
|
logger.component('PinNote', 'Current event ID', { eventId: event.id }) |
|
logger.component('PinNote', 'Is currently pinned', { isPinned }) |
|
|
|
let newTags: string[][] |
|
let successMessage: string |
|
|
|
if (isPinned) { |
|
// Unpin: just keep the existing tags without this event |
|
newTags = existingEventIds.map(id => ['e', id]) |
|
successMessage = t('Note unpinned') |
|
logger.component('PinNote', 'Unpinning - new tags', { count: newTags.length }) |
|
} else { |
|
// Pin: add this event to the existing list |
|
newTags = [...existingEventIds.map(id => ['e', id]), ['e', event.id]] |
|
successMessage = t('Note pinned') |
|
logger.component('PinNote', 'Pinning - new tags', { count: newTags.length }) |
|
} |
|
|
|
// Create and publish the new pin list event |
|
logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: comprehensiveRelays.length }) |
|
await publish({ |
|
kind: 10001, |
|
tags: newTags, |
|
content: '', |
|
created_at: Math.floor(Date.now() / 1000) |
|
}, { |
|
specifiedRelayUrls: comprehensiveRelays |
|
}) |
|
|
|
// Update local state - the publish will update the cache automatically |
|
setIsPinned(!isPinned) |
|
toast.success(successMessage) |
|
closeDrawer() |
|
} catch (error) { |
|
logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message }) |
|
toast.error(t('Failed to pin note')) |
|
} |
|
} |
|
|
|
// Check if this is a reply to a discussion event |
|
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false) |
|
|
|
useEffect(() => { |
|
const isDiscussion = event.kind === ExtendedKind.DISCUSSION |
|
if (isDiscussion) return // Already a discussion event |
|
|
|
const rootEventId = getRootEventHexId(event) |
|
if (rootEventId) { |
|
// Fetch the root event to check if it's a discussion |
|
client.fetchEvent(rootEventId).then(rootEvent => { |
|
if (rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION) { |
|
setIsReplyToDiscussion(true) |
|
} |
|
}).catch(() => { |
|
// If we can't fetch the root event, assume it's not a discussion reply |
|
setIsReplyToDiscussion(false) |
|
}) |
|
} |
|
}, [event.id, event.kind]) |
|
|
|
const broadcastSubMenu: SubMenuAction[] = useMemo(() => { |
|
const items = [] |
|
if (pubkey && event.pubkey === pubkey) { |
|
items.push({ |
|
label: <div className="text-left"> {t('Write relays')}</div>, |
|
onClick: async () => { |
|
closeDrawer() |
|
const promise = async () => { |
|
const relays = await client.determineTargetRelays(event) |
|
if (relays?.length) { |
|
await client.publishEvent(relays, event) |
|
} |
|
} |
|
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 |
|
}) |
|
} |
|
}) |
|
} |
|
}) |
|
} |
|
|
|
if (relaySets.length) { |
|
items.push( |
|
...relaySets |
|
.filter((set) => set.relayUrls.length) |
|
.map((set, index) => ({ |
|
label: <div className="text-left truncate">{set.name}</div>, |
|
onClick: async () => { |
|
closeDrawer() |
|
const promise = client.publishEvent(set.relayUrls, event) |
|
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 |
|
}) |
|
} |
|
}) |
|
}, |
|
separator: index === 0 |
|
})) |
|
) |
|
} |
|
|
|
if (relayUrls.length) { |
|
items.push( |
|
...relayUrls.map((relay, index) => ({ |
|
label: ( |
|
<div className="flex items-center gap-2 w-full"> |
|
<RelayIcon url={relay} /> |
|
<div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div> |
|
</div> |
|
), |
|
onClick: async () => { |
|
closeDrawer() |
|
const promise = client.publishEvent([relay], event) |
|
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 |
|
}) |
|
} |
|
}) |
|
}, |
|
separator: index === 0 |
|
})) |
|
) |
|
} |
|
|
|
return items |
|
}, [pubkey, relayUrls, relaySets]) |
|
|
|
// Check if this is an article-type event |
|
const isArticleType = useMemo(() => { |
|
return event.kind === kinds.LongFormArticle || |
|
event.kind === ExtendedKind.WIKI_ARTICLE || |
|
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || |
|
event.kind === ExtendedKind.PUBLICATION || |
|
event.kind === ExtendedKind.PUBLICATION_CONTENT |
|
}, [event.kind]) |
|
|
|
// Get article metadata for export |
|
const articleMetadata = useMemo(() => { |
|
if (!isArticleType) return null |
|
return getLongFormArticleMetadataFromEvent(event) |
|
}, [isArticleType, event]) |
|
|
|
// Extract d-tag for Wikistr URL |
|
const dTag = useMemo(() => { |
|
if (!isArticleType) return '' |
|
return event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
|
}, [isArticleType, event]) |
|
|
|
// Generate naddr for Alexandria URL |
|
const naddr = useMemo(() => { |
|
if (!isArticleType || !dTag) return '' |
|
try { |
|
const relays = event.tags |
|
.filter(tag => tag[0] === 'relay') |
|
.map(tag => tag[1]) |
|
.filter(Boolean) |
|
|
|
return nip19.naddrEncode({ |
|
kind: event.kind, |
|
pubkey: event.pubkey, |
|
identifier: dTag, |
|
relays: relays.length > 0 ? relays : undefined |
|
}) |
|
} catch (error) { |
|
logger.error('Error generating naddr', { error }) |
|
return '' |
|
} |
|
}, [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 = () => { |
|
if (!isArticleType) return |
|
|
|
try { |
|
const title = articleMetadata?.title || 'Article' |
|
const content = event.content |
|
const filename = `${title}.md` |
|
|
|
const blob = new Blob([content], { type: 'text/markdown' }) |
|
const url = URL.createObjectURL(blob) |
|
const a = document.createElement('a') |
|
a.href = url |
|
a.download = filename |
|
document.body.appendChild(a) |
|
a.click() |
|
document.body.removeChild(a) |
|
URL.revokeObjectURL(url) |
|
|
|
logger.info('[NoteOptions] Exported article as Markdown') |
|
toast.success(t('Article exported as Markdown')) |
|
} catch (error) { |
|
logger.error('[NoteOptions] Error exporting article:', error) |
|
toast.error(t('Failed to export article')) |
|
} |
|
} |
|
|
|
const exportAsAsciidoc = () => { |
|
if (!isArticleType) return |
|
|
|
try { |
|
const title = articleMetadata?.title || 'Article' |
|
const content = event.content |
|
const filename = `${title}.adoc` |
|
|
|
const blob = new Blob([content], { type: 'text/plain' }) |
|
const url = URL.createObjectURL(blob) |
|
const a = document.createElement('a') |
|
a.href = url |
|
a.download = filename |
|
document.body.appendChild(a) |
|
a.click() |
|
document.body.removeChild(a) |
|
URL.revokeObjectURL(url) |
|
|
|
logger.info('[NoteOptions] Exported article as AsciiDoc') |
|
toast.success(t('Article exported as AsciiDoc')) |
|
} catch (error) { |
|
logger.error('[NoteOptions] Error exporting article:', error) |
|
toast.error(t('Failed to export article')) |
|
} |
|
} |
|
|
|
// View on external sites functions |
|
const handleViewOnWikistr = () => { |
|
if (!dTag) return |
|
closeDrawer() |
|
window.open(`https://wikistr.imwald.eu/${dTag}*${event.pubkey}`, '_blank', 'noopener,noreferrer') |
|
} |
|
|
|
const handleViewOnAlexandria = () => { |
|
if (!naddr) return |
|
closeDrawer() |
|
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer') |
|
} |
|
|
|
const handleViewOnDecentNewsroom = () => { |
|
if (!dTag) return |
|
closeDrawer() |
|
window.open(`https://decentnewsroom.com/article/d/${dTag}`, '_blank', 'noopener,noreferrer') |
|
} |
|
const actions: MenuAction[] = [ |
|
{ |
|
icon: Copy, |
|
label: t('Copy event ID'), |
|
onClick: () => { |
|
navigator.clipboard.writeText(getNoteBech32Id(event)) |
|
closeDrawer() |
|
} |
|
}, |
|
{ |
|
icon: Copy, |
|
label: t('Copy user ID'), |
|
onClick: () => { |
|
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') |
|
closeDrawer() |
|
} |
|
}, |
|
{ |
|
icon: Link, |
|
label: t('Copy share link'), |
|
onClick: () => { |
|
navigator.clipboard.writeText(toNjump(getNoteBech32Id(event))) |
|
closeDrawer() |
|
} |
|
} |
|
] |
|
|
|
// 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) { |
|
// Get the selected text |
|
selectedText = selection.toString().trim() |
|
|
|
// Find the paragraph element containing the selection |
|
const range = selection.getRangeAt(0) |
|
let container = range.commonAncestorContainer |
|
|
|
// Walk up the DOM tree to find a paragraph element |
|
while (container && container.nodeType !== Node.ELEMENT_NODE) { |
|
container = container.parentNode |
|
} |
|
|
|
let paragraphElement: Element | null = null |
|
if (container) { |
|
let current: Element | null = container as Element |
|
while (current) { |
|
// Check if it's a paragraph or a div that might contain paragraph content |
|
const tagName = current.tagName?.toLowerCase() |
|
// Look for paragraph tags, or divs/articles that contain the selection |
|
// Also check for common markdown/article container classes |
|
if (tagName === 'p') { |
|
// Found a paragraph tag - this is ideal |
|
if (current.contains(range.startContainer) && current.contains(range.endContainer)) { |
|
paragraphElement = current |
|
break |
|
} |
|
} else if (tagName === 'div' || tagName === 'article' || tagName === 'section') { |
|
// Check if this div/article/section contains the selection |
|
// and doesn't have nested paragraph-like structures |
|
if (current.contains(range.startContainer) && current.contains(range.endContainer)) { |
|
// Check if this element has direct paragraph children |
|
const hasParagraphChildren = Array.from(current.children).some( |
|
child => child.tagName?.toLowerCase() === 'p' |
|
) |
|
// If it doesn't have paragraph children, it might be a paragraph container itself |
|
if (!hasParagraphChildren || !paragraphElement) { |
|
paragraphElement = current |
|
// Don't break here - continue looking for a p tag |
|
} |
|
} |
|
} |
|
current = current.parentElement |
|
} |
|
} |
|
|
|
// If we found a paragraph element, get its text content |
|
if (paragraphElement) { |
|
paragraphContext = paragraphElement.textContent?.trim() || '' |
|
} else { |
|
// Fallback: try to get text from a larger context around the selection |
|
// Clone the range and expand it to include surrounding text |
|
const expandedRange = range.cloneRange() |
|
const startContainer = range.startContainer |
|
const endContainer = range.endContainer |
|
|
|
// Try to expand backwards to find sentence/paragraph boundaries |
|
if (startContainer.nodeType === Node.TEXT_NODE && startContainer.textContent) { |
|
const textBefore = startContainer.textContent.substring(0, range.startOffset) |
|
// Look for paragraph breaks (double newlines) or sentence endings |
|
const lastParagraphBreak = textBefore.lastIndexOf('\n\n') |
|
const lastSentenceEnd = Math.max( |
|
textBefore.lastIndexOf('. '), |
|
textBefore.lastIndexOf('.\n'), |
|
textBefore.lastIndexOf('! '), |
|
textBefore.lastIndexOf('?\n') |
|
) |
|
if (lastParagraphBreak > 0) { |
|
expandedRange.setStart(startContainer, lastParagraphBreak + 2) |
|
} else if (lastSentenceEnd > 0) { |
|
expandedRange.setStart(startContainer, lastSentenceEnd + 2) |
|
} else { |
|
expandedRange.setStart(startContainer, 0) |
|
} |
|
} |
|
|
|
// Try to expand forwards |
|
if (endContainer.nodeType === Node.TEXT_NODE && endContainer.textContent) { |
|
const textAfter = endContainer.textContent.substring(range.endOffset) |
|
const nextParagraphBreak = textAfter.indexOf('\n\n') |
|
const nextSentenceEnd = Math.min( |
|
textAfter.indexOf('. ') !== -1 ? textAfter.indexOf('. ') + 2 : Infinity, |
|
textAfter.indexOf('.\n') !== -1 ? textAfter.indexOf('.\n') + 2 : Infinity, |
|
textAfter.indexOf('! ') !== -1 ? textAfter.indexOf('! ') + 2 : Infinity, |
|
textAfter.indexOf('?\n') !== -1 ? textAfter.indexOf('?\n') + 2 : Infinity |
|
) |
|
if (nextParagraphBreak !== -1 && nextParagraphBreak < nextSentenceEnd) { |
|
expandedRange.setEnd(endContainer, range.endOffset + nextParagraphBreak) |
|
} else if (nextSentenceEnd < Infinity) { |
|
expandedRange.setEnd(endContainer, range.endOffset + nextSentenceEnd) |
|
} else { |
|
expandedRange.setEnd(endContainer, endContainer.textContent.length) |
|
} |
|
} |
|
|
|
paragraphContext = expandedRange.toString().trim() |
|
} |
|
} |
|
|
|
// 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'), |
|
onClick: () => { |
|
closeDrawer() |
|
setIsRawEventDialogOpen(true) |
|
}, |
|
separator: true |
|
}) |
|
|
|
// Add export options for article-type events |
|
if (isArticleType) { |
|
const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN |
|
const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT |
|
|
|
if (isMarkdownFormat) { |
|
actions.push({ |
|
icon: FileDown, |
|
label: t('Export as Markdown'), |
|
onClick: () => { |
|
closeDrawer() |
|
exportAsMarkdown() |
|
}, |
|
separator: true |
|
}) |
|
} |
|
|
|
if (isAsciidocFormat) { |
|
actions.push({ |
|
icon: FileDown, |
|
label: t('Export as AsciiDoc'), |
|
onClick: () => { |
|
closeDrawer() |
|
exportAsAsciidoc() |
|
}, |
|
separator: true |
|
}) |
|
} |
|
|
|
// Add view options based on event kind |
|
if (event.kind === kinds.LongFormArticle) { |
|
// For LongFormArticle (30023): Alexandria and DecentNewsroom |
|
if (naddr) { |
|
actions.push({ |
|
icon: BookOpen, |
|
label: t('View on Alexandria'), |
|
onClick: handleViewOnAlexandria |
|
}) |
|
} |
|
if (dTag) { |
|
actions.push({ |
|
icon: Globe, |
|
label: t('View on DecentNewsroom'), |
|
onClick: handleViewOnDecentNewsroom |
|
}) |
|
} |
|
} else if ( |
|
event.kind === ExtendedKind.PUBLICATION_CONTENT || |
|
event.kind === ExtendedKind.PUBLICATION || |
|
event.kind === ExtendedKind.WIKI_ARTICLE || |
|
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN |
|
) { |
|
// For 30041, 30040, 30818, 30817: Alexandria and Wikistr |
|
if (naddr) { |
|
actions.push({ |
|
icon: BookOpen, |
|
label: t('View on Alexandria'), |
|
onClick: handleViewOnAlexandria |
|
}) |
|
} |
|
if (dTag) { |
|
actions.push({ |
|
icon: Globe, |
|
label: t('View on Wikistr'), |
|
onClick: handleViewOnWikistr |
|
}) |
|
} |
|
} |
|
} |
|
|
|
const isProtected = isProtectedEvent(event) |
|
const isDiscussion = event.kind === ExtendedKind.DISCUSSION |
|
if ((!isProtected || event.pubkey === pubkey) && !isDiscussion && !isReplyToDiscussion) { |
|
actions.push({ |
|
icon: SatelliteDish, |
|
label: t('Republish to ...'), |
|
onClick: isSmallScreen |
|
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) |
|
: undefined, |
|
subMenu: isSmallScreen ? undefined : broadcastSubMenu, |
|
separator: true |
|
}) |
|
} |
|
|
|
if (pubkey && event.pubkey !== pubkey) { |
|
actions.push({ |
|
icon: TriangleAlert, |
|
label: t('Report'), |
|
className: 'text-destructive focus:text-destructive', |
|
onClick: () => { |
|
closeDrawer() |
|
setIsReportDialogOpen(true) |
|
}, |
|
separator: true |
|
}) |
|
} |
|
|
|
if (pubkey && event.pubkey !== pubkey) { |
|
if (isMuted) { |
|
actions.push({ |
|
icon: Bell, |
|
label: t('Unmute user'), |
|
onClick: () => { |
|
closeDrawer() |
|
unmutePubkey(event.pubkey) |
|
}, |
|
className: 'text-destructive focus:text-destructive', |
|
separator: true |
|
}) |
|
} else { |
|
actions.push( |
|
{ |
|
icon: BellOff, |
|
label: t('Mute user privately'), |
|
onClick: () => { |
|
closeDrawer() |
|
mutePubkeyPrivately(event.pubkey) |
|
}, |
|
className: 'text-destructive focus:text-destructive', |
|
separator: true |
|
}, |
|
{ |
|
icon: BellOff, |
|
label: t('Mute user publicly'), |
|
onClick: () => { |
|
closeDrawer() |
|
mutePubkeyPublicly(event.pubkey) |
|
}, |
|
className: 'text-destructive focus:text-destructive' |
|
} |
|
) |
|
} |
|
} |
|
|
|
// Pin functionality available for any note (not just own notes) |
|
if (pubkey) { |
|
actions.push({ |
|
icon: Pin, |
|
label: isPinned ? t('Unpin note') : t('Pin note'), |
|
onClick: () => { |
|
handlePinNote() |
|
}, |
|
separator: true |
|
}) |
|
} |
|
|
|
// Delete functionality only available for own notes |
|
if (pubkey && event.pubkey === pubkey) { |
|
actions.push({ |
|
icon: Trash2, |
|
label: t('Try deleting this note'), |
|
onClick: () => { |
|
closeDrawer() |
|
attemptDelete(event) |
|
}, |
|
className: 'text-destructive focus:text-destructive' |
|
}) |
|
} |
|
|
|
return actions |
|
}, [ |
|
t, |
|
event, |
|
pubkey, |
|
isMuted, |
|
isSmallScreen, |
|
openHighlightEditor, |
|
broadcastSubMenu, |
|
closeDrawer, |
|
showSubMenuActions, |
|
setIsRawEventDialogOpen, |
|
setIsReportDialogOpen, |
|
mutePubkeyPrivately, |
|
mutePubkeyPublicly, |
|
unmutePubkey, |
|
attemptDelete, |
|
isPinned, |
|
handlePinNote, |
|
isArticleType, |
|
articleMetadata, |
|
dTag, |
|
naddr |
|
]) |
|
|
|
return menuActions |
|
}
|
|
|