Browse Source

bug-fixes

change highlighting to a context menu
imwald
Silberengel 2 months ago
parent
commit
4d83fbb873
  1. 10
      src/components/Note/CreateHighlightContext.tsx
  2. 46
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  3. 115
      src/components/Note/SelectionHighlightTrigger.tsx
  4. 165
      src/components/Note/index.tsx
  5. 3
      src/components/NoteCard/MainNoteCard.tsx
  6. 51
      src/components/NoteOptions/index.tsx
  7. 725
      src/components/NoteOptions/useMenuActions.tsx
  8. 9
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  9. 2
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  10. 12
      src/components/ui/dialog.tsx
  11. 10
      src/components/ui/dropdown-menu.tsx
  12. 37
      src/components/ui/popover.tsx
  13. 14
      src/constants.ts
  14. 12
      src/i18n/locales/en.ts
  15. 50
      src/lib/build-highlight-data.ts
  16. 9
      src/services/nip66.service.ts

10
src/components/Note/CreateHighlightContext.tsx

@ -0,0 +1,10 @@ @@ -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<OpenHighlightFn | null>(null)
export function useCreateHighlight(): OpenHighlightFn | null {
return useContext(CreateHighlightContext)
}

46
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -580,6 +580,21 @@ export default function PublicationIndex({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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(() => {

115
src/components/Note/SelectionHighlightTrigger.tsx

@ -0,0 +1,115 @@ @@ -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<HTMLDivElement>(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 (
<div ref={containerRef} onMouseUp={handleMouseUp} className="relative">
{children}
{toolbar && (
<>
<div
className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg"
style={{
top: toolbar.top,
left: Math.max(8, Math.min(toolbar.left, typeof window !== 'undefined' ? window.innerWidth - 176 : toolbar.left))
}}
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5"
onClick={handleCreateHighlight}
>
<Highlighter className="h-4 w-4" />
{t('Create Highlight')}
</Button>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" onClick={handleDismiss}>
{t('Cancel')}
</Button>
</div>
<div
className="fixed inset-0 z-[149]"
aria-hidden
onClick={handleDismiss}
/>
</>
)}
</div>
)
}

165
src/components/Note/index.tsx

@ -6,8 +6,11 @@ import logger from '@/lib/logger' @@ -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({ @@ -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<HighlightData | undefined>(undefined)
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')
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({ @@ -189,72 +211,91 @@ export default function Note({
content = <MarkdownArticle className="mt-2" event={event} />
}
const wrappedContent = isHighlightableKind ? (
<SelectionHighlightTrigger event={event}>{content}</SelectionHighlightTrigger>
) : (
content
)
return (
<div
className={`${className} ${disableClick ? '' : 'clickable'}`}
onClick={disableClick ? undefined : (e) => {
// 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))
}}
>
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
<CreateHighlightContext.Provider value={openHighlight}>
<div
className={`${className} ${disableClick ? '' : 'clickable'}`}
onClick={disableClick ? undefined : (e) => {
// 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))
}}
>
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
</div>
<div className="flex items-center gap-1">
{event.kind === ExtendedKind.DISCUSSION && (
<button
className="p-1 hover:bg-muted rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(event))
}}
title="View in Discussions"
>
<MessageSquare className="w-4 h-4 text-blue-500" />
</button>
)}
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
{size === 'normal' && (
<NoteOptions
event={event}
className="py-1 shrink-0 [&_svg]:size-5"
initialHighlightData={highlightData}
highlightDefaultContent={highlightDefaultContent}
isPostEditorOpen={postEditorOpen}
onPostEditorClose={() => {
setPostEditorOpen(false)
setHighlightData(undefined)
setHighlightDefaultContent('')
}}
/>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1">
{event.kind === ExtendedKind.DISCUSSION && (
<button
className="p-1 hover:bg-muted rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(event))
}}
title="View in Discussions"
>
<MessageSquare className="w-4 h-4 text-blue-500" />
</button>
)}
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
{size === 'normal' && (
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
)}
</div>
{parentEventId && (
<ParentNotePreview
eventId={parentEventId}
className="mt-2"
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(parentEventId))
}}
/>
)}
<IValue event={event} className="mt-2" />
{wrappedContent}
</div>
{parentEventId && (
<ParentNotePreview
eventId={parentEventId}
className="mt-2"
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(parentEventId))
}}
/>
)}
<IValue event={event} className="mt-2" />
{content}
</div>
</CreateHighlightContext.Provider>
)
}

3
src/components/NoteCard/MainNoteCard.tsx

