Browse Source

implement inline search modal

include follows in the mention search
imwald
Silberengel 2 months ago
parent
commit
446fd8772b
  1. 2
      src/components/Embedded/EmbeddedNote.tsx
  2. 2
      src/components/Note/LongFormArticlePreview.tsx
  3. 3
      src/components/Note/index.tsx
  4. 3
      src/components/NoteCard/MainNoteCard.tsx
  5. 2
      src/components/NotificationList/NotificationItem/Notification.tsx
  6. 3
      src/components/PostEditor/PostContent.tsx
  7. 17
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  8. 44
      src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx
  9. 189
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  10. 2
      src/components/PostEditor/PostTextarea/Mention/constants.ts
  11. 35
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  12. 24
      src/components/ProfileListBySearch/index.tsx
  13. 2
      src/components/RelayInfo/RelayReviewCard.tsx
  14. 2
      src/components/ReplyNote/index.tsx
  15. 7
      src/components/SearchBar/index.tsx
  16. 33
      src/components/TextareaWithMentionAutocomplete/index.tsx
  17. 3
      src/i18n/locales/de.ts
  18. 3
      src/i18n/locales/en.ts
  19. 3
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  20. 3
      src/pages/secondary/NotePage/index.tsx
  21. 69
      src/services/client.service.ts

2
src/components/Embedded/EmbeddedNote.tsx

@ -355,7 +355,7 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve
return return
} }
e.stopPropagation() e.stopPropagation()
// Navigate to the note view client.addEventToCache(event)
const noteUrl = toNote(originalNoteId ?? event) const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl) navigateToNote(noteUrl)
}} }}

2
src/components/Note/LongFormArticlePreview.tsx

@ -1,6 +1,7 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import client from '@/services/client.service'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -21,6 +22,7 @@ export default function LongFormArticlePreview({
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
client.addEventToCache(event)
push(toNote(event.id)) push(toNote(event.id))
} }

3
src/components/Note/index.tsx

@ -3,6 +3,7 @@ import { ExtendedKind, SUPPORTED_KINDS } from '@/constants'
import { getParentBech32Id, isNsfwEvent } from '@/lib/event' import { getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -260,6 +261,7 @@ export default function Note({
return return
} }
e.stopPropagation() e.stopPropagation()
client.addEventToCache(event)
navigateToNote(toNote(event)) navigateToNote(toNote(event))
}} }}
> >
@ -291,6 +293,7 @@ export default function Note({
className="p-1 hover:bg-muted rounded transition-colors" className="p-1 hover:bg-muted rounded transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
client.addEventToCache(event)
navigateToNote(toNote(event)) navigateToNote(toNote(event))
}} }}
title="View in Discussions" title="View in Discussions"

3
src/components/NoteCard/MainNoteCard.tsx

@ -1,6 +1,7 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import client from '@/services/client.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import Note from '../Note' import Note from '../Note'
@ -41,7 +42,7 @@ export default function MainNoteCard({
return return
} }
e.stopPropagation() e.stopPropagation()
// Ensure navigation happens immediately client.addEventToCache(event)
const noteUrl = toNote(originalNoteId ?? event) const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl) navigateToNote(noteUrl)
}} }}

2
src/components/NotificationList/NotificationItem/Notification.tsx

