Browse Source

bug-fixing

imwald
Silberengel 1 month ago
parent
commit
a07f21e01f
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 7
      src/components/PostEditor/PostContent.tsx
  4. 130
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  5. 30
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  6. 2
      src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx
  7. 89
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  8. 26
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  9. 6
      src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts
  10. 24
      src/components/TextareaWithMentionAutocomplete/index.tsx
  11. 21
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  12. 2
      src/providers/NotificationProvider.tsx
  13. 21
      src/services/client.service.ts
  14. 48
      src/services/indexed-db.service.ts
  15. 103
      src/services/mention-event-search.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "17.0.1", "version": "18.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "17.0.1", "version": "18.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "17.0.1", "version": "18.0.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

7
src/components/PostEditor/PostContent.tsx

@ -61,6 +61,7 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector' import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog'
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons'
import Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
@ -2103,7 +2104,6 @@ export default function PostContent({
</> </>
} }
/> />
</NeventPickerProvider>
{isPoll && ( {isPoll && (
<PollEditor <PollEditor
pollCreateData={pollCreateData} pollCreateData={pollCreateData}
@ -2238,6 +2238,10 @@ export default function PostContent({
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
)} )}
<MentionAndEventToolbarButtons
insertAtCursor={(text) => textareaRef.current?.insertText(text)}
variant="ghost"
/>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -2398,6 +2402,7 @@ export default function PostContent({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</NeventPickerProvider>
</div> </div>
) )
} }

130
src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx

@ -0,0 +1,130 @@
/**
* Shared toolbar icon buttons for mention (npub) search and event/address (nevent/naddr) search.
* Must be rendered inside NeventPickerProvider.
*/
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SimpleUsername } from '@/components/Username'
import { searchNpubsForMention } from '@/services/mention-event-search.service'
import { AtSign, FileSearch } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNeventPicker } from './useNeventPicker'
type ButtonVariant = 'ghost' | 'outline'
export function MentionAndEventToolbarButtons({
insertAtCursor,
buttonClassName,
variant = 'outline'
}: {
insertAtCursor: (text: string) => void
/** Optional class for the icon buttons (e.g. for consistency with surrounding toolbar). */
buttonClassName?: string
/** Button variant to match surrounding toolbar (e.g. 'ghost' for PostEditor). */
variant?: ButtonVariant
}) {
const { t } = useTranslation()
const neventPicker = useNeventPicker()
const [mentionOpen, setMentionOpen] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
const [mentionResults, setMentionResults] = useState<string[]>([])
const [mentionLoading, setMentionLoading] = useState(false)
const mentionDebounceRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (!mentionOpen) return
const q = mentionQuery.trim()
if (!q) {
setMentionResults([])
return
}
mentionDebounceRef.current = setTimeout(() => {
setMentionLoading(true)
searchNpubsForMention(q, 20)
.then((list) => {
setMentionResults(list ?? [])
})
.finally(() => setMentionLoading(false))
}, 200)
return () => {
if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current)
}
}, [mentionOpen, mentionQuery])
const closeMention = useCallback(() => {
setMentionOpen(false)
setMentionQuery('')
setMentionResults([])
}, [])
const selectNpub = useCallback(
(npub: string) => {
insertAtCursor(`nostr:${npub} `)
closeMention()
},
[insertAtCursor, closeMention]
)
const defaultButtonClass = 'h-8 w-8'
const btnClass = buttonClassName ?? defaultButtonClass
return (
<>
<Popover open={mentionOpen} onOpenChange={(open) => (open ? setMentionOpen(true) : closeMention())}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
size="icon"
title={t('Insert mention')}
className={btnClass}
>
<AtSign className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2 z-[10000]" align="start" side="bottom" sideOffset={4}>
<Input
placeholder={t('Search for user…')}
value={mentionQuery}
onChange={(e) => setMentionQuery(e.target.value)}
className="mb-2"
autoFocus
/>
<div className="max-h-60 overflow-y-auto space-y-0.5">
{mentionLoading && (
<div className="py-4 text-center text-sm text-muted-foreground">{t('Searching…')}</div>
)}
{!mentionLoading && mentionQuery.trim() && mentionResults.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">{t('No users found')}</div>
)}
{!mentionLoading &&
mentionResults.map((npub) => (
<Button
key={npub}
type="button"
variant="ghost"
className="w-full justify-start text-left h-auto py-2 font-normal"
onClick={() => selectNpub(npub)}
>
<SimpleUsername userId={npub} className="text-sm truncate" />
</Button>
))}
</div>
</PopoverContent>
</Popover>
<Button
type="button"
variant={variant}
size="icon"
title={t('Insert event or address')}
className={btnClass}
onClick={() => neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' '))}
>
<FileSearch className="h-4 w-4" />
</Button>
</>
)
}

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

