import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants' import ClientTag from '@/components/ClientTag' import Nip05 from '@/components/Nip05' import { getNoteBech32Id, getReplaceableCoordinateFromEvent, getUsingClient, isProtectedEvent, isReplaceableEvent, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' import { toAlexandria, encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr } from '@/lib/link' import logger from '@/lib/logger' import { pubkeyToNpub } from '@/lib/pubkey' import { batchFetchPublicationSectionEvents, buildPublicationSectionRelayUrls, parsePublicationATagCoordinate, type PublicationSectionRef } from '@/lib/publication-section-fetch' import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url' import { clearNoteTranslation, getNoteTranslation, subscribeNoteTranslations } from '@/lib/note-translation-display' import { speakNoteReadAloud } from '@/lib/read-aloud' import { dispatchPinListUpdated, PIN_LIST_UPDATED_EVENT, type PinListUpdatedDetail } from '@/lib/pin-list-events' import { buildPinListTagsAfterToggle, fetchNewestPinListForPubkey, isEventInPinList } from '@/lib/replaceable-list-latest' import indexedDb from '@/services/indexed-db.service' import { generateBech32IdFromATag } from '@/lib/tag' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' import { useNostr } from '@/providers/NostrProvider' import { useBookmarksOptional } from '@/providers/bookmarks-context' import { useThreadNotificationMenuState } from '@/hooks/useThreadNotificationMenuState' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import client from '@/services/client.service' import { eventService } from '@/services/client.service' import { nip66Service } from '@/services/nip66.service' import { Bell, BellOff, Bookmark, Pin, Settings, Share2, Trash2, Video, Volume2, Languages } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { articleHasTranslatableTitle, eventHasTranslatableTextBody, translateNoteAndRelatedForDisplay } from '@/lib/translate-note-for-menu' import { isTranslateConfigured, warmTranslateLanguagesOnce, type TranslateLanguageOption } from '@/lib/translate-client' import { buildResolvedTranslateMenuLanguageOptions, languageSelectSingleLine, TRANSLATE_LANGUAGE_MENU_ITEM_CLASS } from '@/lib/language-display-meta' const EMPTY_TRANSLATE_MENU: readonly TranslateLanguageOption[] = [] import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines' import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import RelayIcon from '../RelayIcon' import { useSecondaryPage } from '@/PageManager' import { PrimaryPageContext } from '@/contexts/primary-page-context' import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback' import type { TEditOrCloneMode } from './EditOrCloneEventDialog' export interface SubMenuAction { label: React.ReactNode onClick: () => void className?: string separator?: boolean /** Lowercase haystack for submenu filter when the parent sets {@link MenuAction.subMenuSearchable}. */ filterHaystack?: string /** Nested submenu (desktop dropdown only). */ subMenu?: SubMenuAction[] } export interface MenuAction { icon: React.ComponentType label: string onClick?: () => void className?: string separator?: boolean subMenu?: SubMenuAction[] /** Renders a filter field above submenu rows (e.g. translate targets). */ subMenuSearchable?: boolean } export type ShowSubMenuOptions = { subMenuSearchable?: boolean } interface UseMenuActionsProps { event: Event closeDrawer: () => void showSubMenuActions: (subMenu: SubMenuAction[], title: string, options?: ShowSubMenuOptions) => void setIsRawEventDialogOpen: (open: boolean) => void setIsReportDialogOpen: (open: boolean) => void isSmallScreen: boolean /** When provided, adds "Send public message" to open composer with this pubkey in the mention list. */ onOpenPublicMessage?: (pubkey: string) => void /** When provided, adds "Send call invite" to open composer with the call URL as content. */ onOpenCallInvite?: (url: string) => void /** Opens edit/clone dialog (signed-in accounts only, not read-only npub). */ onOpenEditOrClone?: (mode: TEditOrCloneMode) => void /** When the feed already marks this note pinned (e.g. profile pin section). */ pinned?: boolean /** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */ onViewAttestation?: () => void } export function useMenuActions({ event, closeDrawer, showSubMenuActions, setIsRawEventDialogOpen, setIsReportDialogOpen, isSmallScreen, onOpenPublicMessage, onOpenCallInvite, onOpenEditOrClone, pinned: _pinnedInFeed = false, onViewAttestation }: UseMenuActionsProps) { const { t } = useTranslation() const { push } = useSecondaryPage() // Use useContext directly to avoid error if provider is not available const primaryPageContext = useContext(PrimaryPageContext) const currentPrimaryPage = primaryPageContext?.current ?? null const { pubkey, profile, attemptDelete, publish, account, relayList, bookmarkListEvent, checkLogin } = useNostr() const bookmarksContext = useBookmarksOptional() const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event) const { addBookmark, removeBookmark } = bookmarksContext ?? { addBookmark: async () => {}, removeBookmark: async () => false } const [bookmarkUpdating, setBookmarkUpdating] = useState(false) const canSignEvents = account != null && account.signerType !== 'npub' const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() const httpWriteRelayUrls = useMemo(() => { return (relayList?.httpWrite ?? []) .map(url => normalizeHttpRelayUrl(url) || url) .filter(Boolean) as string[] }, [relayList?.httpWrite]) const relayUrls = useMemo(() => { return Array.from(new Set([ ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url), ...favoriteRelays.map(url => normalizeAnyRelayUrl(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 => normalizeAnyRelayUrl(url) || url), ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url), ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)), ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url), ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(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(null) useEffect(() => { nip66Service.getPublicLivelyRelayUrls().then((urls) => { setMonitoringListRelayCount(urls?.length ?? 0) }) }, []) const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event]) const isBookmarked = useMemo(() => { if (!bookmarkListEvent) return false const isReplaceable = isReplaceableEvent(event.kind) const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id return bookmarkListEvent.tags.some((tag) => isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey ) }, [bookmarkListEvent, event]) const noteTranslationFromMenu = useSyncExternalStore( subscribeNoteTranslations, () => getNoteTranslation(event.id), () => getNoteTranslation(event.id) ) const [translateMenuOptions, setTranslateMenuOptions] = useState(EMPTY_TRANSLATE_MENU) useEffect(() => { if (!isTranslateConfigured()) { setTranslateMenuOptions(EMPTY_TRANSLATE_MENU) return } let cancelled = false void warmTranslateLanguagesOnce() .then((list) => { if (cancelled) return setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions(list)) }) .catch(() => { if (!cancelled) setTranslateMenuOptions(buildResolvedTranslateMenuLanguageOptions([])) }) return () => { cancelled = true } }, []) // Whether this note is on the signed-in user's kind 10001 pin list (not profile-owner pins). const [isPinnedInMyList, setIsPinnedInMyList] = useState(false) // Keep refs so the effect can read the latest relay lists without making them // part of the dependency array. Including live array references as deps causes // an infinite loop: relay fetch → NoteList re-render → new array refs → // effect re-fires for every visible note → relay fetch → … const currentBrowsingRelayUrlsRef = useRef(currentBrowsingRelayUrls) currentBrowsingRelayUrlsRef.current = currentBrowsingRelayUrls const favoriteRelaysRef = useRef(favoriteRelays) favoriteRelaysRef.current = favoriteRelays useEffect(() => { const checkIfPinned = async () => { if (!pubkey) { setIsPinnedInMyList(false) return } try { const allRelays = [ ...(currentBrowsingRelayUrlsRef.current || []), ...(favoriteRelaysRef.current || []), ...FAST_READ_RELAY_URLS ] const comprehensiveRelays = Array.from( new Set(allRelays.map(url => normalizeAnyRelayUrl(url)).filter((url): url is string => !!url)) ) const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) const inList = pinListEvent ? isEventInPinList(pinListEvent, event) : false setIsPinnedInMyList(inList) } catch (error) { logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message }) setIsPinnedInMyList(false) } } checkIfPinned() // Only re-run when the user or the specific event changes, not on relay list // reference churn (relay arrays are read via refs above). // eslint-disable-next-line react-hooks/exhaustive-deps }, [pubkey, event.id]) useEffect(() => { if (!pubkey) return const ownerPk = pubkey.trim().toLowerCase() const handler = (raw: globalThis.Event) => { const detail = (raw as CustomEvent).detail if (!detail || detail.ownerPubkey !== ownerPk) return if (detail.toggledEvent?.id === event.id) { setIsPinnedInMyList(detail.pinned ?? false) } } window.addEventListener(PIN_LIST_UPDATED_EVENT, handler) return () => window.removeEventListener(PIN_LIST_UPDATED_EVENT, handler) }, [pubkey, event.id]) const handlePinNote = async () => { if (!pubkey) return try { const pinListReadRelays = Array.from( new Set( [...currentBrowsingRelayUrls, ...favoriteRelays, ...FAST_READ_RELAY_URLS] .map((url) => normalizeAnyRelayUrl(url)) .filter((url): url is string => !!url) ) ) const latestPinList = await fetchNewestPinListForPubkey(pubkey, pinListReadRelays) logger.component('PinNote', 'Current pin list event', { hasEvent: !!latestPinList }) const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event, !isPinnedInMyList) const successMessage = isPinnedInMyList ? t('Note unpinned') : t('Note pinned') logger.component('PinNote', 'Pin list tag count after merge', { count: newTags.length }) const publishRelays = Array.from( new Set([ ...pinListReadRelays, ...FAST_WRITE_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url).filter( (url): url is string => !!url ) ]) ) // Create and publish the new pin list event logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: publishRelays.length }) const publishedEvent = await publish( { kind: 10001, tags: newTags, content: '', created_at: Math.floor(Date.now() / 1000) }, { specifiedRelayUrls: publishRelays } ) // Show publishing feedback with relay messages if ((publishedEvent as any)?.relayStatuses) { showPublishingFeedback({ success: true, relayStatuses: (publishedEvent as any).relayStatuses, successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (publishedEvent as any).relayStatuses.length }, { message: successMessage, duration: 4000 }) } else { toast.success(successMessage) } try { await indexedDb.putReplaceableEvent(publishedEvent as import('nostr-tools').Event) } catch { /* ignore */ } const nowPinned = !isPinnedInMyList dispatchPinListUpdated({ ownerPubkey: pubkey, toggledEvent: event, pinned: nowPinned }) setIsPinnedInMyList(nowPinned) 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 eventService.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: SubMenuAction[] = [] // All available relays (local, favorite, relay sets, default/fast) — success if at least 1 accepts if (allAvailableRelayUrls.length > 0) { items.push({ label:
{t('All available relays')} ({allAvailableRelayUrls.length})
, onClick: async () => { closeDrawer() const promise = client.publishEvent(allAvailableRelayUrls, event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } return result }) toastPublishPromise(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: (
{t('All active relays (monitoring list)')} {activeRelayCount !== null && ` (${activeRelayCount})`}
), 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, { skipOutboxRetry: true }) 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 })() toastPublishPromise(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:
{t('Write relays')}
, separator: items.length > 0, onClick: async () => { closeDrawer() const promise = (async () => { const relays = await client.determineTargetRelays(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 })() toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to your write relays'), error: (err) => 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:
{set.name}
, onClick: async () => { closeDrawer() const promise = client.publishEvent(set.relayUrls, event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } return result }) toastPublishPromise(promise, { loading: t('Republishing...'), 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 })) ) } const wsAndHttpRelayUrls = Array.from(new Set([...relayUrls, ...httpWriteRelayUrls])) if (wsAndHttpRelayUrls.length) { items.push( ...wsAndHttpRelayUrls.map((relay, index) => ({ label: (
{simplifyUrl(relay)}
), onClick: async () => { closeDrawer() const promise = client.publishEvent([relay], event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('Relay did not accept the event')) } return result }) toastPublishPromise(promise, { loading: t('Republishing...'), 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 })) ) } return items }, [pubkey, relayUrls, httpWriteRelayUrls, relaySets, allAvailableRelayUrls, monitoringListRelayCount, event, closeDrawer, t]) // Check if this is an article-type event const isArticleType = useMemo(() => { return event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.NOSTR_SPECIFICATION || 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]) const dTag = useMemo(() => { if (!isArticleType) return '' return event.tags.find(tag => tag[0] === 'd')?.[1] || '' }, [isArticleType, event]) /** Decent Newsroom article URLs are scoped by author: /p/{npub}/d/{identifier} */ const authorNpubForDecentNewsroom = useMemo( () => pubkeyToNpub(event.pubkey) ?? '', [event.pubkey] ) const naddr = useMemo(() => encodeArticleLikePublicationNaddr(event) ?? '', [event]) const menuActions: MenuAction[] = useMemo(() => { const rebroadcastEntirePublication = (selectedRelayUrls: string[]) => { const rootPublication = event const promise = (async () => { if (rootPublication.kind !== ExtendedKind.PUBLICATION) { throw new Error(t('This action is only available for publications')) } if (selectedRelayUrls.length === 0) { throw new Error(t('No relays available')) } const MAX_NESTED_PUBLICATIONS = 128 const MAX_TOTAL_REBROADCAST_EVENTS = 5000 const collectedById = new Map() const visitedPublicationIds = new Set() const queue: Event[] = [rootPublication] let traversedPublications = 0 while (queue.length > 0) { const currentPublication = queue.shift()! if (visitedPublicationIds.has(currentPublication.id)) continue visitedPublicationIds.add(currentPublication.id) traversedPublications++ collectedById.set(currentPublication.id, currentPublication) if (traversedPublications > MAX_NESTED_PUBLICATIONS) { logger.warn('[NoteOptions] Rebroadcast publication traversal capped', { rootId: rootPublication.id, cap: MAX_NESTED_PUBLICATIONS }) break } const refs: PublicationSectionRef[] = [] for (const tag of currentPublication.tags) { if (tag[0] === 'a' && tag[1]) { const parsed = parsePublicationATagCoordinate(tag[1]) if (!parsed) continue refs.push({ type: 'a', coordinate: parsed.coordinate, kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier, relay: tag[2] }) } else if (tag[0] === 'e' && tag[1]) { refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) } } if (refs.length === 0) continue const primaryRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 40, false) const fallbackRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 80, true) const relays = [...new Set([...primaryRelays, ...fallbackRelays, ...selectedRelayUrls])] const resolved = await batchFetchPublicationSectionEvents(refs, relays) for (const ev of resolved.values()) { if (collectedById.size >= MAX_TOTAL_REBROADCAST_EVENTS) break collectedById.set(ev.id, ev) if (ev.kind === ExtendedKind.PUBLICATION && !visitedPublicationIds.has(ev.id)) { queue.push(ev) } } if (collectedById.size >= MAX_TOTAL_REBROADCAST_EVENTS) { logger.warn('[NoteOptions] Rebroadcast event collection capped', { rootId: rootPublication.id, cap: MAX_TOTAL_REBROADCAST_EVENTS }) break } } const uniqueEvents = [...collectedById.values()] if (uniqueEvents.length === 0) { throw new Error(t('No publication events found for rebroadcast')) } const BATCH_SIZE = 6 let acceptedEvents = 0 let failedEvents = 0 let acceptedRelayAcks = 0 for (let i = 0; i < uniqueEvents.length; i += BATCH_SIZE) { const batch = uniqueEvents.slice(i, i + BATCH_SIZE) const batchResults = await Promise.allSettled( batch.map(async (ev) => { const result = await client.publishEvent(selectedRelayUrls, ev, { skipOutboxRetry: true }) if (result.successCount > 0) { acceptedEvents++ acceptedRelayAcks += result.successCount } else { failedEvents++ } }) ) for (const res of batchResults) { if (res.status === 'rejected') failedEvents++ } } if (acceptedEvents < 1) { throw new Error(t('No publication events were accepted by any relay')) } return { acceptedEvents, failedEvents, totalEvents: uniqueEvents.length, acceptedRelayAcks, traversedPublications } })() toastPublishPromise(promise, { loading: t('Rebroadcasting entire publication...'), success: () => t('Rebroadcasted entire publication'), error: (err) => t('Failed to rebroadcast entire publication: {{error}}', { error: err.message }) }) } const publicationBroadcastSubMenu: SubMenuAction[] = [] if (event.kind === ExtendedKind.PUBLICATION) { if (allAvailableRelayUrls.length > 0) { publicationBroadcastSubMenu.push({ label:
{t('All available relays')} ({allAvailableRelayUrls.length})
, onClick: () => { closeDrawer() rebroadcastEntirePublication(allAvailableRelayUrls) } }) } const activeRelayCount = monitoringListRelayCount !== null ? (monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length) : null publicationBroadcastSubMenu.push({ label: (
{t('All active relays (monitoring list)')} {activeRelayCount !== null && ` (${activeRelayCount})`}
), separator: publicationBroadcastSubMenu.length > 0, onClick: () => { 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')) rebroadcastEntirePublication(relays) return usedMonitoringList })() // Trigger async relay resolution immediately; rebroadcast handles its own toasts. void promise.catch((err) => { toast.error(t('Failed to rebroadcast entire publication: {{error}}', { error: err.message })) }) } }) if (pubkey && event.pubkey === pubkey) { publicationBroadcastSubMenu.push({ label:
{t('Write relays')}
, separator: publicationBroadcastSubMenu.length > 0, onClick: async () => { closeDrawer() try { const relays = await client.determineTargetRelays(event) if (!relays?.length) throw new Error(t('No write relays configured')) rebroadcastEntirePublication(relays) } catch (err) { toast.error( t('Failed to rebroadcast entire publication: {{error}}', { error: (err as Error).message }) ) } } }) } if (relaySets.length) { publicationBroadcastSubMenu.push( ...relaySets .filter((set) => set.relayUrls.length) .map((set, index) => ({ label:
{set.name}
, separator: index === 0, onClick: () => { closeDrawer() rebroadcastEntirePublication(set.relayUrls) } })) ) } if (relayUrls.length) { publicationBroadcastSubMenu.push( ...relayUrls.map((relay, index) => ({ label: (
{simplifyUrl(relay)}
), separator: index === 0, onClick: () => { closeDrawer() rebroadcastEntirePublication([relay]) } })) ) } } // 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 = async () => { if (!isArticleType) return try { const title = articleMetadata?.title || 'Article' let content = event.content let filename = `${title}.adoc` // For publications (30040), export all referenced sections if (event.kind === ExtendedKind.PUBLICATION) { const contentParts: string[] = [] // Extract all 'a' tag references const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1]) // Fetch all referenced events const fetchPromises = aTags.map(async (tag) => { try { const coordinate = tag[1] const [kindStr] = coordinate.split(':') const kind = parseInt(kindStr) if (isNaN(kind)) return null // Try to fetch the event const aTag = ['a', coordinate, tag[2] || '', tag[3] || ''] const bech32Id = generateBech32IdFromATag(aTag) if (bech32Id) { const fetchedEvent = await eventService.fetchEvent(bech32Id) return fetchedEvent } return null } catch (error) { logger.warn('[NoteOptions] Error fetching referenced event for export:', error) return null } }) const referencedEvents = (await Promise.all(fetchPromises)).filter((e): e is Event => e !== null) // Combine all events into one AsciiDoc document for (const refEvent of referencedEvents) { const refTitle = refEvent.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled' contentParts.push(`= ${refTitle}\n\n${refEvent.content}\n\n`) } if (contentParts.length > 0) { content = contentParts.join('\n') } } 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 handleViewOnAlexandria = () => { if (!naddr) return closeDrawer() openAlexandriaPublicationFromNaddr(naddr) } const handleViewOnDecentNewsroom = () => { if (!dTag || !authorNpubForDecentNewsroom) return closeDrawer() const p = encodeURIComponent(authorNpubForDecentNewsroom) const d = encodeURIComponent(dTag) window.open(`https://decentnewsroom.com/p/${p}/d/${d}`, '_blank', 'noopener,noreferrer') } const noteSupportsTranslateMenu = isTranslateConfigured() && translateMenuOptions.length > 0 && (eventHasTranslatableTextBody(event) || articleHasTranslatableTitle(event)) const translateTargetSubmenu: SubMenuAction[] = noteSupportsTranslateMenu ? [ { label: t('Show original text'), filterHaystack: `${t('Show original text')} show original`.toLowerCase(), onClick: () => { closeDrawer() clearNoteTranslation(event.id) toast.success(t('Showing original note text')) } }, ...translateMenuOptions.map( (opt, i): SubMenuAction => ({ label: , filterHaystack: `${opt.code} ${languageSelectSingleLine(opt.code)}`.toLowerCase(), className: TRANSLATE_LANGUAGE_MENU_ITEM_CLASS, separator: i === 0, onClick: () => { closeDrawer() void toast.promise( translateNoteAndRelatedForDisplay( event, opt.code, languageSelectSingleLine(opt.code), (id) => client.fetchEvent(id) ), { loading: t('Translating note…'), success: t('Note translated', { language: languageSelectSingleLine(opt.code) }), error: (err: unknown) => t('Note translation failed', { message: err instanceof Error ? err.message : String(err) }) } ) } }) ) ] : [] const pushSubMenuParent = ( target: MenuAction[], icon: MenuAction['icon'], title: string, subMenu: SubMenuAction[], options?: { separator?: boolean; subMenuSearchable?: boolean; className?: string } ) => { if (subMenu.length === 0) return target.push({ icon, label: title, separator: options?.separator, className: options?.className, subMenuSearchable: options?.subMenuSearchable, onClick: isSmallScreen ? () => showSubMenuActions(subMenu, title, { subMenuSearchable: options?.subMenuSearchable }) : undefined, subMenu: isSmallScreen ? undefined : subMenu }) } const connectionsSubMenu: SubMenuAction[] = [ ...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage ? [ { label: t('Send public message'), onClick: () => { closeDrawer() onOpenPublicMessage(event.pubkey) } } ] : []), { label: t('Share with Imwald'), separator: pubkey != null && event.pubkey !== pubkey && !!onOpenPublicMessage, onClick: () => { const noteId = getNoteBech32Id(event) const path = currentPrimaryPage === 'spells' ? `/spells/notes/${noteId}` : currentPrimaryPage === 'rss' ? `/rss/notes/${noteId}` : `/notes/${noteId}` navigator.clipboard.writeText(`https://jumble.imwald.eu${path}`) closeDrawer() } }, { label: t('Share with Alexandria'), onClick: () => { navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event))) closeDrawer() } } ] if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { connectionsSubMenu.push({ label: t('View on Alexandria'), separator: true, onClick: () => { closeDrawer() window.open( 'https://next-alexandria.gitcitadel.eu/profile/notifications', '_blank', 'noopener,noreferrer' ) } }) } if (isArticleType) { if (event.kind === kinds.LongFormArticle) { if (naddr) { connectionsSubMenu.push({ label: t('View on Alexandria'), separator: connectionsSubMenu.length > 0, onClick: handleViewOnAlexandria }) } if (dTag && authorNpubForDecentNewsroom) { connectionsSubMenu.push({ 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.NOSTR_SPECIFICATION ) { if (naddr) { connectionsSubMenu.push({ label: t('View on Alexandria'), separator: connectionsSubMenu.length > 0, onClick: handleViewOnAlexandria }) } } } const callsSubMenu: SubMenuAction[] = [ { label: t('Start call about this'), onClick: () => { closeDrawer() const roomId = `imwald-note-${event.id}` window.open(buildHiveTalkJoinUrl({ room: roomId }), '_blank', 'noopener,noreferrer') } }, { label: t('Copy call invite link'), onClick: () => { closeDrawer() const roomId = `imwald-note-${event.id}` navigator.clipboard.writeText(buildHiveTalkJoinUrl({ room: roomId })) toast.success(t('Copied to clipboard')) } }, ...(onOpenCallInvite ? [ { label: t('Send call invite'), onClick: () => { closeDrawer() const roomId = `imwald-note-${event.id}` const url = buildHiveTalkJoinUrl({ room: roomId }) onOpenCallInvite(`${t('Join the video call')}: ${url}`) } } ] : []) ] const isProtected = isProtectedEvent(event) const isDiscussion = event.kind === ExtendedKind.DISCUSSION const showRepublish = broadcastSubMenu.length > 0 && (!isProtected || event.pubkey === pubkey) && !isDiscussion && !isReplyToDiscussion const advancedAuthorMetaRows: SubMenuAction[] = [] if (getUsingClient(event)) { advancedAuthorMetaRows.push({ label: (
{t('Posted via')}
), onClick: () => {}, className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent' }) } advancedAuthorMetaRows.push({ label: (
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > NIP-05
), onClick: () => {}, className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent', filterHaystack: 'nip05' }) const advancedSubMenu: SubMenuAction[] = [ ...advancedAuthorMetaRows, { label: t('Copy event ID'), separator: advancedAuthorMetaRows.length > 0, onClick: () => { navigator.clipboard.writeText(getNoteBech32Id(event)) closeDrawer() } }, { label: t('Copy user ID'), onClick: () => { navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') closeDrawer() } } ] if (pubkey && event.pubkey !== pubkey) { advancedSubMenu.push({ label: t('Report'), className: 'text-destructive focus:text-destructive', separator: true, onClick: () => { closeDrawer() setIsReportDialogOpen(true) } }) } if (canSignEvents && pubkey && onOpenEditOrClone) { advancedSubMenu.push({ label: t('Edit or fork this event'), separator: advancedSubMenu.length > 2, onClick: () => { closeDrawer() onOpenEditOrClone(event.pubkey === pubkey ? 'edit' : 'clone') } }) } advancedSubMenu.push({ label: t('View raw event'), separator: true, onClick: () => { closeDrawer() setIsRawEventDialogOpen(true) } }) if (onViewAttestation) { advancedSubMenu.push({ label: t('View attestation'), onClick: () => { closeDrawer() onViewAttestation() } }) } if (showRepublish) { advancedSubMenu.push({ label: t('Republish to ...'), separator: true, onClick: isSmallScreen ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) : () => {}, subMenu: isSmallScreen ? undefined : broadcastSubMenu } as SubMenuAction) } if (isArticleType) { const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT if (isMarkdownFormat) { advancedSubMenu.push({ label: t('Export as Markdown'), onClick: () => { closeDrawer() exportAsMarkdown() } }) } if (isAsciidocFormat) { advancedSubMenu.push({ label: t('Export as AsciiDoc'), onClick: () => { closeDrawer() exportAsAsciidoc() } }) } if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) { advancedSubMenu.push({ label: t('Rebroadcast entire publication'), separator: true, onClick: isSmallScreen ? () => showSubMenuActions( publicationBroadcastSubMenu, t('Rebroadcast entire publication to ...') ) : () => {}, subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu } as SubMenuAction) } } const actions: MenuAction[] = [] if (READ_ALOUD_KINDS.includes(event.kind)) { actions.push({ icon: Volume2, label: t('Read this note aloud'), onClick: () => { closeDrawer() void speakNoteReadAloud(event).then((result) => { if (result === 'unsupported') { toast.error(t('Read-aloud is not supported in this browser')) } else if (result === 'empty') { toast.error(t('Nothing to read aloud')) } else if (result === 'error') { toast.error(t('Read-aloud failed')) } }) } }) } if (noteSupportsTranslateMenu) { actions.push({ icon: Languages, label: t('Translate note'), onClick: isSmallScreen ? () => showSubMenuActions(translateTargetSubmenu, t('Translate note'), { subMenuSearchable: true }) : undefined, subMenu: isSmallScreen ? undefined : translateTargetSubmenu, subMenuSearchable: true }) } pushSubMenuParent(actions, Share2, t('Connections'), connectionsSubMenu, { separator: actions.length > 0 }) pushSubMenuParent(actions, Video, t('Calls'), callsSubMenu) pushSubMenuParent(actions, Settings, t('Advanced'), advancedSubMenu, { separator: actions.length > 0 }) 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: actions.length > 0 }, { icon: BellOff, label: t('Mute user publicly'), onClick: () => { closeDrawer() mutePubkeyPublicly(event.pubkey) }, className: 'text-destructive focus:text-destructive' } ) } } const savesGroupStartIndex = actions.length const savesGroupNeedsSeparator = savesGroupStartIndex > 0 if (threadWatch && pubkey) { actions.push({ icon: Bell, label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'), separator: savesGroupNeedsSeparator, onClick: () => { closeDrawer() void checkLogin(async () => { try { if (threadFollowed) { const ok = await threadWatch.unfollowThreadForNotifications(event) if (ok) { toast.success(t('Unfollowed thread notifications')) } else { toast.error(t('Thread notification list update failed')) } } else { await threadWatch.followThreadForNotifications(event) toast.success(t('Following thread for notifications')) } } catch (err) { toast.error( t('Thread notification list update failed') + ': ' + (err instanceof Error ? err.message : String(err)) ) } }) } }) actions.push({ icon: BellOff, label: threadMuted ? t('Unmute thread notifications') : t('Mute this'), className: 'text-destructive focus:text-destructive', onClick: () => { closeDrawer() void checkLogin(async () => { try { if (threadMuted) { const ok = await threadWatch.unmuteThreadForNotifications(event) if (ok) { toast.success(t('Unmuted thread notifications')) } else { toast.error(t('Thread notification list update failed')) } } else { await threadWatch.muteThreadForNotifications(event) toast.success(t('Muted thread for notifications')) } } catch (err) { toast.error( t('Thread notification list update failed') + ': ' + (err instanceof Error ? err.message : String(err)) ) } }) } }) } if (pubkey && event.pubkey === pubkey) { actions.push({ icon: Pin, label: isPinnedInMyList ? t('Unpin note') : t('Pin note'), onClick: () => { handlePinNote() }, separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator }) } else if (pubkey && event.pubkey !== pubkey && bookmarksContext) { actions.push({ icon: Bookmark, label: isBookmarked ? t('Remove bookmark') : t('Bookmark'), onClick: () => { closeDrawer() void checkLogin(async () => { if (bookmarkUpdating) return setBookmarkUpdating(true) try { if (isBookmarked) { await removeBookmark(event) } else { await addBookmark(event) } } catch (error) { toast.error( (isBookmarked ? t('Remove bookmark failed') : t('Bookmark failed')) + ': ' + (error as Error).message ) } finally { setBookmarkUpdating(false) } }) }, separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator }) } // Delete only when signed in as the author with a signing key (not read-only npub) if (canSignEvents && 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, broadcastSubMenu, closeDrawer, showSubMenuActions, setIsRawEventDialogOpen, setIsReportDialogOpen, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey, attemptDelete, isPinnedInMyList, handlePinNote, bookmarkListEvent, bookmarksContext, isBookmarked, bookmarkUpdating, addBookmark, removeBookmark, checkLogin, isArticleType, articleMetadata, dTag, authorNpubForDecentNewsroom, naddr, onOpenPublicMessage, onOpenCallInvite, onOpenEditOrClone, canSignEvents, profile, noteTranslationFromMenu, translateMenuOptions, onViewAttestation, push, currentPrimaryPage, isReplyToDiscussion, threadWatch, threadFollowed, threadMuted ]) return menuActions }