@ -6,6 +6,7 @@ import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { NOTIFICATION_LIST_STYLE } from '@/constants' import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { toNote, toProfile } from '@/lib/link' import { toNote, toProfile } from '@/lib/link'
import client from '@/services/client.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -72,6 +73,7 @@ export default function Notification({
markNotificationAsRead(notificationId) markNotificationAsRead(notificationId)
if (targetEvent) { if (targetEvent) {
client.addEventToCache(targetEvent)
navigateToNote(toNote(targetEvent.id)) navigateToNote(toNote(targetEvent.id))
} else if (pubkey) { } else if (pubkey) {
push(toProfile(pubkey)) push(toProfile(pubkey))

3
src/components/PostEditor/PostContent.tsx

@ -60,6 +60,7 @@ import PollEditor from './PollEditor'
import PostOptions from './PostOptions' 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 Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
@ -1959,6 +1960,7 @@ export default function PostContent({
</div> </div>
)} )}
<NeventPickerProvider>
<PostTextarea <PostTextarea
ref={textareaRef} ref={textareaRef}
text={text} text={text}
@ -2101,6 +2103,7 @@ export default function PostContent({
</> </>
} }
/> />
</NeventPickerProvider>
{isPoll && ( {isPoll && (
<PollEditor <PollEditor
pollCreateData={pollCreateData} pollCreateData={pollCreateData}

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

@ -3,9 +3,11 @@ import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
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 { NEVENT_NADDR_PICKER_ID } from './constants'
export interface MentionListProps { export interface MentionListProps {
items: string[] items: string[]
@ -22,6 +24,7 @@ export interface MentionListHandle {
} }
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => { const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const { t } = useTranslation()
const items = props.items ?? [] const items = props.items ?? []
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]')) const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0) const [internalIndex, setInternalIndex] = useState<number>(0)
@ -33,7 +36,11 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
const item = items[index] const item = items[index]
if (item) { if (item) {
props.command({ id: item, label: formatNpub(item) }) const label =
item === NEVENT_NADDR_PICKER_ID
? t('Search for event or address…')
: formatNpub(item)
props.command({ id: item, label })
} }
} }
@ -102,11 +109,19 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
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 ? (
<span className="text-muted-foreground text-sm">
{t('Search for event or address…')}
</span>
) : (
<>
<SimpleUserAvatar userId={item} /> <SimpleUserAvatar userId={item} />
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<SimpleUsername userId={item} className="font-semibold truncate" /> <SimpleUsername userId={item} className="font-semibold truncate" />
<Nip05 pubkey={userIdToPubkey(item)} /> <Nip05 pubkey={userIdToPubkey(item)} />
</div> </div>
</>
)}
</div> </div>
</button> </button>
))} ))}

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