@ -7,11 +7,14 @@ import { useTranslation } from 'react-i18next'
import Nip05 from '../../../Nip05' import Nip05 from '../../../Nip05'
import { SimpleUserAvatar } from '../../../UserAvatar' import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username' import { SimpleUsername } from '../../../Username'
import type { PickerSearchMode } from '@/services/mention-event-search.service'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
export type MentionListItem = string | { id: string; mode?: PickerSearchMode }
export interface MentionListProps { export interface MentionListProps {
items: string[] items: MentionListItem[]
command: (payload: { id: string; label?: string }) => void command: (payload: { id: string; label?: string; mode?: PickerSearchMode }) => void
/** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */ /** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */
selectedIndex?: number selectedIndex?: number
onSelectIndex?: (index: number) => void onSelectIndex?: (index: number) => void
@ -32,15 +35,22 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex
const getItemId = (item: MentionListItem): string =>
typeof item === 'string' ? item : item.id
const getItemMode = (item: MentionListItem): PickerSearchMode | undefined =>
typeof item === 'object' && item && 'mode' in item ? item.mode : undefined
const selectItem = (index: number) => { const selectItem = (index: number) => {
const item = items[index] const item = items[index]
if (item) { if (item) {
const id = getItemId(item)
const label = const label =
item === NEVENT_NADDR_PICKER_ID id === NEVENT_NADDR_PICKER_ID
? t('Search for event or address…') ? t('Search for event or address…')
: formatNpub(item) : formatNpub(id)
props.command({ id: item, label }) props.command({ id, label, mode: getItemMode(item) })
} }
} }
@ -104,21 +114,21 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md', 'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
selectedIndex === index && 'bg-accent text-accent-foreground' selectedIndex === index && 'bg-accent text-accent-foreground'
)} )}
key={item} key={getItemId(item)}
onClick={() => selectItem(index)} onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
> >
<div className="flex gap-2 w-80 items-center truncate pointer-events-none"> <div className="flex gap-2 w-80 items-center truncate pointer-events-none">
{item === NEVENT_NADDR_PICKER_ID ? ( {getItemId(item) === NEVENT_NADDR_PICKER_ID ? (
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
{t('Search for event or address…')} {t('Search for event or address…')}
</span> </span>
) : ( ) : (
<> <>
<SimpleUserAvatar userId={item} /> <SimpleUserAvatar userId={getItemId(item)} />
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<SimpleUsername userId={item} className="font-semibold truncate" /> <SimpleUsername userId={getItemId(item)} className="font-semibold truncate" />
<Nip05 pubkey={userIdToPubkey(item)} /> <Nip05 pubkey={userIdToPubkey(getItemId(item))} />
</div> </div>
</> </>
)} )}

2
src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx

