feat: nak req
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<NakItem
|
||||
key={index}
|
||||
selected={selectedIndex === index}
|
||||
description={option.search}
|
||||
onClick={() => updateSearch(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (option.type === 'profiles') {
|
||||
return (
|
||||
<Item
|
||||
@@ -468,6 +493,28 @@ function ExternalContentItem({
|
||||
)
|
||||
}
|
||||
|
||||
function NakItem({
|
||||
description,
|
||||
onClick,
|
||||
selected
|
||||
}: {
|
||||
description: string
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Terminal className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">REQ</div>
|
||||
<div className="text-sm text-muted-foreground truncate">{description}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({
|
||||
className,
|
||||
children,
|
||||
|
||||
@@ -32,5 +32,8 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (searchParams.type === 'nak') {
|
||||
return <NormalFeed subRequests={[searchParams.request]} showRelayCloseReason />
|
||||
}
|
||||
return <Relay url={searchParams.search} />
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
253
src/lib/nak-parser.ts
Normal file
253
src/lib/nak-parser.ts
Normal file
@@ -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=<id>
|
||||
* -d: shortcut for --tag d=<value>
|
||||
* -e: shortcut for --tag e=<value>
|
||||
* -p: shortcut for --tag p=<value>
|
||||
*
|
||||
* 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<Filter, 'since' | 'until'> = {}
|
||||
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<string, string[]>
|
||||
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'
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
|
||||
18
src/types/index.d.ts
vendored
18
src/types/index.d.ts
vendored
@@ -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<TSearchType, 'nak'>
|
||||
search: string
|
||||
input?: string
|
||||
}
|
||||
| {
|
||||
type: 'nak'
|
||||
search: string
|
||||
request: TFeedSubRequest
|
||||
input?: string
|
||||
}
|
||||
|
||||
export type TNotificationStyle =
|
||||
(typeof NOTIFICATION_LIST_STYLE)[keyof typeof NOTIFICATION_LIST_STYLE]
|
||||
|
||||
Reference in New Issue
Block a user