@ -2,16 +2,56 @@ import { useFetchProfile } from '@/hooks'
import { formatUserId } from '@/lib/pubkey' import { formatUserId } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react' import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
import { useCallback } from 'react'
import { NEVENT_NADDR_PICKER_ID } from './constants'
import { useNeventPicker } from './NeventNaddrPickerDialog'
export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) { export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) {
const { profile } = useFetchProfile(props.node.attrs.id) const id = props.node.attrs.id as string
const isNeventNaddrPlaceholder = id === NEVENT_NADDR_PICKER_ID
const neventPicker = useNeventPicker()
const { profile } = useFetchProfile(isNeventNaddrPlaceholder ? '' : id)
const label =
isNeventNaddrPlaceholder
? (props.node.attrs.label as string) || 'event/address'
: profile
? profile.username
: formatUserId(id)
const handlePlaceholderClick = useCallback(() => {
const { editor, getPos, node } = props
const pos = typeof getPos === 'function' ? getPos() : undefined
if (pos === undefined || pos === null) return
neventPicker?.openNeventPicker((nostrLink: string) => {
const from = pos
const to = pos + node.nodeSize
editor.chain().focus().insertContentAt({ from, to }, nostrLink + ' ').run()
})
}, [props, neventPicker])
if (isNeventNaddrPlaceholder && neventPicker) {
return (
<NodeViewWrapper
className={cn(
'inline cursor-pointer text-primary underline decoration-dotted hover:bg-primary/10 rounded px-0.5',
props.selected ? 'bg-primary/20 rounded-sm' : ''
)}
>
<button type="button" onClick={handlePlaceholderClick} className="text-left">
{'@'}
{label}
</button>
</NodeViewWrapper>
)
}
return ( return (
<NodeViewWrapper <NodeViewWrapper
className={cn('inline text-primary', props.selected ? 'bg-primary/20 rounded-sm' : '')} className={cn('inline text-primary', props.selected ? 'bg-primary/20 rounded-sm' : '')}
> >
{'@'} {'@'}
{profile ? profile.username : formatUserId(props.node.attrs.id)} {label}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }

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

@ -0,0 +1,189 @@
import * as React from 'react'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { getNoteBech32Id } from '@/lib/event'
import client from '@/services/client.service'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { SimpleUsername } from '@/components/Username'
import { kinds, nip19, type Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, Search } from 'lucide-react'
import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT } from './suggestion'
type NeventNaddrPickerDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (nostrLink: string) => void
}
export function NeventNaddrPickerDialog({
open,
onOpenChange,
onSelect
}: NeventNaddrPickerDialogProps) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [events, setEvents] = useState<NEvent[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
setQuery('')
setDebouncedQuery('')
setEvents([])
}, [open])
useEffect(() => {
if (!open) return
const t = setTimeout(() => setDebouncedQuery(query.trim()), 300)
return () => clearTimeout(t)
}, [open, query])
useEffect(() => {
if (!open || !debouncedQuery) {
setEvents([])
setLoading(false)
return
}
let cancelled = false
setLoading(true)
client
.fetchEvents(SEARCHABLE_RELAY_URLS, { kinds: [kinds.ShortTextNote], search: debouncedQuery, limit: 20 }, { eoseTimeout: 5000, globalTimeout: 8000 })
.then((list) => {
if (cancelled) return
setEvents(list.slice(0, 15) as NEvent[])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, debouncedQuery])
const handleSelect = useCallback(
(event: NEvent) => {
client.addEventToCache(event)
try {
const bech32 = getNoteBech32Id(event)
onSelect(`nostr:${bech32}`)
onOpenChange(false)
} catch {
onSelect(`nostr:${nip19.noteEncode(event.id)}`)
onOpenChange(false)
}
},
[onSelect, onOpenChange]
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{t('Search for event or address…')}</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Search notes…')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
<ScrollArea className="flex-1 min-h-[200px] max-h-[40vh] border rounded-md">
<div className="p-2 space-y-1">
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
{!loading && debouncedQuery && events.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">{t('No notes found')}</p>
)}
{!loading &&
events.map((ev: NEvent) => (
<Button
key={ev.id}
variant="ghost"
className="w-full justify-start text-left h-auto py-2 px-3 font-normal"
onClick={() => handleSelect(ev)}
>
<div className="flex flex-col gap-0.5 min-w-0 w-full">
<SimpleUsername userId={ev.pubkey} className="text-xs text-muted-foreground truncate" />
<span className="text-sm line-clamp-2 break-words">{ev.content || t('(empty)')}</span>
</div>
</Button>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
type NeventPickerContextValue = {
openNeventPicker: (onSelected: (nostrLink: string) => void) => void
}
const NeventPickerContext = React.createContext<NeventPickerContextValue | null>(null)
export function useNeventPicker(): NeventPickerContextValue | null {
return React.useContext(NeventPickerContext)
}
export function NeventPickerProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null)
useEffect(() => {
const handler = (e: Event) => {
const { editor, range } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail
setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt(range, link + ' ').run()
})
setOpen(true)
}
window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler)
}, [])
const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void) => {
setOnSelectedRef(() => onSelected)
setOpen(true)
}, [])
const handleSelect = useCallback(
(link: string) => {
onSelectedRef?.(link)
setOnSelectedRef(null)
},
[onSelectedRef]
)
const handleOpenChange = useCallback((next: boolean) => {
if (!next) setOnSelectedRef(null)
setOpen(next)
}, [])
const value = React.useMemo(() => ({ openNeventPicker }), [openNeventPicker])
return (
<NeventPickerContext.Provider value={value}>
{children}
<NeventNaddrPickerDialog open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} />
</NeventPickerContext.Provider>
)
}

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

@ -0,0 +1,2 @@
/** Sentinel id when user types @nevent or @naddr; shows "Search for event or address" option. */
export const NEVENT_NADDR_PICKER_ID = '__nevent_naddr_picker__'

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

@ -5,15 +5,44 @@ import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js' import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import MentionList, { MentionListHandle, MentionListProps } from './MentionList' import MentionList, { MentionListHandle, MentionListProps } from './MentionList'
import { NEVENT_NADDR_PICKER_ID } from './constants'
export { NEVENT_NADDR_PICKER_ID } from './constants'
const MENTION_EXTENSION_NAME = 'mention'
const MENTION_CHAR = '@'
export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
const suggestion = { const suggestion = {
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => {
if (props.id === NEVENT_NADDR_PICKER_ID) {
postEditor.closeSuggestionPopup()
window.dispatchEvent(
new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { detail: { editor, range } })
)
return
}
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
const to = overrideSpace ? range.to + 1 : range.to
editor
.chain()
.focus()
.insertContentAt({ from: range.from, to }, [
{ type: MENTION_EXTENSION_NAME, attrs: { ...props, mentionSuggestionChar: MENTION_CHAR } },
{ type: 'text', text: ' ' }
])
.run()
editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
},
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
// Reserved for future nevent/naddr picker; don't treat as npub handle
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return [] return [NEVENT_NADDR_PICKER_ID]
} }
const result = await client.searchNpubsFromLocal(query, 20) const result = await client.searchNpubsForMention(query, 20)
return result ?? [] return result ?? []
}, },

