From 446fd8772b2a8b47c76a57746dfec29d0f8e837d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Mar 2026 13:16:29 +0100 Subject: [PATCH] implement inline search modal include follows in the mention search --- src/components/Embedded/EmbeddedNote.tsx | 2 +- .../Note/LongFormArticlePreview.tsx | 2 + src/components/Note/index.tsx | 3 + src/components/NoteCard/MainNoteCard.tsx | 3 +- .../NotificationItem/Notification.tsx | 2 + src/components/PostEditor/PostContent.tsx | 3 + .../PostTextarea/Mention/MentionList.tsx | 27 ++- .../PostTextarea/Mention/MentionNode.tsx | 44 +++- .../Mention/NeventNaddrPickerDialog.tsx | 189 ++++++++++++++++++ .../PostTextarea/Mention/constants.ts | 2 + .../PostTextarea/Mention/suggestion.ts | 35 +++- src/components/ProfileListBySearch/index.tsx | 24 ++- src/components/RelayInfo/RelayReviewCard.tsx | 2 + src/components/ReplyNote/index.tsx | 2 + src/components/SearchBar/index.tsx | 7 + .../TextareaWithMentionAutocomplete/index.tsx | 33 ++- src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + .../DiscussionsPage/CreateThreadDialog.tsx | 3 + src/pages/secondary/NotePage/index.tsx | 3 + src/services/client.service.ts | 69 +++++++ 21 files changed, 440 insertions(+), 21 deletions(-) create mode 100644 src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx create mode 100644 src/components/PostEditor/PostTextarea/Mention/constants.ts diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index ef5bba1a..9b9cb6d7 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -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) }} diff --git a/src/components/Note/LongFormArticlePreview.tsx b/src/components/Note/LongFormArticlePreview.tsx index 6f59c1a9..dd7d84a3 100644 --- a/src/components/Note/LongFormArticlePreview.tsx +++ b/src/components/Note/LongFormArticlePreview.tsx @@ -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({ const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() + client.addEventToCache(event) push(toNote(event.id)) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 77fc4968..90d55ea7 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({ return } e.stopPropagation() + client.addEventToCache(event) navigateToNote(toNote(event)) }} > @@ -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" diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 1848e439..ceb83a31 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -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({ return } e.stopPropagation() - // Ensure navigation happens immediately + client.addEventToCache(event) const noteUrl = toNote(originalNoteId ?? event) navigateToNote(noteUrl) }} diff --git a/src/components/NotificationList/NotificationItem/Notification.tsx b/src/components/NotificationList/NotificationItem/Notification.tsx index a223e005..3012a54b 100644 --- a/src/components/NotificationList/NotificationItem/Notification.tsx +++ b/src/components/NotificationList/NotificationItem/Notification.tsx @@ -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({ markNotificationAsRead(notificationId) if (targetEvent) { + client.addEventToCache(targetEvent) navigateToNote(toNote(targetEvent.id)) } else if (pubkey) { push(toProfile(pubkey)) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index b1667cff..d4fef91e 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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({ )} + } /> + {isPoll && ( ((props, ref) => { + const { t } = useTranslation() const items = props.items ?? [] const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]')) const [internalIndex, setInternalIndex] = useState(0) @@ -33,7 +36,11 @@ const MentionList = forwardRef((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((props, ref) onMouseEnter={() => setSelectedIndex(index)} >
- -
- - -
+ {item === NEVENT_NADDR_PICKER_ID ? ( + + {t('Search for event or address…')} + + ) : ( + <> + +
+ + +
+ + )}
))} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx index bfcf6195..bddf4713 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx @@ -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 ( + + + + ) + } return ( {'@'} - {profile ? profile.username : formatUserId(props.node.attrs.id)} + {label} ) } diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx new file mode 100644 index 00000000..ae223c51 --- /dev/null +++ b/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([]) + 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 ( + + + + {t('Search for event or address…')} + +
+ + setQuery(e.target.value)} + className="pl-9" + autoFocus + /> +
+ +
+ {loading && ( +
+ +
+ )} + {!loading && debouncedQuery && events.length === 0 && ( +

{t('No notes found')}

+ )} + {!loading && + events.map((ev: NEvent) => ( + + ))} +
+
+
+
+ ) +} + +type NeventPickerContextValue = { + openNeventPicker: (onSelected: (nostrLink: string) => void) => void +} + +const NeventPickerContext = React.createContext(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 ( + + {children} + + + ) +} + diff --git a/src/components/PostEditor/PostTextarea/Mention/constants.ts b/src/components/PostEditor/PostTextarea/Mention/constants.ts new file mode 100644 index 00000000..8a8d753e --- /dev/null +++ b/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__' diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index a742f7ab..94e164ce 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -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 ?? [] }, diff --git a/src/components/ProfileListBySearch/index.tsx b/src/components/ProfileListBySearch/index.tsx index 9c74bd2c..be9a3039 100644 --- a/src/components/ProfileListBySearch/index.tsx +++ b/src/components/ProfileListBySearch/index.tsx @@ -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' const LIMIT = 50 export function ProfileListBySearch({ search }: { search: string }) { + const { push } = useSecondaryPage() const [until, setUntil] = useState(() => dayjs().unix()) const [hasMore, setHasMore] = useState(true) const [pubkeySet, setPubkeySet] = useState(new Set()) @@ -67,7 +71,25 @@ export function ProfileListBySearch({ search }: { search: string }) { return (
{Array.from(pubkeySet).map((pubkey, index) => ( - +
{ + client.fetchProfileEvent(pubkey).catch(() => {}) + push(toProfile(pubkey)) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + client.fetchProfileEvent(pubkey).catch(() => {}) + push(toProfile(pubkey)) + } + }} + > + +
))} {hasMore && } {hasMore &&
} diff --git a/src/components/RelayInfo/RelayReviewCard.tsx b/src/components/RelayInfo/RelayReviewCard.tsx index 7441bcfa..4d414713 100644 --- a/src/components/RelayInfo/RelayReviewCard.tsx +++ b/src/components/RelayInfo/RelayReviewCard.tsx @@ -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({ 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)) }} > diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index c0be38ef..f364e4c8 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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({ if (onClickReply) { onClickReply(event) } else { + client.addEventToCache(event) navigateToNote(toNote(event)) } }} diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index 06d0396c..eb1d07a2 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -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< 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) } diff --git a/src/components/TextareaWithMentionAutocomplete/index.tsx b/src/components/TextareaWithMentionAutocomplete/index.tsx index 191ac929..ce677e35 100644 --- a/src/components/TextareaWithMentionAutocomplete/index.tsx +++ b/src/components/TextareaWithMentionAutocomplete/index.tsx @@ -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(null) const searchTimeoutRef = useRef | null>(null) + const neventPicker = useNeventPicker() const closeMention = useCallback(() => { setMentionOpen(false) @@ -39,14 +42,29 @@ const TextareaWithMentionAutocomplete = forwardRef { + (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 { @@ -66,14 +84,15 @@ const TextareaWithMentionAutocomplete = forwardRef { 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 insertMention(id)} + command={({ id }) => insertMention(id as string)} selectedIndex={selectedIndex} onSelectIndex={setSelectedIndex} /> diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index a18e7c6d..ca1b2d5e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fe0cad2f..fd98e52f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 1446585b..3b51b62f 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -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({ className="absolute inset-0 pointer-events-none" aria-hidden /> + {t('Create New Thread')} @@ -1035,6 +1037,7 @@ export default function CreateThreadDialog({ +
) } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index c8f556be..0e3860a5 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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({ )} onClick={(e) => { e.stopPropagation() + if (event) client.addEventToCache(event) navigateToNote(toNote(event ?? eventBech32Id)) }} > @@ -567,6 +569,7 @@ function ParentNote({ className="truncate flex-1" onClick={(e) => { e.stopPropagation() + if (event) client.addEventToCache(event) navigateToNote(toNote(event ?? eventBech32Id)) }} > diff --git a/src/services/client.service.ts b/src/services/client.service.ts index fc359546..5c3514e7 100644 --- a/src/services/client.service.ts +++ b/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[] } + /** + * 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 { + const q = query.trim() + const qLower = q.toLowerCase() + const addedNpubs = new Set() + 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)))