@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react' import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
import { useNeventPicker } from './NeventNaddrPickerDialog' import { useNeventPicker } from './useNeventPicker'
export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) { export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) {
const id = props.node.attrs.id as string const id = props.node.attrs.id as string

89
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

@ -1,7 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import client from '@/services/client.service' import client from '@/services/client.service'
import {
searchEventsForPicker,
type PickerSearchMode
} from '@/services/mention-event-search.service'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@ -10,9 +13,8 @@ import {
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { SimpleUsername } from '@/components/Username' import { SimpleUsername } from '@/components/Username'
import { kinds, nip19, type Event as NEvent } from 'nostr-tools' import { nip19, type Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Loader2, Search } from 'lucide-react' import { Loader2, Search } from 'lucide-react'
@ -23,14 +25,18 @@ type NeventNaddrPickerDialogProps = {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSelect: (nostrLink: string) => void onSelect: (nostrLink: string) => void
/** When provided, the dialog opens with this tab selected (e.g. from @naddr vs @nevent). */
initialMode?: PickerSearchMode
} }
export function NeventNaddrPickerDialog({ export function NeventNaddrPickerDialog({
open, open,
onOpenChange, onOpenChange,
onSelect onSelect,
initialMode
}: NeventNaddrPickerDialogProps) { }: NeventNaddrPickerDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [mode, setMode] = useState<PickerSearchMode>(initialMode ?? 'nevent')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('')
const [events, setEvents] = useState<NEvent[]>([]) const [events, setEvents] = useState<NEvent[]>([])
@ -41,7 +47,8 @@ export function NeventNaddrPickerDialog({
setQuery('') setQuery('')
setDebouncedQuery('') setDebouncedQuery('')
setEvents([]) setEvents([])
}, [open]) if (initialMode !== undefined) setMode(initialMode)
}, [open, initialMode])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -57,8 +64,7 @@ export function NeventNaddrPickerDialog({
} }
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
client searchEventsForPicker(debouncedQuery, 20, mode)
.fetchEvents(SEARCHABLE_RELAY_URLS, { kinds: [kinds.ShortTextNote], search: debouncedQuery, limit: 20 }, { eoseTimeout: 5000, globalTimeout: 8000 })
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
setEvents(list.slice(0, 15) as NEvent[]) setEvents(list.slice(0, 15) as NEvent[])
@ -69,7 +75,7 @@ export function NeventNaddrPickerDialog({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [open, debouncedQuery]) }, [open, debouncedQuery, mode])
const handleSelect = useCallback( const handleSelect = useCallback(
(event: NEvent) => { (event: NEvent) => {
@ -89,23 +95,45 @@ export function NeventNaddrPickerDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="max-w-lg max-h-[80vh] flex flex-col gap-4 z-[10001]" className="max-w-lg max-h-[80vh] flex flex-col gap-4 z-[99999]"
overlayClassName="z-[10001]" overlayClassName="z-[99999]"
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{t('Search for event or address…')}</DialogTitle> <DialogTitle>{t('Search for event or address…')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex gap-2">
<Button
type="button"
variant={mode === 'nevent' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('nevent')}
>
{t('nevent')}
</Button>
<Button
type="button"
variant={mode === 'naddr' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('naddr')}
>
{t('naddr')}
</Button>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t('Search notes…')} placeholder={
mode === 'nevent'
? t('Search notes, threads, long-form…')
: t('Search calendar, publications, wiki…')
}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
autoFocus autoFocus
/> />
</div> </div>
<ScrollArea className="flex-1 min-h-[200px] max-h-[40vh] border rounded-md"> <div className="min-h-[200px] max-h-[50vh] border rounded-md overflow-y-auto overflow-x-hidden">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
@ -113,7 +141,9 @@ export function NeventNaddrPickerDialog({
</div> </div>
)} )}
{!loading && debouncedQuery && events.length === 0 && ( {!loading && debouncedQuery && events.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">{t('No notes found')}</p> <p className="text-sm text-muted-foreground text-center py-6">
{t('No events found')}
</p>
)} )}
{!loading && {!loading &&
events.map((ev: NEvent) => ( events.map((ev: NEvent) => (
@ -130,41 +160,44 @@ export function NeventNaddrPickerDialog({
</Button> </Button>
))} ))}
</div> </div>
</ScrollArea> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }
type NeventPickerContextValue = { type NeventPickerContextValue = {
openNeventPicker: (onSelected: (nostrLink: string) => void) => void openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void
} }
const NeventPickerContext = React.createContext<NeventPickerContextValue | null>(null) export const NeventPickerContext = React.createContext<NeventPickerContextValue | null>(null)
export function useNeventPicker(): NeventPickerContextValue | null {
return React.useContext(NeventPickerContext)
}
export function NeventPickerProvider({ children }: { children: React.ReactNode }) { export function NeventPickerProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null) const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null)
const [initialMode, setInitialMode] = useState<PickerSearchMode>('nevent')
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
const { editor, range } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail const { editor, range, initialMode: detailMode } = (e as CustomEvent<{
editor: Editor
range: { from: number; to: number }
initialMode?: PickerSearchMode
}>).detail
const to = extendMentionRangeToEndOfWord(editor, range) const to = extendMentionRangeToEndOfWord(editor, range)
setOnSelectedRef(() => (link: string) => { setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run() editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run()
}) })
setInitialMode(detailMode ?? 'nevent')
setOpen(true) setOpen(true)
} }
window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler) window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler) return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
}, []) }, [])
const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void) => { const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => {
setOnSelectedRef(() => onSelected) setOnSelectedRef(() => onSelected)
setInitialMode(mode ?? 'nevent')
setOpen(true) setOpen(true)
}, []) }, [])
@ -177,7 +210,10 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode }
) )
const handleOpenChange = useCallback((next: boolean) => { const handleOpenChange = useCallback((next: boolean) => {
if (!next) setOnSelectedRef(null) if (!next) {
setOnSelectedRef(null)
setInitialMode('nevent')
}
setOpen(next) setOpen(next)
}, []) }, [])
@ -186,7 +222,12 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode }
return ( return (
<NeventPickerContext.Provider value={value}> <NeventPickerContext.Provider value={value}>
{children} {children}
<NeventNaddrPickerDialog open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} /> <NeventNaddrPickerDialog
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
initialMode={initialMode}
/>
</NeventPickerContext.Provider> </NeventPickerContext.Provider>
) )
} }

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