24
src/components/ProfileListBySearch/index.tsx

@ -1,5 +1,8 @@
import { useSecondaryPage } from '@/PageManager'
import { SEARCHABLE_RELAY_URLS } from '@/constants' import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { toProfile } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import UserItem, { UserItemSkeleton } from '../UserItem' import UserItem, { UserItemSkeleton } from '../UserItem'
@ -7,6 +10,7 @@ import UserItem, { UserItemSkeleton } from '../UserItem'
const LIMIT = 50 const LIMIT = 50
export function ProfileListBySearch({ search }: { search: string }) { export function ProfileListBySearch({ search }: { search: string }) {
const { push } = useSecondaryPage()
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>()) const [pubkeySet, setPubkeySet] = useState(new Set<string>())
@ -67,7 +71,25 @@ export function ProfileListBySearch({ search }: { search: string }) {
return ( return (
<div className="px-4"> <div className="px-4">
{Array.from(pubkeySet).map((pubkey, index) => ( {Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <div
key={`${index}-${pubkey}`}
role="button"
tabIndex={0}
className={cn('rounded-lg clickable')}
onClick={() => {
client.fetchProfileEvent(pubkey).catch(() => {})
push(toProfile(pubkey))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
client.fetchProfileEvent(pubkey).catch(() => {})
push(toProfile(pubkey))
}
}}
>
<UserItem pubkey={pubkey} />
</div>
))} ))}
{hasMore && <UserItemSkeleton />} {hasMore && <UserItemSkeleton />}
{hasMore && <div ref={bottomRef} />} {hasMore && <div ref={bottomRef} />}

2
src/components/RelayInfo/RelayReviewCard.tsx

@ -2,6 +2,7 @@ import { useSmartNoteNavigation } from '@/PageManager'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
@ -31,6 +32,7 @@ export default function RelayReviewCard({
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) {
return return
} }
client.addEventToCache(event)
navigateToNote(toNote(event)) navigateToNote(toNote(event))
}} }}
> >

2
src/components/ReplyNote/index.tsx

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import client from '@/services/client.service'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -65,6 +66,7 @@ export default function ReplyNote({
if (onClickReply) { if (onClickReply) {
onClickReply(event) onClickReply(event)
} else { } else {
client.addEventToCache(event)
navigateToNote(toNote(event)) navigateToNote(toNote(event))
} }
}} }}

7
src/components/SearchBar/index.tsx

@ -1,6 +1,7 @@
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import { useSearchProfiles } from '@/hooks' import { useSearchProfiles } from '@/hooks'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser' import { normalizeToDTag } from '@/lib/search-parser'
@ -90,12 +91,18 @@ const SearchBar = forwardRef<
blur() blur()
if (params.type === 'note') { if (params.type === 'note') {
// Prime event cache so note page finds it without re-fetch
client.fetchEvent(params.search).then((ev) => { if (ev) client.addEventToCache(ev) }).catch(() => {})
navigateToNote(toNote(params.search)) navigateToNote(toNote(params.search))
} else if (params.type === 'hashtag') { } else if (params.type === 'hashtag') {
navigateToHashtag(toNoteList({ hashtag: params.search })) navigateToHashtag(toNoteList({ hashtag: params.search }))
} else if (params.type === 'dtag') { } else if (params.type === 'dtag') {
// Navigate to d-tag search using same pattern as hashtag // Navigate to d-tag search using same pattern as hashtag
navigateToHashtag(toNoteList({ domain: params.search })) navigateToHashtag(toNoteList({ domain: params.search }))
} else if (params.type === 'profile') {
// Prime profile cache so profile page finds it without re-fetch
client.fetchProfileEvent(params.search).catch(() => {})
onSearch(params)
} else { } else {
onSearch(params) onSearch(params)
} }

