Browse Source

implement inline search modal

include follows in the mention search
imwald
Silberengel 1 month 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. 27
      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 @@ -355,7 +355,7 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve
return
}
e.stopPropagation()
// Navigate to the note view
client.addEventToCache(event)
const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl)
}}

2
src/components/Note/LongFormArticlePreview.tsx

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

3
src/components/Note/index.tsx

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

3
src/components/NoteCard/MainNoteCard.tsx

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

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

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

3
src/components/PostEditor/PostContent.tsx

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

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

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

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

@ -2,16 +2,56 @@ import { useFetchProfile } from '@/hooks' @@ -2,16 +2,56 @@ import { useFetchProfile } from '@/hooks'
import { formatUserId } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
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 }) {
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 (
<NodeViewWrapper
className={cn('inline text-primary', props.selected ? 'bg-primary/20 rounded-sm' : '')}
>
{'@'}
{profile ? profile.username : formatUserId(props.node.attrs.id)}
{label}
</NodeViewWrapper>
)
}

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

@ -0,0 +1,189 @@ @@ -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 @@ @@ -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' @@ -5,15 +5,44 @@ import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
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 = {
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 }) => {
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')) {
return []
return [NEVENT_NADDR_PICKER_ID]
}
const result = await client.searchNpubsFromLocal(query, 20)
const result = await client.searchNpubsForMention(query, 20)
return result ?? []
},

24
src/components/ProfileListBySearch/index.tsx

@ -1,5 +1,8 @@ @@ -1,5 +1,8 @@
import { useSecondaryPage } from '@/PageManager'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { toProfile } from '@/lib/link'
import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import dayjs from 'dayjs'
import { useEffect, useRef, useState } from 'react'
import UserItem, { UserItemSkeleton } from '../UserItem'
@ -7,6 +10,7 @@ import UserItem, { UserItemSkeleton } from '../UserItem' @@ -7,6 +10,7 @@ import UserItem, { UserItemSkeleton } from '../UserItem'
const LIMIT = 50
export function ProfileListBySearch({ search }: { search: string }) {
const { push } = useSecondaryPage()
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
@ -67,7 +71,25 @@ export function ProfileListBySearch({ search }: { search: string }) { @@ -67,7 +71,25 @@ export function ProfileListBySearch({ search }: { search: string }) {
return (
<div className="px-4">
{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 && <div ref={bottomRef} />}

2
src/components/RelayInfo/RelayReviewCard.tsx

@ -2,6 +2,7 @@ import { useSmartNoteNavigation } from '@/PageManager' @@ -2,6 +2,7 @@ import { useSmartNoteNavigation } from '@/PageManager'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ClientTag from '../ClientTag'
@ -31,6 +32,7 @@ export default function RelayReviewCard({ @@ -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]')) {
return
}
client.addEventToCache(event)
navigateToNote(toNote(event))
}}
>

2
src/components/ReplyNote/index.tsx

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

7
src/components/SearchBar/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import SearchInput from '@/components/SearchInput'
import { useSearchProfiles } from '@/hooks'
import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service'
import { randomString } from '@/lib/random'
import { normalizeUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser'
@ -90,12 +91,18 @@ const SearchBar = forwardRef< @@ -90,12 +91,18 @@ const SearchBar = forwardRef<
blur()
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))
} else if (params.type === 'hashtag') {
navigateToHashtag(toNoteList({ hashtag: params.search }))
} else if (params.type === 'dtag') {
// Navigate to d-tag search using same pattern as hashtag
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 {
onSearch(params)
}

33
src/components/TextareaWithMentionAutocomplete/index.tsx

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

3
src/i18n/locales/de.ts

@ -42,6 +42,9 @@ export default { @@ -42,6 +42,9 @@ export default {
'Write something...': 'Schreibe etwas...',
Cancel: 'Abbrechen',
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',
'Post successful': 'Beitrag erfolgreich',
'Your post has been published': 'Dein Beitrag wurde veröffentlicht',

3
src/i18n/locales/en.ts

@ -42,6 +42,9 @@ export default { @@ -42,6 +42,9 @@ export default {
'Write something...': 'Write something...',
Cancel: 'Cancel',
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',
'Post successful': 'Post successful',
'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' @@ -29,6 +29,7 @@ import RelayIcon from '@/components/RelayIcon'
import GifPicker from '@/components/GifPicker'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import Uploader from '@/components/PostEditor/Uploader'
import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
import logger from '@/lib/logger'
// Utility functions for thread creation
@ -516,6 +517,7 @@ export default function CreateThreadDialog({ @@ -516,6 +517,7 @@ export default function CreateThreadDialog({
className="absolute inset-0 pointer-events-none"
aria-hidden
/>
<NeventPickerProvider>
<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">
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle>
@ -1035,6 +1037,7 @@ export default function CreateThreadDialog({ @@ -1035,6 +1037,7 @@ export default function CreateThreadDialog({
</form>
</CardContent>
</Card>
</NeventPickerProvider>
</div>
)
}

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

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

69
src/services/client.service.ts

@ -1808,6 +1808,75 @@ class ClientService extends EventTarget { @@ -1808,6 +1808,75 @@ class ClientService extends EventTarget {
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) {
const npubs = await this.searchNpubsFromLocal(query, limit)
const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub)))

Loading…
Cancel
Save