@ -1,4 +1,7 @@
import client from '@/services/client.service' import {
searchNpubsForMention,
type PickerSearchMode
} from '@/services/mention-event-search.service'
import postEditor from '@/services/post-editor.service' import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
@ -9,6 +12,8 @@ import { NEVENT_NADDR_PICKER_ID } from './constants'
export { NEVENT_NADDR_PICKER_ID } from './constants' export { NEVENT_NADDR_PICKER_ID } from './constants'
export type { PickerSearchMode }
const MENTION_EXTENSION_NAME = 'mention' const MENTION_EXTENSION_NAME = 'mention'
const MENTION_CHAR = '@' const MENTION_CHAR = '@'
@ -33,11 +38,21 @@ export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: num
} }
const suggestion = { const suggestion = {
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => { command: ({
editor,
range,
props
}: {
editor: Editor
range: { from: number; to: number }
props: { id: string; label?: string; mode?: PickerSearchMode }
}) => {
if (props.id === NEVENT_NADDR_PICKER_ID) { if (props.id === NEVENT_NADDR_PICKER_ID) {
postEditor.closeSuggestionPopup() postEditor.closeSuggestionPopup()
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { detail: { editor, range } }) new CustomEvent(OPEN_NEVENT_PICKER_EVENT, {
detail: { editor, range, initialMode: props.mode ?? 'nevent' }
})
) )
return return
} }
@ -59,9 +74,10 @@ const suggestion = {
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return [NEVENT_NADDR_PICKER_ID] const mode: PickerSearchMode = q === 'naddr' || q.startsWith('naddr') ? 'naddr' : 'nevent'
return [{ id: NEVENT_NADDR_PICKER_ID, mode }]
} }
const result = await client.searchNpubsForMention(query, 20) const result = await searchNpubsForMention(query, 20)
return result ?? [] return result ?? []
}, },

6
src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts

@ -0,0 +1,6 @@
import * as React from 'react'
import { NeventPickerContext } from './NeventNaddrPickerDialog'
export function useNeventPicker() {
return React.useContext(NeventPickerContext)
}