33
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -1,5 +1,7 @@
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 { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
import client from '@/services/client.service' import client from '@/services/client.service'
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
@ -31,6 +33,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement | null>(null) const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const neventPicker = useNeventPicker()
const closeMention = useCallback(() => { const closeMention = useCallback(() => {
setMentionOpen(false) setMentionOpen(false)
@ -39,14 +42,29 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
}, []) }, [])
const insertMention = useCallback( const insertMention = useCallback(
(npub: string) => { (id: string) => {
const ta = textareaRef.current const ta = textareaRef.current
if (!ta) return if (!ta) return
const start = mentionStart const start = mentionStart
const end = start + 1 + mentionQuery.length const end = start + 1 + mentionQuery.length
const before = value.slice(0, start) const before = value.slice(0, start)
const after = value.slice(end) const after = value.slice(end)
const insert = MENTION_INSERT_PREFIX + npub
if (id === NEVENT_NADDR_PICKER_ID && neventPicker) {
closeMention()
neventPicker.openNeventPicker((link: string) => {
const insert = link + ' '
onChange(before + insert + after)
setTimeout(() => {
ta.focus()
const newPos = start + insert.length
ta.setSelectionRange(newPos, newPos)
}, 0)
})
return
}
const insert = MENTION_INSERT_PREFIX + id
onChange(before + insert + after) onChange(before + insert + after)
closeMention() closeMention()
setTimeout(() => { setTimeout(() => {
@ -55,7 +73,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
ta.setSelectionRange(newPos, newPos) ta.setSelectionRange(newPos, newPos)
}, 0) }, 0)
}, },
[value, mentionStart, mentionQuery.length, onChange, closeMention] [value, mentionStart, mentionQuery.length, onChange, closeMention, neventPicker]
) )
useEffect(() => { useEffect(() => {
@ -66,14 +84,15 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
} }
const q = mentionQuery.trim().toLowerCase() const q = mentionQuery.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
setMentionItems([]) setMentionItems([NEVENT_NADDR_PICKER_ID])
setMentionOpen(false) setMentionOpen(true)
setSelectedIndex(0)
return return
} }
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
client client
.searchNpubsFromLocal(mentionQuery.trim(), MENTION_LIMIT) .searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT)
.then((npubs) => { .then((npubs) => {
const list = npubs ?? [] const list = npubs ?? []
setMentionItems(list) setMentionItems(list)
@ -158,7 +177,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
<div className="absolute left-0 right-0 top-full z-50 mt-1" role="listbox"> <div className="absolute left-0 right-0 top-full z-50 mt-1" role="listbox">
<MentionList <MentionList
items={mentionItems} items={mentionItems}
command={({ id }) => insertMention(id)} command={({ id }) => insertMention(id as string)}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelectIndex={setSelectedIndex} onSelectIndex={setSelectedIndex}
/> />

3
src/i18n/locales/de.ts

@ -42,6 +42,9 @@ export default {
'Write something...': 'Schreibe etwas...', 'Write something...': 'Schreibe etwas...',
Cancel: 'Abbrechen', Cancel: 'Abbrechen',
Mentions: '@', Mentions: '@',
'Search for event or address…': 'Nach Event oder Adresse suchen…',
'Search notes…': 'Notizen suchen…',
'No notes found': 'Keine Notizen gefunden',
'Failed to post': 'Posten fehlgeschlagen', 'Failed to post': 'Posten fehlgeschlagen',
'Post successful': 'Beitrag erfolgreich', 'Post successful': 'Beitrag erfolgreich',
'Your post has been published': 'Dein Beitrag wurde veröffentlicht', 'Your post has been published': 'Dein Beitrag wurde veröffentlicht',

3
src/i18n/locales/en.ts

@ -42,6 +42,9 @@ export default {
'Write something...': 'Write something...', 'Write something...': 'Write something...',
Cancel: 'Cancel', Cancel: 'Cancel',
Mentions: 'Mentions', Mentions: 'Mentions',
'Search for event or address…': 'Search for event or address…',
'Search notes…': 'Search notes…',
'No notes found': 'No notes found',
'Failed to post': 'Failed to post', 'Failed to post': 'Failed to post',
'Post successful': 'Post successful', 'Post successful': 'Post successful',
'Your post has been published': 'Your post has been published', 'Your post has been published': 'Your post has been published',

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

@ -29,6 +29,7 @@ import RelayIcon from '@/components/RelayIcon'
import GifPicker from '@/components/GifPicker' 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 logger from '@/lib/logger' import logger from '@/lib/logger'
// Utility functions for thread creation // Utility functions for thread creation
@ -516,6 +517,7 @@ export default function CreateThreadDialog({
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
aria-hidden aria-hidden
/> />
<NeventPickerProvider>
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background"> <Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle> <CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle>
@ -1035,6 +1037,7 @@ export default function CreateThreadDialog({
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</NeventPickerProvider>
</div> </div>
) )
} }

3
src/pages/secondary/NotePage/index.tsx

@ -1,6 +1,7 @@
import { useSecondaryPage, useSmartNoteNavigation } from '@/PageManager' import { useSecondaryPage, useSmartNoteNavigation } from '@/PageManager'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import client from '@/services/client.service'
import Note from '@/components/Note' import Note from '@/components/Note'
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
@ -559,6 +560,7 @@ function ParentNote({
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (event) client.addEventToCache(event)
navigateToNote(toNote(event ?? eventBech32Id)) navigateToNote(toNote(event ?? eventBech32Id))
}} }}
> >
@ -567,6 +569,7 @@ function ParentNote({
className="truncate flex-1" className="truncate flex-1"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (event) client.addEventToCache(event)
navigateToNote(toNote(event ?? eventBech32Id)) navigateToNote(toNote(event ?? eventBech32Id))
}} }}
> >

69
src/services/client.service.ts

@ -1808,6 +1808,75 @@ class ClientService extends EventTarget {
return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[] return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[]
} }
/**
* Npubs for @-mention dropdown: (1) follow-list profiles matching the query,
* (2) local index, (3) relay search on SEARCHABLE_RELAY_URLS (same as search page).
*/
async searchNpubsForMention(query: string, limit: number = 100): Promise<string[]> {
const q = query.trim()
const qLower = q.toLowerCase()
const addedNpubs = new Set<string>()
const out: string[] = []
if (this.pubkey && qLower.length >= 1) {
try {
const followListEvent = await this.fetchFollowListEvent(this.pubkey)
const followPubkeys = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
const toCheck = followPubkeys.slice(0, 80)
const profiles = await Promise.all(
toCheck.map((pubkey) => {
const npub = pubkeyToNpub(pubkey)
return npub ? this.fetchProfile(npub) : Promise.resolve(undefined)
})
)
const matchText = (p: TProfile) =>
((p.username ?? '') + ' ' + (p.original_username ?? '') + ' ' + (p.nip05 ?? '')).toLowerCase()
for (const p of profiles) {
if (!p) continue
const npub = p.npub || pubkeyToNpub(p.pubkey)
if (!npub || addedNpubs.has(npub)) continue
if (!matchText(p).includes(qLower)) continue
addedNpubs.add(npub)
out.push(npub)
if (out.length >= limit) return out
}
} catch {
// ignore follow-list errors; fall back to local + relay
}
}
const local = await this.searchNpubsFromLocal(q, limit)
for (const npub of local) {
if (addedNpubs.has(npub)) continue
addedNpubs.add(npub)
out.push(npub)
if (out.length >= limit) return out
}
if (out.length < limit && q.length >= 1) {
try {
const relayProfiles = await this.searchProfiles(SEARCHABLE_RELAY_URLS, {
search: q,
limit: limit - out.length
})
for (const p of relayProfiles) {
const npub = pubkeyToNpub(p.pubkey)
if (!npub || addedNpubs.has(npub)) continue
addedNpubs.add(npub)
out.push(npub)
if (out.length >= limit) break
}
} catch {
// relay search is best-effort
}
}
// Prime profile cache so we can find everyone again that we have already found once
out.forEach((npub) => {
this.fetchProfileEvent(npub).catch(() => {})
})
return out
}
async searchProfilesFromLocal(query: string, limit: number = 100) { async searchProfilesFromLocal(query: string, limit: number = 100) {
const npubs = await this.searchNpubsFromLocal(query, limit) const npubs = await this.searchNpubsFromLocal(query, limit)
const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub))) const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub)))

Loading…
Cancel
Save