diff --git a/src/components/Note/CreateHighlightContext.tsx b/src/components/Note/CreateHighlightContext.tsx new file mode 100644 index 00000000..df45bbff --- /dev/null +++ b/src/components/Note/CreateHighlightContext.tsx @@ -0,0 +1,10 @@ +import type { HighlightData } from '@/components/PostEditor/HighlightEditor' +import { createContext, useContext } from 'react' + +export type OpenHighlightFn = (highlightData: HighlightData, eventContent?: string) => void + +export const CreateHighlightContext = createContext(null) + +export function useCreateHighlight(): OpenHighlightFn | null { + return useContext(CreateHighlightContext) +} diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index 4285c427..0d77c1c4 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -580,6 +580,21 @@ export default function PublicationIndex({ return await fetchEventWithSubscription(filter, finalRelayUrls, logPrefix) }, [buildComprehensiveRelayList, fetchEventWithSubscription]) + /** Resolve eventId (hex, note1, or nevent1) to 64-char hex for filter.ids. Relays require 64-char hex; wrong length causes "uneven size input to from_hex". */ + const resolveEventIdToHex = useCallback((eventId: string): string | undefined => { + if (!eventId) return undefined + const trimmed = eventId.trim() + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase() + try { + const decoded = nip19.decode(trimmed) + if (decoded.type === 'note') return decoded.data + if (decoded.type === 'nevent') return decoded.data.id + } catch { + // ignore + } + return undefined + }, []) + // Fetch a single reference with retry logic const fetchSingleReference = useCallback(async ( ref: PublicationReference, @@ -652,8 +667,8 @@ export default function PublicationIndex({ } } else if (ref.type === 'e' && ref.eventId) { // Handle event ID reference (e tag) - same as a tags - // First check indexedDb PUBLICATION_EVENTS store (events cached as part of publications) - const hexId = ref.eventId.length === 64 ? ref.eventId : undefined + // Resolve to 64-char hex only; relays require hex in filter.ids (wrong length → "uneven size input to from_hex") + const hexId = resolveEventIdToHex(ref.eventId) if (hexId) { try { // Check PUBLICATION_EVENTS store first (for non-replaceable events stored with master) @@ -681,10 +696,19 @@ export default function PublicationIndex({ // If not found in indexedDb cache, try to fetch from relay using unified method if (!fetchedEvent) { - // Build comprehensive relay list and fetch using unified method - const additionalRelays = ref.relay ? [ref.relay] : [] - const filter = { ids: [hexId || ref.eventId], limit: 1 } - fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'e tag') + if (hexId) { + // Only send filter.ids with valid 64-char hex; otherwise relays can return "bad req: uneven size input to from_hex" + const additionalRelays = ref.relay ? [ref.relay] : [] + const filter = { ids: [hexId], limit: 1 } + fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'e tag') + } else { + // ref.eventId is bech32 or invalid; client.fetchEvent decodes bech32 and builds correct filter internally + try { + fetchedEvent = await client.fetchEvent(ref.eventId) + } catch (err) { + logger.debug('[PublicationIndex] fetchEvent failed for ref.eventId:', ref.eventId, err) + } + } // Cache the fetched event if found if (fetchedEvent) { @@ -777,7 +801,7 @@ export default function PublicationIndex({ } return updatedRef } - }, [referencesData]) + }, [referencesData, resolveEventIdToHex, fetchEventFromRelay]) // Helper function to extract nested references from an event const extractNestedReferences = useCallback(( @@ -862,7 +886,7 @@ export default function PublicationIndex({ if (ref.type === 'a' && ref.coordinate) { cached = await indexedDb.getPublicationEvent(ref.coordinate) } else if (ref.type === 'e' && ref.eventId) { - const hexId = ref.eventId.length === 64 ? ref.eventId : undefined + const hexId = resolveEventIdToHex(ref.eventId) if (hexId) { cached = await indexedDb.getEventFromPublicationStore(hexId) if (!cached && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) { @@ -905,7 +929,7 @@ export default function PublicationIndex({ if (ref.type === 'a' && ref.coordinate) { cached = await indexedDb.getPublicationEvent(ref.coordinate) } else if (ref.type === 'e' && ref.eventId) { - const hexId = ref.eventId.length === 64 ? ref.eventId : undefined + const hexId = resolveEventIdToHex(ref.eventId) if (hexId) { cached = await indexedDb.getEventFromPublicationStore(hexId) if (!cached && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) { @@ -977,7 +1001,7 @@ export default function PublicationIndex({ if (nestedRef.type === 'a' && nestedRef.coordinate) { nestedCached = await indexedDb.getPublicationEvent(nestedRef.coordinate) } else if (nestedRef.type === 'e' && nestedRef.eventId) { - const hexId = nestedRef.eventId.length === 64 ? nestedRef.eventId : undefined + const hexId = resolveEventIdToHex(nestedRef.eventId) if (hexId) { nestedCached = await indexedDb.getEventFromPublicationStore(hexId) if (!nestedCached && nestedRef.kind && nestedRef.pubkey && isReplaceableEvent(nestedRef.kind)) { @@ -1073,7 +1097,7 @@ export default function PublicationIndex({ fetched: allFetchedRefs, failed: allFetchedRefs.filter(ref => !ref.event) } - }, [fetchSingleReference, extractNestedReferences]) + }, [fetchSingleReference, extractNestedReferences, resolveEventIdToHex]) // Fetch referenced events useEffect(() => { diff --git a/src/components/Note/SelectionHighlightTrigger.tsx b/src/components/Note/SelectionHighlightTrigger.tsx new file mode 100644 index 00000000..c9197aa7 --- /dev/null +++ b/src/components/Note/SelectionHighlightTrigger.tsx @@ -0,0 +1,115 @@ +import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' +import { useCreateHighlight } from './CreateHighlightContext' +import { Event } from 'nostr-tools' +import { Highlighter } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' + +function getParagraphContextFromRange(range: Range): string { + let node: Node | null = range.commonAncestorContainer + if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement + let el = node as Element | null + while (el) { + const tag = el.tagName?.toLowerCase() + if (tag === 'p' || (tag?.startsWith('h') && /^h[1-6]$/.test(tag))) { + return el.textContent?.trim() || range.toString().trim() + } + el = el.parentElement + } + return range.toString().trim() +} + +export default function SelectionHighlightTrigger({ + event, + children +}: { + event: Event + children: React.ReactNode +}) { + const { t } = useTranslation() + const openHighlight = useCreateHighlight() + const containerRef = useRef(null) + const [toolbar, setToolbar] = useState<{ + selectedText: string + paragraphContext: string + top: number + left: number + } | null>(null) + + const handleMouseUp = useCallback(() => { + if (!openHighlight || !containerRef.current) return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + setToolbar(null) + return + } + const range = sel.getRangeAt(0) + if (!containerRef.current.contains(range.commonAncestorContainer)) { + setToolbar(null) + return + } + const selectedText = range.toString().trim() + if (!selectedText) { + setToolbar(null) + return + } + const rect = range.getBoundingClientRect() + setToolbar({ + selectedText, + paragraphContext: getParagraphContextFromRange(range), + top: rect.top - 44, + left: rect.left + rect.width / 2 - 80 + }) + }, [openHighlight]) + + const handleCreateHighlight = useCallback(() => { + if (!toolbar || !openHighlight) return + const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext) + openHighlight(highlightData, toolbar.selectedText) + setToolbar(null) + window.getSelection()?.removeAllRanges() + }, [event, toolbar, openHighlight]) + + const handleDismiss = useCallback(() => { + setToolbar(null) + }, []) + + if (!openHighlight) return <>{children} + + return ( +
+ {children} + {toolbar && ( + <> +
+ + +
+
+ + )} +
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 0840fef3..90c68508 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -6,8 +6,11 @@ import logger from '@/lib/logger' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { CreateHighlightContext } from './CreateHighlightContext' +import SelectionHighlightTrigger from './SelectionHighlightTrigger' import AudioPlayer from '../AudioPlayer' import ClientTag from '../ClientTag' import { FormattedTimestamp } from '../FormattedTimestamp' @@ -68,6 +71,25 @@ export default function Note({ const [showNsfw, setShowNsfw] = useState(false) const { mutePubkeySet } = useMuteList() const [showMuted, setShowMuted] = useState(false) + const [highlightData, setHighlightData] = useState(undefined) + const [highlightDefaultContent, setHighlightDefaultContent] = useState('') + const [postEditorOpen, setPostEditorOpen] = useState(false) + + const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { + setHighlightData(data) + setHighlightDefaultContent(eventContent ?? '') + setPostEditorOpen(true) + }, []) + + const isHighlightableKind = + event.kind === kinds.ShortTextNote || + 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 === ExtendedKind.DISCUSSION || + event.kind === ExtendedKind.COMMENT let content: React.ReactNode @@ -189,72 +211,91 @@ export default function Note({ content = } + const wrappedContent = isHighlightableKind ? ( + {content} + ) : ( + content + ) + return ( -
{ - // Don't navigate if clicking on interactive elements - const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) { - return - } - e.stopPropagation() - navigateToNote(toNote(event)) - }} - > -
-
- -
-
- - + +
{ + // Don't navigate if clicking on interactive elements + const target = e.target as HTMLElement + if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) { + return + } + e.stopPropagation() + navigateToNote(toNote(event)) + }} + > +
+
+ +
+
+ + +
+
+ + +
-
- - +
+ {event.kind === ExtendedKind.DISCUSSION && ( + + )} + + {size === 'normal' && ( + { + setPostEditorOpen(false) + setHighlightData(undefined) + setHighlightDefaultContent('') + }} /> -
+ )}
-
- {event.kind === ExtendedKind.DISCUSSION && ( - - )} - - {size === 'normal' && ( - - )} -
+ {parentEventId && ( + { + e.stopPropagation() + navigateToNote(toNote(parentEventId)) + }} + /> + )} + + {wrappedContent}
- {parentEventId && ( - { - e.stopPropagation() - navigateToNote(toNote(parentEventId)) - }} - /> - )} - - {content} -
+
) } diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 10168836..1848e439 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -27,6 +27,9 @@ export default function MainNoteCard({ className={className} data-event-id={event.id} onClick={(e) => { + // Don't navigate when user has selected text (e.g. for creating a highlight) + const sel = window.getSelection() + if (sel && !sel.isCollapsed) return // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) { diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index b69b9f93..42a3ef9e 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -8,9 +8,23 @@ import RawEventDialog from './RawEventDialog' import ReportDialog from './ReportDialog' import { SubMenuAction, useMenuActions } from './useMenuActions' import PostEditor from '../PostEditor' -import { HighlightData } from '../PostEditor/HighlightEditor' +import type { HighlightData } from '../PostEditor/HighlightEditor' -export default function NoteOptions({ event, className }: { event: Event; className?: string }) { +export default function NoteOptions({ + event, + className, + initialHighlightData, + highlightDefaultContent, + isPostEditorOpen, + onPostEditorClose +}: { + event: Event + className?: string + initialHighlightData?: HighlightData + highlightDefaultContent?: string + isPostEditorOpen?: boolean + onPostEditorClose?: () => void +}) { const { isSmallScreen } = useScreenSize() const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [isReportDialogOpen, setIsReportDialogOpen] = useState(false) @@ -18,9 +32,6 @@ export default function NoteOptions({ event, className }: { event: Event; classN const [showSubMenu, setShowSubMenu] = useState(false) const [activeSubMenu, setActiveSubMenu] = useState([]) const [subMenuTitle, setSubMenuTitle] = useState('') - const [isPostEditorOpen, setIsPostEditorOpen] = useState(false) - const [initialHighlightData, setInitialHighlightData] = useState(undefined) - const [highlightDefaultContent, setHighlightDefaultContent] = useState('') const closeDrawer = () => { setIsDrawerOpen(false) @@ -43,13 +54,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN showSubMenuActions, setIsRawEventDialogOpen, setIsReportDialogOpen, - isSmallScreen, - openHighlightEditor: (highlightData: HighlightData, eventContent?: string) => { - setInitialHighlightData(highlightData) - setHighlightDefaultContent(eventContent || '') - setIsPostEditorOpen(true) - closeDrawer() - } + isSmallScreen }) const trigger = useMemo( @@ -92,18 +97,16 @@ export default function NoteOptions({ event, className }: { event: Event; classN isOpen={isReportDialogOpen} closeDialog={() => setIsReportDialogOpen(false)} /> - { - setIsPostEditorOpen(open) - if (!open) { - setInitialHighlightData(undefined) - setHighlightDefaultContent('') - } - }} - defaultContent={highlightDefaultContent} - initialHighlightData={initialHighlightData} - /> + {onPostEditorClose != null && ( + { + if (!open) onPostEditorClose() + }} + defaultContent={highlightDefaultContent ?? ''} + initialHighlightData={initialHighlightData} + /> + )}
) } diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 96ab815a..27d4282c 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -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 { 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({ 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({ ...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(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({ }, [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:
{t('All available relays')} ({allAvailableRelayUrls.length})
, + 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: ( +
+ {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) + 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:
{t('Write relays')}
, + 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({ label:
{set.name}
, 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({ ), 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({ } 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({ } }, [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({ }) } - // 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 (

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

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

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({ pubkey, isMuted, isSmallScreen, - openHighlightEditor, broadcastSubMenu, closeDrawer, showSubMenuActions, diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index a802e1f7..56b97709 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -1,3 +1,4 @@ +import type { Editor } from '@tiptap/core' import { formatNpub, userIdToPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' import { SuggestionKeyDownProps } from '@tiptap/suggestion' @@ -12,6 +13,8 @@ export interface MentionListProps { /** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */ selectedIndex?: number onSelectIndex?: (index: number) => void + /** When provided, used to detect if we're inside a dialog (for z-index). */ + editor?: Editor } export interface MentionListHandle { @@ -19,6 +22,7 @@ export interface MentionListHandle { } const MentionList = forwardRef((props, ref) => { + const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]')) const [internalIndex, setInternalIndex] = useState(0) const isControlled = props.selectedIndex !== undefined const selectedIndex = isControlled ? props.selectedIndex! : internalIndex @@ -77,7 +81,10 @@ const MentionList = forwardRef((props, ref) return (

e.stopPropagation()} onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} > diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index e0bdfb61..b1c45da0 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -38,7 +38,7 @@ const suggestion = { }, onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component = new ReactRenderer(MentionList, { - props, + ...props, editor: props.editor }) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 14f98bff..9a88e75d 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -6,6 +6,8 @@ import { randomString } from '@/lib/random' import { cn } from '@/lib/utils' import modalManager from '@/services/modal-manager.service' +export const DialogContext = React.createContext(false) + const Dialog = ({ children, open, onOpenChange, ...props }: DialogPrimitive.DialogProps) => { const [innerOpen, setInnerOpen] = React.useState(open ?? false) const id = React.useMemo(() => `dialog-${randomString()}`, []) @@ -58,7 +60,7 @@ const DialogOverlay = React.forwardRef< - {children} - {!withoutClose && ( + + {children} + {!withoutClose && ( Close )} + )) diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 9778d354..c2afe843 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Check, ChevronDown, ChevronRight, ChevronUp, Circle } from 'lucide-react' +import { DialogContext } from '@/components/ui/dialog' import { cn } from '@/lib/utils' const DropdownMenu = DropdownMenuPrimitive.Root @@ -80,12 +81,14 @@ const DropdownMenuSubContent = React.forwardRef< }) } + const inDialog = React.useContext(DialogContext) return ( { if (showScrollButtons) { @@ -178,13 +181,16 @@ const DropdownMenuContent = React.forwardRef< }) } + const inDialog = React.useContext(DialogContext) + return ( { if (showScrollButtons) { diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 505b6a09..daf74acf 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' +import { DialogContext } from '@/components/ui/dialog' import { cn } from '@/lib/utils' const Popover = PopoverPrimitive.Root @@ -12,22 +13,26 @@ const PopoverAnchor = PopoverPrimitive.Anchor const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - e.preventDefault()} - {...props} - /> - -)) +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => { + const inDialog = React.useContext(DialogContext) + return ( + + e.preventDefault()} + {...props} + /> + + ) +}) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/constants.ts b/src/constants.ts index 706609dc..1b3878e3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -98,20 +98,6 @@ export const NIP66_DISCOVERY_RELAY_URLS = [ 'wss://relaypag.es' ] -/** - * Known public (no auth, open write) relays for censorship-resilience: when the user opts in, - * we add 3 random relays from this list to every publish. Curated list of lively public relays. - */ -export const PUBLIC_LIVELY_RELAY_URLS = [ - 'wss://relay.damus.io', - 'wss://relay.primal.net', - 'wss://nos.lol', - 'wss://thecitadel.nostr1.com', - 'wss://relay.lumina.rocks', - 'wss://nostr.mom', - 'wss://freelay.sovbit.host' -] - // Relay with bookstr composite index support export const BOOKSTR_RELAY_URLS = [ 'wss://orly-relay.imwald.eu' diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bfe4f2e2..4160a8cb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -415,6 +415,18 @@ export default { Poll: 'Poll', Media: 'Media', 'Republish to ...': 'Republish to ...', + 'All available relays': 'All available relays', + 'All active relays (monitoring list)': 'All active relays (monitoring list)', + 'Successfully republish to all available relays': 'Successfully republish to all available relays', + 'Failed to republish to all available relays: {{error}}': 'Failed to republish to all available relays: {{error}}', + 'Successfully republish to all active relays': 'Successfully republish to all active relays', + 'Failed to republish to all active relays: {{error}}': 'Failed to republish to all active relays: {{error}}', + 'No active relays in monitoring list': 'No active relays in monitoring list', + 'No relay accepted the event': 'No relay accepted the event', + 'No relays available': 'No relays available', + 'No write relays configured': 'No write relays configured', + 'Relay did not accept the event': 'Relay did not accept the event', + 'Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".': 'Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', 'Successfully republish to your write relays': 'Successfully republish to your write relays', 'Failed to republish to your write relays: {{error}}': 'Failed to republish to your write relays: {{error}}', diff --git a/src/lib/build-highlight-data.ts b/src/lib/build-highlight-data.ts new file mode 100644 index 00000000..8e323cad --- /dev/null +++ b/src/lib/build-highlight-data.ts @@ -0,0 +1,50 @@ +import { getNoteBech32Id } from '@/lib/event' +import { Event, kinds } from 'nostr-tools' +import { nip19 } from 'nostr-tools' +import type { HighlightData } from '@/components/PostEditor/HighlightEditor' + +/** + * Build HighlightData for a Nostr event (source reference + optional paragraph context). + */ +export function buildHighlightDataFromEvent( + event: Event, + paragraphContext?: string +): HighlightData { + let sourceValue: string + let sourceHexId: string | undefined + + if (kinds.isAddressableKind(event.kind) || kinds.isReplaceableKind(event.kind)) { + 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 + } catch { + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + } else { + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + } else { + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + + return { + sourceType: 'nostr', + sourceValue, + sourceHexId, + context: paragraphContext || undefined + } +} diff --git a/src/services/nip66.service.ts b/src/services/nip66.service.ts index b919d002..8a71806d 100644 --- a/src/services/nip66.service.ts +++ b/src/services/nip66.service.ts @@ -6,7 +6,6 @@ * require this data to function; use as a hint only. */ -import { PUBLIC_LIVELY_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import indexDb from '@/services/indexed-db.service' import { TNip66RelayDiscovery, TRelayInfo } from '@/types' @@ -164,13 +163,13 @@ class Nip66Service { } /** - * Returns relay URLs to use for "add 3 random relays to publish". Prefers NIP-66 discovery - * (in-memory then IndexedDB cache), falls back to static PUBLIC_LIVELY_RELAY_URLS. + * Returns relay URLs from NIP-66 discovery (in-memory then IndexedDB cache). + * Returns empty array when no monitoring list is available (caller may fallback to other relay lists). */ async getPublicLivelyRelayUrls(): Promise { const fromMemory = this.buildPublicLivelyFromDiscovery() if (fromMemory.length > 0) return fromMemory - if (typeof window === 'undefined') return [...PUBLIC_LIVELY_RELAY_URLS] + if (typeof window === 'undefined') return [] try { const cached = await indexDb.getPublicLivelyRelayUrlsCache() if (cached?.urls?.length && (Date.now() - cached.cachedAt) < PUBLIC_LIVELY_CACHE_TTL_MS) { @@ -179,7 +178,7 @@ class Nip66Service { } catch { // ignore } - return [...PUBLIC_LIVELY_RELAY_URLS] + return [] } getDiscovery(url: string): TNip66RelayDiscovery | undefined {