24
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -1,9 +1,12 @@
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList'
import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants' import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants'
import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker'
import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList' import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList'
import client from '@/services/client.service' import {
searchNpubsForMention,
type PickerSearchMode
} from '@/services/mention-event-search.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import { searchStandardEmojiShortcodes } from '@/lib/emoji-content' import { searchStandardEmojiShortcodes } from '@/lib/emoji-content'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -19,6 +22,8 @@ export type TextareaWithMentionAutocompleteProps = Omit<
> & { > & {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
/** When provided, used to open the nevent/naddr picker when user selects that option. Use when context may be unavailable (e.g. modal). */
onOpenNeventPicker?: (onSelected: (link: string) => void, initialMode?: PickerSearchMode) => void
} }
/** /**
@ -29,6 +34,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
value, value,
onChange, onChange,
onKeyDown, onKeyDown,
onOpenNeventPicker,
...textareaProps ...textareaProps
}, refProp) { }, refProp) {
const [mentionOpen, setMentionOpen] = useState(false) const [mentionOpen, setMentionOpen] = useState(false)
@ -96,9 +102,12 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const before = value.slice(0, start) const before = value.slice(0, start)
const after = value.slice(end) const after = value.slice(end)
if (id === NEVENT_NADDR_PICKER_ID && neventPicker) { const openPicker = onOpenNeventPicker ?? neventPicker?.openNeventPicker
if (id === NEVENT_NADDR_PICKER_ID && openPicker) {
closeMention() closeMention()
neventPicker.openNeventPicker((link: string) => { const initialMode: PickerSearchMode =
mentionQuery.trim().toLowerCase().startsWith('naddr') ? 'naddr' : 'nevent'
openPicker((link: string) => {
const insert = link + ' ' const insert = link + ' '
onChange(before + insert + after) onChange(before + insert + after)
setTimeout(() => { setTimeout(() => {
@ -106,7 +115,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const newPos = start + insert.length const newPos = start + insert.length
ta.setSelectionRange(newPos, newPos) ta.setSelectionRange(newPos, newPos)
}, 0) }, 0)
}) }, initialMode)
return return
} }
@ -119,7 +128,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
ta.setSelectionRange(newPos, newPos) ta.setSelectionRange(newPos, newPos)
}, 0) }, 0)
}, },
[value, mentionStart, onChange, closeMention, neventPicker, findMentionSegmentEnd] [value, mentionStart, onChange, closeMention, onOpenNeventPicker, neventPicker, findMentionSegmentEnd]
) )
const insertEmoji = useCallback( const insertEmoji = useCallback(
@ -156,8 +165,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
} }
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
client searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT)
.searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT)
.then((npubs) => { .then((npubs) => {
const q = mentionQueryRef.current.trim().toLowerCase() const q = mentionQueryRef.current.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {

21
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { forwardRef, useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -30,8 +30,24 @@ import GifPicker from '@/components/GifPicker'
import EmojiPickerDialog from '@/components/EmojiPickerDialog' import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import Uploader from '@/components/PostEditor/Uploader' import Uploader from '@/components/PostEditor/Uploader'
import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import { MentionAndEventToolbarButtons } from '@/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons'
/** Wraps the textarea so it receives the nevent/naddr picker from context (must be rendered inside NeventPickerProvider). */
const ThreadContentTextarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<typeof TextareaWithMentionAutocomplete>>(
function ThreadContentTextarea(props, ref) {
const neventPicker = useNeventPicker()
return (
<TextareaWithMentionAutocomplete
ref={ref}
{...props}
onOpenNeventPicker={neventPicker?.openNeventPicker}
/>
)
}
)
// Utility functions for thread creation // Utility functions for thread creation
function extractImagesFromContent(content: string): string[] { function extractImagesFromContent(content: string): string[] {
@ -745,8 +761,9 @@ export default function CreateThreadDialog({
{t('Insert emoji')} {t('Insert emoji')}
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
<MentionAndEventToolbarButtons insertAtCursor={insertAtCursor} />
</div> </div>
<TextareaWithMentionAutocomplete <ThreadContentTextarea
ref={contentTextareaRef} ref={contentTextareaRef}
id="content" id="content"
value={content} value={content}

2
src/providers/NotificationProvider.tsx

@ -226,7 +226,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
if (isMountedRef.current) { if (isMountedRef.current) {
setTimeout(() => { setTimeout(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
logger.info('[NotificationProvider] Reconnecting after close...') logger.debug('[NotificationProvider] Reconnecting after close...')
subscribe() subscribe()
} }
}, 15_000) // Increased from 5s to 15s }, 15_000) // Increased from 5s to 15s

21
src/services/client.service.ts

@ -1536,6 +1536,27 @@ class ClientService extends EventTarget {
// Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere // Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere
} }
/**
* Return events from session cache whose kind is in the allowed set and content/tags match the query (case-insensitive).
* Used by mention-event-search.service for cache-first event search (nevent/naddr picker).
*/
getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds: number[]): NEvent[] {
const q = query.trim().toLowerCase()
if (!q || allowedKinds.length === 0) return []
const kindSet = new Set(allowedKinds)
const out: NEvent[] = []
const values = [...this.sessionEventCache.values()]
for (const evt of values) {
if (out.length >= limit) break
if (!kindSet.has(evt.kind)) continue
const content = (evt.content ?? '').toLowerCase()
const tagsStr = (evt.tags ?? []).flat().join(' ').toLowerCase()
if (!content.includes(q) && !tagsStr.includes(q)) continue
out.push(evt)
}
return out
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> { private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
const event = await this.fetchEventFromBigRelaysDataloader.load(id) const event = await this.fetchEventFromBigRelaysDataloader.load(id)
if (event) { if (event) {

48
src/services/indexed-db.service.ts

@ -972,6 +972,54 @@ class IndexedDbService {
}) })
} }
/**
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags
* match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first.
*/
async getCachedEventsForSearch(query: string, limit: number, allowedKinds: number[]): Promise<Event[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return []
}
const q = query.trim().toLowerCase()
if (!q || allowedKinds.length === 0) return []
const kindSet = new Set(allowedKinds)
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.openCursor()
const results: Event[] = []
request.onsuccess = () => {
const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || results.length >= limit) {
transaction.commit()
resolve(results)
return
}
const item = cursor.value as TValue<Event> | undefined
if (item?.value) {
const event = item.value as Event
if (kindSet.has(event.kind)) {
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
results.push(event)
}
}
}
cursor.continue()
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> { async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts // For publication stores, only return master events with nested counts
await this.initPromise await this.initPromise

103
src/services/mention-event-search.service.ts

@ -0,0 +1,103 @@
/**
* Unified search for mentions (npubs) and event/note picker (nevent/naddr).
* Both use the same pattern: cache first, then IndexedDB, then relays, up to limit.
*/
import { ExtendedKind, SEARCHABLE_RELAY_URLS } from '@/constants'
import { kinds, type Event as NEvent } from 'nostr-tools'
import client from './client.service'
import indexedDb from './indexed-db.service'
const DEFAULT_NOTES_LIMIT = 20
const DEFAULT_NPUBS_LIMIT = 100
/** Kinds for nevent search: notes, threads, long-form, etc. */
export const NEVENT_KINDS = [
kinds.ShortTextNote,
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.POLL,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.DISCUSSION,
ExtendedKind.CITATION_INTERNAL,
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT,
] as const
/** Kinds for naddr search: calendar, publications, wiki, etc. */
export const NADDR_KINDS = [
ExtendedKind.CALENDAR_EVENT_DATE,
ExtendedKind.CALENDAR_EVENT_TIME,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.PUBLICATION_CONTENT,
kinds.LongFormArticle,
] as const
export type PickerSearchMode = 'nevent' | 'naddr'
/**
* Search for events: session cache IndexedDB relays. Merges and dedupes by event id, up to limit.
* @param mode - 'nevent' uses NEVENT_KINDS (1,11,20,21,22,9802), 'naddr' uses NADDR_KINDS (30023,30817,30818,30040).
*/
export async function searchEventsForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT,
mode: PickerSearchMode = 'nevent'
): Promise<NEvent[]> {
const q = query.trim()
if (!q) return []
const kindsList = mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS]
const seen = new Set<string>()
const out: NEvent[] = []
const addUnique = (evt: NEvent) => {
if (seen.has(evt.id)) return
seen.add(evt.id)
out.push(evt)
}
const fromSession = client.getSessionEventsMatchingSearch(q, limit, kindsList)
fromSession.forEach(addUnique)
if (out.length >= limit) return out.slice(0, limit)
const fromIdb = await indexedDb.getCachedEventsForSearch(q, limit - out.length, kindsList)
fromIdb.forEach(addUnique)
if (out.length >= limit) return out.slice(0, limit)
const fromRelays = await client.fetchEvents(
SEARCHABLE_RELAY_URLS,
{ kinds: kindsList, search: q, limit: limit - out.length },
{ eoseTimeout: 5000, globalTimeout: 8000 }
)
fromRelays.forEach(addUnique)
return out.slice(0, limit)
}
/**
* @deprecated Use searchEventsForPicker(query, limit, 'nevent') instead.
*/
export async function searchNotesForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT
): Promise<NEvent[]> {
return searchEventsForPicker(query, limit, 'nevent')
}
/**
* Search for npubs for @-mentions. Uses same pattern as note search: cache (follow + local index) then relays.
* Delegates to client which already does follow-list local index relay search.
*/
export async function searchNpubsForMention(
query: string,
limit: number = DEFAULT_NPUBS_LIMIT
): Promise<string[]> {
return client.searchNpubsForMention(query, limit)
}
Loading…
Cancel
Save