@ -27,6 +27,9 @@ export default function MainNoteCard({ @@ -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]')) {

51
src/components/NoteOptions/index.tsx

@ -8,9 +8,23 @@ import RawEventDialog from './RawEventDialog' @@ -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 @@ -18,9 +32,6 @@ export default function NoteOptions({ event, className }: { event: Event; classN
const [showSubMenu, setShowSubMenu] = useState(false)
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
const [subMenuTitle, setSubMenuTitle] = useState('')
const [isPostEditorOpen, setIsPostEditorOpen] = useState(false)
const [initialHighlightData, setInitialHighlightData] = useState<HighlightData | undefined>(undefined)
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')
const closeDrawer = () => {
setIsDrawerOpen(false)
@ -43,13 +54,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN @@ -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 @@ -92,18 +97,16 @@ export default function NoteOptions({ event, className }: { event: Event; classN
isOpen={isReportDialogOpen}
closeDialog={() => setIsReportDialogOpen(false)}
/>
<PostEditor
open={isPostEditorOpen}
setOpen={(open) => {
setIsPostEditorOpen(open)
if (!open) {
setInitialHighlightData(undefined)
setHighlightDefaultContent('')
}
}}
defaultContent={highlightDefaultContent}
initialHighlightData={initialHighlightData}
/>
{onPostEditorClose != null && (
<PostEditor
open={isPostEditorOpen ?? false}
setOpen={(open) => {
if (!open) onPostEditorClose()
}}
defaultContent={highlightDefaultContent ?? ''}
initialHighlightData={initialHighlightData}
/>
)}
</div>
)
}

725
src/components/NoteOptions/useMenuActions.tsx

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

9
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -1,3 +1,4 @@ @@ -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 { @@ -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 { @@ -19,6 +22,7 @@ export interface MentionListHandle {
}
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
@ -77,7 +81,10 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -77,7 +81,10 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
return (
<div
className="border rounded-lg bg-background z-[110] pointer-events-auto flex flex-col max-h-80 min-h-0 overflow-y-scroll overflow-x-hidden"
className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col max-h-80 min-h-0 overflow-y-scroll overflow-x-hidden',
inDialog ? 'z-[210]' : 'z-[110]'
)}
onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
>

2
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -38,7 +38,7 @@ const suggestion = { @@ -38,7 +38,7 @@ const suggestion = {
},
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(MentionList, {
props,
...props,
editor: props.editor
})

12
src/components/ui/dialog.tsx

@ -6,6 +6,8 @@ import { randomString } from '@/lib/random' @@ -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< @@ -58,7 +60,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-[100] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-[200] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@ -77,18 +79,20 @@ const DialogContent = React.forwardRef< @@ -77,18 +79,20 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 sm:border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-[200] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 sm:border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
{!withoutClose && (
<DialogContext.Provider value={true}>
{children}
{!withoutClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogContext.Provider>
</DialogPrimitive.Content>
</DialogPortal>
))

10
src/components/ui/dropdown-menu.tsx

@ -2,6 +2,7 @@ import * as React from 'react' @@ -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< @@ -80,12 +81,14 @@ const DropdownMenuSubContent = React.forwardRef<
})
}
const inDialog = React.useContext(DialogContext)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={contentRef}
className={cn(
'relative z-[100] min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {
@ -178,13 +181,16 @@ const DropdownMenuContent = React.forwardRef< @@ -178,13 +181,16 @@ const DropdownMenuContent = React.forwardRef<
})
}
const inDialog = React.useContext(DialogContext)
return (
<DropdownMenuPrimitive.Portal container={portalContainer}>
<DropdownMenuPrimitive.Content
ref={contentRef}
sideOffset={sideOffset}
className={cn(
'relative z-[100] min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {

37
src/components/ui/popover.tsx

@ -1,6 +1,7 @@ @@ -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 @@ -12,22 +13,26 @@ const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
className={cn(
'z-[110] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
onOpenAutoFocus={(e) => e.preventDefault()}
{...props}
/>
</PopoverPrimitive.Portal>
))
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
const inDialog = React.useContext(DialogContext)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
className={cn(
'w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[110]',
className
)}
onOpenAutoFocus={(e) => e.preventDefault()}
{...props}
/>
</PopoverPrimitive.Portal>
)
})
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

14
src/constants.ts

@ -98,20 +98,6 @@ export const NIP66_DISCOVERY_RELAY_URLS = [ @@ -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'

12
src/i18n/locales/en.ts

@ -415,6 +415,18 @@ export default { @@ -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}}',

50
src/lib/build-highlight-data.ts

@ -0,0 +1,50 @@ @@ -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
}
}

9
src/services/nip66.service.ts

@ -6,7 +6,6 @@ @@ -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 { @@ -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<string[]> {
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 { @@ -179,7 +178,7 @@ class Nip66Service {
} catch {
// ignore
}
return [...PUBLIC_LIVELY_RELAY_URLS]
return []
}
getDiscovery(url: string): TNip66RelayDiscovery | undefined {

Loading…
Cancel
Save