diff --git a/src/PageManager.tsx b/src/PageManager.tsx index f0887166..a6ba1207 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -81,6 +81,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const { enableSingleColumnLayout } = useUserPreferences() const ignorePopStateRef = useRef(false) + useEffect(() => { + if (isSmallScreen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + navigatePrimaryPage('search') + } + } + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [isSmallScreen]) + useEffect(() => { if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { window.history.replaceState( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index e1e142c1..d837cea5 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -284,15 +284,22 @@ const NoteList = forwardRef< return () => {} } - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests.map(({ urls, filter }) => ({ - urls, - filter: { - kinds: showKinds ?? [], - ...filter, - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + const preprocessedSubRequests = await Promise.all( + subRequests.map(async ({ urls, filter }) => { + const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) + return { + urls: relays, + filter: { + kinds: showKinds ?? [], + ...filter, + limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + } } - })), + }) + ) + + const { closer, timelineKey } = await client.subscribeTimeline( + preprocessedSubRequests, { onEvents: (events, eosed) => { if (events.length > 0) { diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index 87211ec6..1111cae8 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 { toExternalContent, toNote } from '@/lib/link' +import { formatFeedRequest, parseNakReqCommand } from '@/lib/nak-parser' import { randomString } from '@/lib/random' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' @@ -8,7 +9,7 @@ import { useSecondaryPage } from '@/PageManager' import { useScreenSize } from '@/providers/ScreenSizeProvider' import modalManager from '@/services/modal-manager.service' import { TSearchParams } from '@/types' -import { Hash, MessageSquare, Notebook, Search, Server } from 'lucide-react' +import { Hash, MessageSquare, Notebook, Search, Server, Terminal } from 'lucide-react' import { nip19 } from 'nostr-tools' import { forwardRef, @@ -103,6 +104,20 @@ const SearchBar = forwardRef< const search = input.trim() if (!search) return + // Check if input is a nak req command + const request = parseNakReqCommand(search) + if (request) { + setSelectableOptions([ + { + type: 'nak', + search: formatFeedRequest(request), + request, + input: search + } + ]) + return + } + if (/^[0-9a-f]{64}$/.test(search)) { setSelectableOptions([ { type: 'note', search }, @@ -213,6 +228,16 @@ const SearchBar = forwardRef< /> ) } + if (option.type === 'nak') { + return ( + updateSearch(option)} + /> + ) + } if (option.type === 'profiles') { return ( void + selected?: boolean +}) { + return ( + +
+ +
+
+
REQ
+
{description}
+
+
+ ) +} + function Item({ className, children, diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index beaddaa9..520be28c 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -32,5 +32,8 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa /> ) } + if (searchParams.type === 'nak') { + return + } return } diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx index 372e65f3..737cb857 100644 --- a/src/components/UserAggregationList/index.tsx +++ b/src/components/UserAggregationList/index.tsx @@ -129,15 +129,22 @@ const UserAggregationList = forwardRef< return () => {} } - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests.map(({ urls, filter }) => ({ - urls, - filter: { - kinds: showKinds ?? [], - ...filter, - limit: LIMIT + const preprocessedSubRequests = await Promise.all( + subRequests.map(async ({ urls, filter }) => { + const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) + return { + urls: relays, + filter: { + kinds: showKinds ?? [], + ...filter, + limit: LIMIT + } } - })), + }) + ) + + const { closer, timelineKey } = await client.subscribeTimeline( + preprocessedSubRequests, { onEvents: (events, eosed) => { if (events.length > 0) { diff --git a/src/lib/nak-parser.ts b/src/lib/nak-parser.ts new file mode 100644 index 00000000..3cb451a2 --- /dev/null +++ b/src/lib/nak-parser.ts @@ -0,0 +1,253 @@ +import { TFeedSubRequest } from '@/types' +import { Filter } from 'nostr-tools' +import { decode } from 'nostr-tools/nip19' +import { normalizeUrl } from './url' + +/** + * Check if the input is a nak req command + */ +function isNakReqCommand(input: string): boolean { + return input.startsWith('nak req ') || input.startsWith('req ') +} + +/** + * Parse a nak req command and return filter and relays + * + * Supported options: + * --author, -a: only accept events from these authors (pubkey as hex) + * --id, -i: only accept events with these ids (hex) + * --kind, -k: only accept events with these kind numbers + * --search: a nip50 search query + * --tag, -t: takes a tag like -t e= + * -d: shortcut for --tag d= + * -e: shortcut for --tag e= + * -p: shortcut for --tag p= + * + * Remaining arguments are treated as relay URLs + */ +export function parseNakReqCommand(input: string): TFeedSubRequest | null { + const trimmed = input.trim() + if (!isNakReqCommand(trimmed)) { + return null + } + + // Remove "nak req " or "req " prefix + const argsString = trimmed.startsWith('nak') ? trimmed.slice(8).trim() : trimmed.slice(3).trim() + if (!argsString) { + return { filter: {}, urls: [] } + } + + const args = parseArgs(argsString) + const filter: Omit = {} + const relays: string[] = [] + + let i = 0 + while (i < args.length) { + const arg = args[i] + + // Handle options with values + if (arg === '--author' || arg === '-a') { + const value = args[++i] + const hexId = value ? parseHexId(value) : null + if (hexId) { + if (!filter.authors) filter.authors = [] + if (!filter.authors.includes(hexId)) { + filter.authors.push(hexId) + } + } + } else if (arg === '--id' || arg === '-i') { + const value = args[++i] + const hexId = value ? parseHexId(value) : null + if (hexId) { + if (!filter.ids) filter.ids = [] + if (!filter.ids.includes(hexId)) { + filter.ids.push(hexId) + } + } + } else if (arg === '--kind' || arg === '-k') { + const value = args[++i] + if (value && /^\d+$/.test(value)) { + const kind = parseInt(value, 10) + if (!filter.kinds) filter.kinds = [] + if (!filter.kinds.includes(kind)) { + filter.kinds.push(kind) + } + } + } else if (arg === '--search') { + const value = args[++i] + if (value) { + filter.search = value + } + } else if (arg === '--tag' || arg === '-t') { + const value = args[++i] + if (value) { + const [tagName, tagValue] = parseTagValue(value) + if (tagName && tagValue) { + const tagKey = `#${tagName}` + const filterRecord = filter as Record + if (!filterRecord[tagKey]) { + filterRecord[tagKey] = [] + } + if (!filterRecord[tagKey].includes(tagValue)) { + filterRecord[tagKey].push(tagValue) + } + } + } + } else if (arg === '-d') { + const value = args[++i] + if (value) { + if (!filter['#d']) filter['#d'] = [] + if (!filter['#d'].includes(value)) { + filter['#d'].push(value) + } + } + } else if (arg === '-e') { + const value = args[++i] + if (value && isValidHexId(value)) { + if (!filter['#e']) filter['#e'] = [] + if (!filter['#e'].includes(value)) { + filter['#e'].push(value) + } + } + } else if (arg === '-p') { + const value = args[++i] + if (value && isValidHexId(value)) { + if (!filter['#p']) filter['#p'] = [] + if (!filter['#p'].includes(value)) { + filter['#p'].push(value) + } + } + } else if (!arg.startsWith('-')) { + // Treat as relay URL + try { + const url = normalizeUrl(arg) + if (url.startsWith('wss://') || url.startsWith('ws://')) { + if (!relays.includes(url)) { + relays.push(url) + } + } + } catch { + // Ignore invalid URLs + } + } + + i++ + } + + return { filter, urls: relays } +} + +/** + * Parse command line arguments, handling quoted strings + */ +function parseArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let inQuote: string | null = null + + for (let i = 0; i < input.length; i++) { + const char = input[i] + + if (inQuote) { + if (char === inQuote) { + inQuote = null + } else { + current += char + } + } else if (char === '"' || char === "'") { + inQuote = char + } else if (char === ' ' || char === '\t') { + if (current) { + args.push(current) + current = '' + } + } else { + current += char + } + } + + if (current) { + args.push(current) + } + + return args +} + +/** + * Parse tag value in format "name=value" + */ +function parseTagValue(value: string): [string, string] | [null, null] { + const idx = value.indexOf('=') + if (idx === -1) { + return [null, null] + } + return [value.slice(0, idx), value.slice(idx + 1)] +} + +/** + * Check if a string is valid hex of specified length + */ +function isValidHexId(value: string): boolean { + return new RegExp(`^[0-9a-fA-F]{64}$`).test(value) +} + +function parseHexId(value: string): string | null { + if (isValidHexId(value)) { + return value + } + if (['nevent', 'note', 'npub', 'nprofile'].every((prefix) => !value.startsWith(prefix))) { + return null + } + + try { + const { type, data } = decode(value) + if (type === 'nevent') { + return data.id + } + if (type === 'note' || type === 'npub') { + return data + } + if (type === 'nprofile') { + return data.pubkey + } + return null + } catch { + return null + } +} + +/** + * Format a filter for display + */ +export function formatFeedRequest(request: TFeedSubRequest): string { + const parts: string[] = [] + + if (request.filter.kinds?.length) { + parts.push(`kinds: ${request.filter.kinds.join(', ')}`) + } + if (request.filter.authors?.length) { + parts.push(`authors: ${request.filter.authors.length}`) + } + if (request.filter.ids?.length) { + parts.push(`ids: ${request.filter.ids.length}`) + } + if (request.filter.search) { + parts.push(`search: "${request.filter.search}"`) + } + + // Check for tag filters + for (const key of Object.keys(request.filter)) { + if (key.startsWith('#')) { + const values = request.filter[key as keyof typeof request.filter] as string[] + if (values?.length) { + parts.push(`${key}: ${values.length}`) + } + } + } + + if (request.urls.length) { + parts.push(`relays: ${request.urls.length}`) + } + + return parts.join(' | ') || 'No filters' +} diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index 83779e24..84034cc1 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -3,6 +3,7 @@ import SearchResult from '@/components/SearchResult' import { Button } from '@/components/ui/button' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toSearch } from '@/lib/link' +import { parseNakReqCommand } from '@/lib/nak-parser' import { useSecondaryPage } from '@/PageManager' import { TSearchParams } from '@/types' import { ChevronLeft } from 'lucide-react' @@ -20,7 +21,8 @@ const SearchPage = forwardRef(({ index }: { index?: number }, ref) => { type !== 'profiles' && type !== 'notes' && type !== 'hashtag' && - type !== 'relay' + type !== 'relay' && + type !== 'nak' ) { return null } @@ -29,8 +31,16 @@ const SearchPage = forwardRef(({ index }: { index?: number }, ref) => { return null } const input = params.get('i') ?? '' + let request = undefined + if (type === 'nak') { + try { + request = parseNakReqCommand(input) + } catch { + // ignore invalid request param + } + } setInput(input || search) - return { type, search, input } as TSearchParams + return { type, search, input, request } as TSearchParams }, []) useEffect(() => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 2c44b4dc..4445edaa 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,4 @@ -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { BIG_RELAY_URLS, ExtendedKind, SEARCHABLE_RELAY_URLS } from '@/constants' import { compareEvents, getReplaceableCoordinate, @@ -151,6 +151,19 @@ class ClientService extends EventTarget { return Array.from(relaySet) } + async determineRelaysByFilter(filter: Filter) { + if (filter.search) { + return SEARCHABLE_RELAY_URLS + } else if (filter.authors?.length) { + const relayLists = await this.fetchRelayLists(filter.authors) + return Array.from(new Set(relayLists.flatMap((list) => list.write.slice(0, 5)))) + } else if (filter['#p']?.length) { + const relayLists = await this.fetchRelayLists(filter['#p']) + return Array.from(new Set(relayLists.flatMap((list) => list.read.slice(0, 5)))) + } + return BIG_RELAY_URLS + } + async publishEvent(relayUrls: string[], event: NEvent) { const uniqueRelayUrls = Array.from(new Set(relayUrls)) await new Promise((resolve, reject) => { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 9c9ae906..f005789d 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -178,12 +178,20 @@ export type TSearchType = | 'hashtag' | 'relay' | 'externalContent' + | 'nak' -export type TSearchParams = { - type: TSearchType - search: string - input?: string -} +export type TSearchParams = + | { + type: Exclude + search: string + input?: string + } + | { + type: 'nak' + search: string + request: TFeedSubRequest + input?: string + } export type TNotificationStyle = (typeof NOTIFICATION_LIST_STYLE)[keyof typeof NOTIFICATION_LIST_STYLE]