Browse Source

implemented advanced search

imwald
Silberengel 5 months ago
parent
commit
78a0bbdbe4
  1. 16
      src/PageManager.tsx
  2. 89
      src/components/Profile/index.tsx
  3. 35
      src/components/SearchBar/index.tsx
  4. 118
      src/components/SearchInfo.tsx
  5. 410
      src/lib/search-parser.ts
  6. 2
      src/pages/primary/DiscussionsPage/index.tsx
  7. 8
      src/pages/primary/SearchPage/index.tsx
  8. 204
      src/pages/secondary/NoteListPage/index.tsx
  9. 87
      src/pages/secondary/NotePage/index.tsx
  10. 87
      src/pages/secondary/SearchPage/index.tsx
  11. 11
      src/services/navigation.service.ts
  12. 2
      src/types/index.d.ts

16
src/PageManager.tsx

@ -310,6 +310,19 @@ function MainContentArea({
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
goBack: () => void goBack: () => void
}) { }) {
const [, forceUpdate] = useState(0)
// Listen for note page title updates
useEffect(() => {
const handleTitleUpdate = () => {
forceUpdate(n => n + 1)
}
window.addEventListener('notePageTitleUpdated', handleTitleUpdate)
return () => {
window.removeEventListener('notePageTitleUpdated', handleTitleUpdate)
}
}, [])
logger.debug('MainContentArea rendering:', { logger.debug('MainContentArea rendering:', {
currentPrimaryPage, currentPrimaryPage,
primaryPages: primaryPages.map(p => p.name), primaryPages: primaryPages.map(p => p.name),
@ -707,7 +720,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{primaryViewType === 'settings' ? 'Settings' : {primaryViewType === 'settings' ? 'Settings' :
primaryViewType === 'settings-sub' ? 'Settings' : primaryViewType === 'settings-sub' ? 'Settings' :
primaryViewType === 'profile' ? 'Back' : primaryViewType === 'profile' ? 'Back' :
primaryViewType === 'hashtag' ? 'Hashtag' : 'Note'} primaryViewType === 'hashtag' ? 'Hashtag' :
primaryViewType === 'note' ? getPageTitle(primaryViewType, window.location.pathname) : 'Note'}
</div> </div>
</Button> </Button>
</div> </div>

89
src/components/Profile/index.tsx

@ -26,9 +26,11 @@ import { Event, kinds } from 'nostr-tools'
import { toProfileEditor } from '@/lib/link' import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { toNoteList } from '@/lib/link'
import { parseAdvancedSearch } from '@/lib/search-parser'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Link, Zap } from 'lucide-react' import { FileText, Link, Zap } from 'lucide-react'
import { useEffect, useMemo, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -52,6 +54,88 @@ export default function Profile({ id }: { id?: string }) {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [articleKindFilter, setArticleKindFilter] = useState<string>('all') const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
// Handle search in articles tab - parse advanced search parameters
const handleArticleSearch = (query: string) => {
if (activeTab === 'articles' && query.trim()) {
const searchParams = parseAdvancedSearch(query)
// Build kinds array from filter
const kinds = articleKindFilter && articleKindFilter !== 'all'
? [parseInt(articleKindFilter)]
: undefined
// Combine filter kinds with search param kinds
const allKinds = kinds || searchParams.kinds || undefined
// Build URL with search parameters
// For now, if we have a d-tag, use that. Otherwise use advanced search
if (searchParams.dtag) {
// Use d-tag search if we have plain text
const url = toNoteList({ domain: searchParams.dtag, kinds: allKinds })
push(url)
return
} else if (Object.keys(searchParams).length > 0) {
// Advanced search - we'll need to pass these as URL params
// For now, construct URL with all parameters
const urlParams = new URLSearchParams()
if (searchParams.title) {
if (Array.isArray(searchParams.title)) {
searchParams.title.forEach(t => urlParams.append('title', t))
} else {
urlParams.set('title', searchParams.title)
}
}
if (searchParams.subject) {
if (Array.isArray(searchParams.subject)) {
searchParams.subject.forEach(s => urlParams.append('subject', s))
} else {
urlParams.set('subject', searchParams.subject)
}
}
if (searchParams.description) {
if (Array.isArray(searchParams.description)) {
searchParams.description.forEach(d => urlParams.append('description', d))
} else {
urlParams.set('description', searchParams.description)
}
}
if (searchParams.author) {
if (Array.isArray(searchParams.author)) {
searchParams.author.forEach(a => urlParams.append('author', a))
} else {
urlParams.set('author', searchParams.author)
}
}
if (searchParams.pubkey) {
if (Array.isArray(searchParams.pubkey)) {
searchParams.pubkey.forEach(p => urlParams.append('pubkey', p))
} else {
urlParams.set('pubkey', searchParams.pubkey)
}
}
if (searchParams.type) {
if (Array.isArray(searchParams.type)) {
searchParams.type.forEach(t => urlParams.append('type', t))
} else {
urlParams.set('type', searchParams.type)
}
}
if (searchParams.from) urlParams.set('from', searchParams.from)
if (searchParams.to) urlParams.set('to', searchParams.to)
if (searchParams.before) urlParams.set('before', searchParams.before)
if (searchParams.after) urlParams.set('after', searchParams.after)
if (allKinds) {
allKinds.forEach(k => urlParams.append('k', k.toString()))
}
const url = `/notes?${urlParams.toString()}`
push(url)
return
}
}
setSearchQuery(query)
}
// Refs for child components // Refs for child components
const profileFeedRef = useRef<{ refresh: () => void }>(null) const profileFeedRef = useRef<{ refresh: () => void }>(null)
const profileBookmarksRef = useRef<{ refresh: () => void }>(null) const profileBookmarksRef = useRef<{ refresh: () => void }>(null)
@ -232,7 +316,7 @@ export default function Profile({ id }: { id?: string }) {
/> />
<div className="flex items-center gap-2 pr-2 px-1"> <div className="flex items-center gap-2 pr-2 px-1">
<ProfileSearchBar <ProfileSearchBar
onSearch={setSearchQuery} onSearch={activeTab === 'articles' ? handleArticleSearch : setSearchQuery}
placeholder={`Search ${activeTab}...`} placeholder={`Search ${activeTab}...`}
className="w-64" className="w-64"
/> />
@ -248,6 +332,7 @@ export default function Profile({ id }: { id?: string }) {
return ( return (
<Select value={articleKindFilter} onValueChange={setArticleKindFilter}> <Select value={articleKindFilter} onValueChange={setArticleKindFilter}>
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter by type" /> <SelectValue placeholder="Filter by type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

35
src/components/SearchBar/index.tsx

@ -3,12 +3,13 @@ import { useSearchProfiles } from '@/hooks'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
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 { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { Hash, Notebook, Search, Server } from 'lucide-react' import { Hash, Notebook, Search, Server, FileText } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@ -92,6 +93,9 @@ const SearchBar = forwardRef<
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') {
// Navigate to d-tag search using same pattern as hashtag
navigateToHashtag(toNoteList({ domain: params.search }))
} else { } else {
onSearch(params) onSearch(params)
} }
@ -128,10 +132,12 @@ const SearchBar = forwardRef<
} }
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() ?? '' const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() ?? ''
const normalizedDTag = normalizeToDTag(search)
setSelectableOptions([ setSelectableOptions([
{ type: 'notes', search }, { type: 'notes', search },
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` }, { type: 'hashtag', search: hashtag, input: `#${hashtag}` },
...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []),
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
...profiles.map((profile) => ({ ...profiles.map((profile) => ({
type: 'profile', type: 'profile',
@ -190,6 +196,16 @@ const SearchBar = forwardRef<
/> />
) )
} }
if (option.type === 'dtag') {
return (
<DTagItem
key={index}
selected={selectedIndex === index}
dtag={option.search}
onClick={() => updateSearch(option)}
/>
)
}
if (option.type === 'relay') { if (option.type === 'relay') {
return ( return (
<RelayItem <RelayItem
@ -386,6 +402,23 @@ function ProfileItem({
) )
} }
function DTagItem({
dtag,
onClick,
selected
}: {
dtag: string
onClick?: () => void
selected?: boolean
}) {
return (
<Item onClick={onClick} selected={selected}>
<FileText className="text-muted-foreground" />
<div className="font-semibold truncate">{dtag}</div>
</Item>
)
}
function RelayItem({ function RelayItem({
url, url,
onClick, onClick,

118
src/components/SearchInfo.tsx

@ -0,0 +1,118 @@
import { Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger
} from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils'
export default function SearchInfo() {
const { isSmallScreen } = useScreenSize()
const searchInfoContent = (
<div className="space-y-3">
<div>
<h4 className="font-semibold mb-2">Advanced Search Parameters</h4>
<div className="space-y-2 text-sm">
<div>
<strong>Plain text:</strong> Searches by d-tag for replaceable events (normalized, hyphenated)
</div>
<div>
<strong>Date ranges:</strong>
<ul className="ml-4 mt-1 space-y-1 list-disc">
<li><code className="text-xs">YYYY-MM-DD to YYYY-MM-DD</code> - Date range (e.g., <code className="text-xs">2025-10-23 to 2025-10-30</code>)</li>
<li><code className="text-xs">from:YYYY-MM-DD</code> - Events from this date</li>
<li><code className="text-xs">to:YYYY-MM-DD</code> - Events until this date</li>
<li><code className="text-xs">before:YYYY-MM-DD</code> - Events before this date</li>
<li><code className="text-xs">after:YYYY-MM-DD</code> - Events after this date</li>
<li>Supports 2-digit years (e.g., <code className="text-xs">25-10-23</code> = <code className="text-xs">2025-10-23</code>)</li>
</ul>
</div>
<div>
<strong>Metadata fields:</strong>
<ul className="ml-4 mt-1 space-y-1 list-disc">
<li><code className="text-xs">title:"text"</code> or <code className="text-xs">title:text</code> - Search in title tag</li>
<li><code className="text-xs">subject:"text"</code> or <code className="text-xs">subject:text</code> - Search in subject tag</li>
<li><code className="text-xs">description:"text"</code> - Search in description tag</li>
<li><code className="text-xs">author:"name"</code> - Search by author tag (not pubkey)</li>
<li><code className="text-xs">pubkey:npub...</code>, <code className="text-xs">pubkey:hex</code>, <code className="text-xs">pubkey:nprofile...</code>, or <code className="text-xs">pubkey:user@domain.com</code> - Filter by pubkey (accepts npub, nprofile, hex, or NIP-05)</li>
<li><code className="text-xs">events:hex</code>, <code className="text-xs">events:note1...</code>, <code className="text-xs">events:nevent1...</code>, or <code className="text-xs">events:naddr1...</code> - Filter by specific events (accepts hex, note, nevent, or naddr)</li>
<li><code className="text-xs">type:value</code> - Filter by type tag</li>
<li><code className="text-xs">kind:30023</code> - Filter by event kind (e.g., 1=notes, 30023=articles, 30817/30818=wiki)</li>
<li>Multiple values supported: <code className="text-xs">author:Aristotle,Plato</code> or <code className="text-xs">kind:30023,2018</code></li>
</ul>
</div>
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<strong>Examples:</strong>
</p>
<ul className="ml-4 mt-1 space-y-1 list-disc text-xs text-muted-foreground">
<li><code>jumble search</code> searches d-tag</li>
<li><code>title:"My Article" from:2024-01-01</code></li>
<li><code>author:"John Doe" type:wiki</code></li>
<li><code>2025-10-23 to 2025-10-30</code> date range</li>
</ul>
</div>
</div>
</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md relative z-10")}
title="Search help"
>
<Info className="h-4 w-4" />
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Advanced Search Help</DrawerTitle>
<DrawerDescription>
Learn about available search parameters
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-4 max-h-[60vh] overflow-y-auto">
{searchInfoContent}
</div>
<DrawerClose asChild>
<Button variant="outline" className="m-4">Close</Button>
</DrawerClose>
</DrawerContent>
</Drawer>
)
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md relative z-10")}
title="Search help"
>
<Info className="h-4 w-4" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-96 max-h-[80vh] overflow-y-auto" side="left" align="start">
<h3 className="font-semibold mb-3">Advanced Search Help</h3>
{searchInfoContent}
</HoverCardContent>
</HoverCard>
)
}

410
src/lib/search-parser.ts

@ -0,0 +1,410 @@
/**
* Advanced search parser for Nostr events
* Supports multiple search parameters:
* - Date ranges: YYYY-MM-DD to YYYY-MM-DD, from:YYYY-MM-DD, to:YYYY-MM-DD, before:YYYY-MM-DD, after:YYYY-MM-DD
* - Title: title:"text" or title:text
* - Subject: subject:"text" or subject:text
* - Description: description:"text" or description:text
* - Author: author:"name" (author tag, not pubkey)
* - Pubkey: pubkey:npub... or pubkey:hex...
* - Type: type:value
* - Kind: kind:30023 (filter by event kind)
* - Plain text: becomes d-tag search for replaceable events
*/
export interface AdvancedSearchParams {
dtag?: string
title?: string | string[]
subject?: string | string[]
description?: string | string[]
author?: string | string[]
pubkey?: string | string[] // Accepts: hex, npub, nprofile, or NIP-05
events?: string | string[] // Accepts: hex event ID, note, nevent, naddr
type?: string | string[]
from?: string // YYYY-MM-DD
to?: string // YYYY-MM-DD
before?: string // YYYY-MM-DD
after?: string // YYYY-MM-DD
kinds?: number[]
}
/**
* Normalize search term to d-tag format (lowercase, hyphenated)
*/
export function normalizeToDTag(term: string): string {
return term
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
}
/**
* Normalize date to YYYY-MM-DD format
* Supports both 2-digit (YY) and 4-digit (YYYY) years
*/
function normalizeDate(dateStr: string): string {
const parts = dateStr.split('-')
if (parts.length !== 3) return dateStr
let year = parts[0]
const month = parts[1]
const day = parts[2]
// Convert 2-digit year to 4-digit
if (year.length === 2) {
const yearNum = parseInt(year)
// Assume years 00-30 are 2000-2030, years 31-99 are 1931-1999
year = yearNum <= 30 ? `20${year.padStart(2, '0')}` : `19${year}`
}
return `${year}-${month}-${day}`
}
/**
* Parse advanced search query
*/
export function parseAdvancedSearch(query: string): AdvancedSearchParams {
// Normalize the query: trim, normalize whitespace, handle multiple spaces
const normalizedQuery = query
.trim()
.replace(/\s+/g, ' ') // Replace multiple whitespace with single space
.replace(/\s*,\s*/g, ',') // Normalize spaces around commas
.replace(/\s*:\s*/g, ':') // Normalize spaces around colons
.replace(/\s+to\s+/gi, ' to ') // Normalize "to" in date ranges
const params: AdvancedSearchParams = {}
// Regular expressions for different parameter types
// Support both 4-digit (YYYY) and 2-digit (YY) years, date ranges (DATE to DATE)
const dateRangePattern = /(\d{2,4}-\d{2}-\d{2})\s+to\s+(\d{2,4}-\d{2}-\d{2})/gi
const datePattern = /(?:from|to|before|after):(\d{2,4}-\d{2}-\d{2})/gi
const quotedPattern = /(title|subject|description|author|type|pubkey|events):"([^"]+)"/gi
const unquotedPattern = /(title|subject|description|author|pubkey|type|kind|events):([^\s]+)/gi
// Pattern to detect bare nip19 IDs (nevent, note, naddr) or hex event IDs
// These start with the prefix and are base32 encoded (use word boundary to avoid partial matches)
const bareEventIdPattern = /\b(nevent1|note1|naddr1)[a-z0-9]{0,58}\b/gi
const hexEventIdPattern = /\b[a-f0-9]{64}\b/i
// Pattern to detect bare pubkey IDs (npub, nprofile) or hex pubkeys
const barePubkeyIdPattern = /\b(nprofile1|npub1)[a-z0-9]{0,58}\b/gi
const nip05Pattern = /\b[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/gi
// Extract quoted parameters
let match
let lastIndex = 0
const usedIndices: number[] = []
const detectedEventIds: { id: string; start: number; end: number }[] = []
const detectedPubkeyIds: { id: string; start: number; end: number }[] = []
// First, detect bare event IDs (nevent, note, naddr) in the normalized query
bareEventIdPattern.lastIndex = 0
while ((match = bareEventIdPattern.exec(normalizedQuery)) !== null) {
const id = match[0]
const start = match.index
const end = start + id.length
detectedEventIds.push({ id, start, end })
usedIndices.push(start, end)
}
// Detect bare pubkey IDs (npub, nprofile)
barePubkeyIdPattern.lastIndex = 0
while ((match = barePubkeyIdPattern.exec(normalizedQuery)) !== null) {
const id = match[0]
const start = match.index
const end = start + id.length
detectedPubkeyIds.push({ id, start, end })
usedIndices.push(start, end)
}
// Detect NIP-05 identifiers
nip05Pattern.lastIndex = 0
while ((match = nip05Pattern.exec(normalizedQuery)) !== null) {
const id = match[0]
const start = match.index
const end = start + id.length
// Skip if already used by a parameter pattern or other detected IDs
if (!usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
detectedPubkeyIds.push({ id, start, end })
usedIndices.push(start, end)
}
}
// Check for hex IDs (64 character hex string) - could be either event or pubkey
// We'll treat them as events by default, but they might be interpreted differently in context
hexEventIdPattern.lastIndex = 0
while ((match = hexEventIdPattern.exec(normalizedQuery)) !== null) {
const id = match[0]
const start = match.index
const end = start + id.length
// Only add if not already in a detected ID range
if (!usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
// Default to treating as event ID (most common case for hex IDs in Nostr)
detectedEventIds.push({ id, start, end })
usedIndices.push(start, end)
}
}
// Helper function to parse comma-separated values
const parseValues = (value: string): string[] => {
return value.split(',').map(v => v.trim()).filter(v => v.length > 0)
}
// Process quoted strings first (they can contain spaces)
while ((match = quotedPattern.exec(normalizedQuery)) !== null) {
const param = match[1].toLowerCase()
const value = match[2]
const start = match.index
const end = start + match[0].length
usedIndices.push(start, end)
lastIndex = end
const values = parseValues(value)
switch (param) {
case 'title':
params.title = values.length === 1 ? values[0] : values
break
case 'subject':
params.subject = values.length === 1 ? values[0] : values
break
case 'description':
params.description = values.length === 1 ? values[0] : values
break
case 'author':
params.author = values.length === 1 ? values[0] : values
break
case 'type':
params.type = values.length === 1 ? values[0] : values
break
case 'pubkey':
const pubkeyValues = parseValues(value)
params.pubkey = pubkeyValues.length === 1 ? pubkeyValues[0] : pubkeyValues
break
case 'events':
const eventValues = parseValues(value)
params.events = eventValues.length === 1 ? eventValues[0] : eventValues
break
}
}
// Process unquoted parameters
while ((match = unquotedPattern.exec(normalizedQuery)) !== null) {
const start = match.index
// Skip if already used by quoted pattern
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
continue
}
const param = match[1].toLowerCase()
const value = match[2]
const end = start + match[0].length
usedIndices.push(start, end)
lastIndex = Math.max(lastIndex, end)
switch (param) {
case 'title':
if (!params.title) {
const values = parseValues(value)
params.title = values.length === 1 ? values[0] : values
}
break
case 'subject':
if (!params.subject) {
const values = parseValues(value)
params.subject = values.length === 1 ? values[0] : values
}
break
case 'description':
if (!params.description) {
const values = parseValues(value)
params.description = values.length === 1 ? values[0] : values
}
break
case 'author':
if (!params.author) {
const values = parseValues(value)
params.author = values.length === 1 ? values[0] : values
}
break
case 'pubkey':
if (!params.pubkey) {
const pubkeyValues = parseValues(value)
params.pubkey = pubkeyValues.length === 1 ? pubkeyValues[0] : pubkeyValues
}
break
case 'events':
if (!params.events) {
const eventValues = parseValues(value)
params.events = eventValues.length === 1 ? eventValues[0] : eventValues
}
break
case 'type':
if (!params.type) {
const values = parseValues(value)
params.type = values.length === 1 ? values[0] : values
}
break
case 'kind':
const kindValues = parseValues(value)
params.kinds = params.kinds || []
for (const kindVal of kindValues) {
const kindNum = parseInt(kindVal)
if (!isNaN(kindNum)) {
params.kinds.push(kindNum)
}
}
break
}
}
// Process detected bare event IDs (those not used as parameters)
for (const detectedId of detectedEventIds) {
const start = detectedId.start
// Skip if already used by a parameter pattern
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
continue
}
// Mark as used
usedIndices.push(start, detectedId.end)
// Store the event ID in params.events
if (!params.events) {
params.events = detectedId.id
} else if (Array.isArray(params.events)) {
params.events.push(detectedId.id)
} else {
params.events = [params.events, detectedId.id]
}
}
// Process detected bare pubkey IDs (those not used as parameters)
for (const detectedId of detectedPubkeyIds) {
const start = detectedId.start
// Skip if already used by a parameter pattern
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
continue
}
// Mark as used
usedIndices.push(start, detectedId.end)
// Store the pubkey ID in params.pubkey
if (!params.pubkey) {
params.pubkey = detectedId.id
} else if (Array.isArray(params.pubkey)) {
params.pubkey.push(detectedId.id)
} else {
params.pubkey = [params.pubkey, detectedId.id]
}
}
// Process date range patterns first (DATE to DATE)
dateRangePattern.lastIndex = 0
while ((match = dateRangePattern.exec(normalizedQuery)) !== null) {
const startDate = normalizeDate(match[1])
const endDate = normalizeDate(match[2])
const start = match.index
const end = start + match[0].length
usedIndices.push(start, end)
lastIndex = Math.max(lastIndex, end)
// Use from/to for date ranges
params.from = startDate
params.to = endDate
}
// Process date parameters (from:, to:, before:, after:)
datePattern.lastIndex = 0
while ((match = datePattern.exec(normalizedQuery)) !== null) {
const start = match.index
// Skip if already used by date range pattern
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) {
continue
}
const param = match[0].split(':')[0].toLowerCase()
const value = normalizeDate(match[1])
const end = start + match[0].length
usedIndices.push(start, end)
lastIndex = Math.max(lastIndex, end)
switch (param) {
case 'from':
params.from = value
break
case 'to':
params.to = value
break
case 'before':
params.before = value
break
case 'after':
params.after = value
break
}
}
// Extract plain text (everything not matched by patterns)
usedIndices.sort((a, b) => a - b)
let plainText = ''
let textStart = 0
// Remove duplicate indices and merge overlapping ranges
const ranges: Array<[number, number]> = []
for (let i = 0; i < usedIndices.length; i += 2) {
const start = usedIndices[i]
const end = usedIndices[i + 1] || usedIndices[i]
ranges.push([start, end])
}
// Sort and merge overlapping ranges
ranges.sort((a, b) => a[0] - b[0])
const merged: Array<[number, number]> = []
for (const range of ranges) {
if (merged.length === 0 || merged[merged.length - 1][1] < range[0]) {
merged.push(range)
} else {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], range[1])
}
}
// Extract plain text from gaps between used ranges
for (const [start, end] of merged) {
if (textStart < start) {
const segment = normalizedQuery.substring(textStart, start).trim()
if (segment) {
plainText += (plainText ? ' ' : '') + segment
}
}
textStart = Math.max(textStart, end)
}
// Add remaining text
if (textStart < normalizedQuery.length) {
const remaining = normalizedQuery.substring(textStart).trim()
if (remaining) {
plainText += (plainText ? ' ' : '') + remaining
}
}
// If we have plain text and no other parameters, use it as d-tag
if (plainText && !Object.keys(params).length) {
params.dtag = normalizeToDTag(plainText)
} else if (plainText) {
// Plain text can also be used for d-tag even with other params
params.dtag = normalizeToDTag(plainText)
}
return params
}

2
src/pages/primary/DiscussionsPage/index.tsx

@ -324,7 +324,7 @@ function DiscussionsPageTitlebar() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-3 h-full">
<h1 className="text-lg font-semibold">{t('Discussions')}</h1> <h1 className="text-lg font-semibold">{t('Discussions')}</h1>
</div> </div>
) )

8
src/pages/primary/SearchPage/index.tsx

@ -3,6 +3,7 @@ import SearchResult from '@/components/SearchResult'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import SearchInfo from '@/components/SearchInfo'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
const SearchPage = forwardRef((_, ref) => { const SearchPage = forwardRef((_, ref) => {
@ -44,7 +45,14 @@ const SearchPage = forwardRef((_, ref) => {
> >
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="text-2xl font-bold mb-4">Search Nostr</div>
<div className="flex items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div>
<div className="flex-shrink-0 relative z-50">
<SearchInfo />
</div>
</div>
<div className="h-4"></div> <div className="h-4"></div>
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />
</div> </div>

204
src/pages/secondary/NoteListPage/index.tsx

@ -12,6 +12,7 @@ import { useInterestList } from '@/providers/InterestListProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import { UserRound, Plus } from 'lucide-react' import { UserRound, Plus } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import React, { forwardRef, useEffect, useState, useMemo } from 'react' import React, { forwardRef, useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -29,8 +30,9 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const [controls, setControls] = useState<React.ReactNode>(null) const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState< const [data, setData] = useState<
| { | {
type: 'hashtag' | 'search' | 'externalContent' type: 'hashtag' | 'search' | 'externalContent' | 'dtag'
kinds?: number[] kinds?: number[]
dtag?: string
} }
| { | {
type: 'domain' type: 'domain'
@ -123,6 +125,11 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
} }
const domain = searchParams.get('d') const domain = searchParams.get('d')
if (domain) { if (domain) {
// Check if it looks like a domain (contains a dot) or is a d-tag search
const looksLikeDomain = domain.includes('.')
if (looksLikeDomain) {
// Domain lookup (NIP-05)
setTitle( setTitle(
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{domain} {domain}
@ -148,6 +155,193 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
} else { } else {
setSubRequests([]) setSubRequests([])
} }
} else {
// D-tag search - filter events by d-tag value
setTitle(`D-Tag: ${domain}`)
setData({
type: 'dtag',
dtag: domain,
kinds: kinds.length > 0 ? kinds : undefined
})
// Filter by d-tag - we'll need to fetch events that have this d-tag
// For replaceable events, the d-tag is in the 'd' tag position
const filter: any = {
'#d': [domain]
}
if (kinds.length > 0) {
filter.kinds = kinds
}
setSubRequests([
{
filter,
urls: BIG_RELAY_URLS
}
])
}
return
}
// Advanced search parameters (support multiple values)
const title = searchParams.getAll('title')
const subject = searchParams.getAll('subject')
const description = searchParams.getAll('description')
const author = searchParams.getAll('author')
const searchPubkey = searchParams.getAll('pubkey')
const searchEvents = searchParams.getAll('events')
const type = searchParams.getAll('type')
const from = searchParams.get('from')
const to = searchParams.get('to')
const before = searchParams.get('before')
const after = searchParams.get('after')
// Check if we have any advanced search parameters
if (title.length > 0 || subject.length > 0 || description.length > 0 || author.length > 0 || searchPubkey.length > 0 || searchEvents.length > 0 || type.length > 0 || from || to || before || after) {
const filter: any = {}
// Tag-based filters (support multiple values - use OR logic)
if (title.length > 0) filter['#title'] = title
if (subject.length > 0) filter['#subject'] = subject
if (description.length > 0) filter['#description'] = description
if (author.length > 0) filter['#author'] = author
if (type.length > 0) filter['#type'] = type
// Pubkey filter (support multiple pubkeys: hex, npub, nprofile, NIP-05)
if (searchPubkey.length > 0) {
const decodedPubkeys: string[] = []
for (const pubkeyInput of searchPubkey) {
try {
// Check if it's a NIP-05 identifier
if (pubkeyInput.includes('@')) {
// Will need to resolve NIP-05, but for now we'll handle it separately
// For now, try to fetch and decode
const pubkeys = await fetchPubkeysFromDomain(pubkeyInput.split('@')[1])
decodedPubkeys.push(...pubkeys)
} else if (pubkeyInput.startsWith('npub') || pubkeyInput.startsWith('nprofile')) {
const decoded = nip19.decode(pubkeyInput)
if (decoded.type === 'npub') {
decodedPubkeys.push(decoded.data)
} else if (decoded.type === 'nprofile') {
decodedPubkeys.push(decoded.data.pubkey)
}
} else {
// Assume hex pubkey
decodedPubkeys.push(pubkeyInput)
}
} catch (e) {
// If decoding fails, try as hex or skip
if (/^[a-f0-9]{64}$/i.test(pubkeyInput)) {
decodedPubkeys.push(pubkeyInput)
}
}
}
if (decodedPubkeys.length > 0) {
filter.authors = decodedPubkeys
}
}
// Events filter (support multiple events: hex, note, nevent, naddr)
if (searchEvents.length > 0) {
const eventIds: string[] = []
for (const eventInput of searchEvents) {
try {
if (/^[a-f0-9]{64}$/i.test(eventInput)) {
// Hex event ID
eventIds.push(eventInput)
} else if (eventInput.startsWith('note1') || eventInput.startsWith('nevent1') || eventInput.startsWith('naddr1')) {
const decoded = nip19.decode(eventInput)
if (decoded.type === 'note') {
eventIds.push(decoded.data)
} else if (decoded.type === 'nevent') {
eventIds.push(decoded.data.id)
} else if (decoded.type === 'naddr') {
// For naddr, we need to filter by kind, pubkey, and d-tag
if (!filter.kinds) filter.kinds = []
if (!filter.kinds.includes(decoded.data.kind)) {
filter.kinds.push(decoded.data.kind)
}
if (!filter.authors) filter.authors = []
if (!filter.authors.includes(decoded.data.pubkey)) {
filter.authors.push(decoded.data.pubkey)
}
if (decoded.data.identifier) {
if (!filter['#d']) filter['#d'] = []
if (!filter['#d'].includes(decoded.data.identifier)) {
filter['#d'].push(decoded.data.identifier)
}
}
continue // Skip adding to eventIds for naddr
}
}
} catch (e) {
// Skip invalid event IDs
}
}
if (eventIds.length > 0) {
filter.ids = eventIds
}
}
// Date filters - convert to unix timestamps
let since: number | undefined
let until: number | undefined
if (from) {
const date = new Date(from + 'T00:00:00Z')
since = Math.floor(date.getTime() / 1000)
}
if (to) {
const date = new Date(to + 'T23:59:59Z')
until = Math.floor(date.getTime() / 1000)
}
if (before) {
const date = new Date(before + 'T00:00:00Z')
until = Math.min(until || Infinity, Math.floor(date.getTime() / 1000) - 1)
}
if (after) {
const date = new Date(after + 'T23:59:59Z')
since = Math.max(since || 0, Math.floor(date.getTime() / 1000) + 1)
}
if (since) filter.since = since
if (until) filter.until = until
// Kinds filter
if (kinds.length > 0) {
filter.kinds = kinds
}
// Build title from search params
const titleParts: string[] = []
if (title.length > 0) titleParts.push(`title:${title.join(',')}`)
if (subject.length > 0) titleParts.push(`subject:${subject.join(',')}`)
if (author.length > 0) titleParts.push(`author:${author.join(',')}`)
if (searchPubkey.length > 0) {
const pubkeyDisplay = searchPubkey.length === 1
? `${searchPubkey[0].substring(0, 16)}...`
: `${searchPubkey.length} pubkeys`
titleParts.push(`pubkey:${pubkeyDisplay}`)
}
if (type.length > 0) titleParts.push(`type:${type.join(',')}`)
if (from || to || before || after) {
const dateParts: string[] = []
if (from) dateParts.push(`from:${from}`)
if (to) dateParts.push(`to:${to}`)
if (before) dateParts.push(`before:${before}`)
if (after) dateParts.push(`after:${after}`)
titleParts.push(dateParts.join(', '))
}
setTitle(`Search: ${titleParts.join(' ')}`)
setData({
type: 'search',
kinds: kinds.length > 0 ? kinds : undefined
})
setSubRequests([
{
filter,
urls: BIG_RELAY_URLS
}
])
return return
} }
} }
@ -225,15 +419,19 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
controls={hideTitlebar ? undefined : controls} controls={hideTitlebar ? undefined : controls}
displayScrollToTopButton displayScrollToTopButton
> >
{hideTitlebar && data?.type === 'hashtag' && ( {hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') ? (
<>
<div className="px-4 py-2 border-b"> <div className="px-4 py-2 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-lg font-semibold">{title}</div> <div className="text-lg font-semibold">{title}</div>
{controls} {controls}
</div> </div>
</div> </div>
<div className="pt-4">{content}</div>
</>
) : (
content
)} )}
{content}
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

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

@ -15,8 +15,8 @@ import { toNote, toNoteList } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useMemo, useState } from 'react' import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFound from './NotFound' import NotFound from './NotFound'
@ -35,41 +35,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId) const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId)
const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId) const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId)
if (!event && isFetching) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')}>
<div className="px-4 pt-3">
<div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className={`flex-1 w-0`}>
<div className="py-1">
<Skeleton className="h-4 w-16" />
</div>
<div className="py-0.5">
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
<div className="pt-2">
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div>
</div>
</div>
</SecondaryPageLayout>
)
}
if (!finalEvent) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')} displayScrollToTopButton>
<NotFound bech32Id={id} onEventFound={setExternalEvent} />
</SecondaryPageLayout>
)
}
const getNoteTypeTitle = (kind: number): string => { const getNoteTypeTitle = (kind: number): string => {
switch (kind) { switch (kind) {
case 1: // kinds.ShortTextNote case 1: // kinds.ShortTextNote
@ -83,7 +48,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
case 22: // ExtendedKind.SHORT_VIDEO case 22: // ExtendedKind.SHORT_VIDEO
return 'Note: Short Video' return 'Note: Short Video'
case 11: // ExtendedKind.DISCUSSION case 11: // ExtendedKind.DISCUSSION
return 'Note: Discussion Thread' return 'Discussions'
case 9802: // kinds.Highlights case 9802: // kinds.Highlights
return 'Note: Highlight' return 'Note: Highlight'
case 1068: // ExtendedKind.POLL case 1068: // ExtendedKind.POLL
@ -107,6 +72,52 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
} }
} }
// Store title in sessionStorage for primary note view when hideTitlebar is true
// This must be called before any early returns to follow Rules of Hooks
useEffect(() => {
if (hideTitlebar && finalEvent) {
const title = getNoteTypeTitle(finalEvent.kind)
sessionStorage.setItem('notePageTitle', title)
// Trigger a re-render of the primary view title by dispatching a custom event
window.dispatchEvent(new Event('notePageTitleUpdated'))
}
}, [hideTitlebar, finalEvent])
if (!event && isFetching) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')}>
<div className="px-4 pt-3">
<div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className={`flex-1 w-0`}>
<div className="py-1">
<Skeleton className="h-4 w-16" />
</div>
<div className="py-0.5">
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
<div className="pt-2">
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div>
</div>
</div>
</SecondaryPageLayout>
)
}
if (!finalEvent) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')} displayScrollToTopButton>
<NotFound bech32Id={id} onEventFound={setExternalEvent} />
</SecondaryPageLayout>
)
}
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : getNoteTypeTitle(finalEvent.kind)} displayScrollToTopButton> <SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : getNoteTypeTitle(finalEvent.kind)} displayScrollToTopButton>
<div className="px-4 pt-3 w-full"> <div className="px-4 pt-3 w-full">

87
src/pages/secondary/SearchPage/index.tsx

@ -2,8 +2,10 @@ import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toSearch } from '@/lib/link' import { toSearch } from '@/lib/link'
import { parseAdvancedSearch } from '@/lib/search-parser'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import SearchInfo from '@/components/SearchInfo'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -39,6 +41,84 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
const onSearch = (params: TSearchParams | null) => { const onSearch = (params: TSearchParams | null) => {
if (params) { if (params) {
// Check if this is a 'notes' search that contains advanced search parameters
if (params.type === 'notes' && params.search) {
const searchParams = parseAdvancedSearch(params.search)
// Check if we have advanced search parameters (not just plain text)
const hasAdvancedParams = Object.keys(searchParams).some(key =>
key !== 'dtag' && searchParams[key as keyof typeof searchParams]
)
if (hasAdvancedParams || searchParams.dtag) {
// Route to NoteListPage with advanced search
const urlParams = new URLSearchParams()
if (searchParams.dtag) {
urlParams.set('d', searchParams.dtag)
}
if (searchParams.title) {
if (Array.isArray(searchParams.title)) {
searchParams.title.forEach(t => urlParams.append('title', t))
} else {
urlParams.set('title', searchParams.title)
}
}
if (searchParams.subject) {
if (Array.isArray(searchParams.subject)) {
searchParams.subject.forEach(s => urlParams.append('subject', s))
} else {
urlParams.set('subject', searchParams.subject)
}
}
if (searchParams.description) {
if (Array.isArray(searchParams.description)) {
searchParams.description.forEach(d => urlParams.append('description', d))
} else {
urlParams.set('description', searchParams.description)
}
}
if (searchParams.author) {
if (Array.isArray(searchParams.author)) {
searchParams.author.forEach(a => urlParams.append('author', a))
} else {
urlParams.set('author', searchParams.author)
}
}
if (searchParams.pubkey) {
if (Array.isArray(searchParams.pubkey)) {
searchParams.pubkey.forEach(p => urlParams.append('pubkey', p))
} else {
urlParams.set('pubkey', searchParams.pubkey)
}
}
if (searchParams.events) {
if (Array.isArray(searchParams.events)) {
searchParams.events.forEach(e => urlParams.append('events', e))
} else {
urlParams.set('events', searchParams.events)
}
}
if (searchParams.type) {
if (Array.isArray(searchParams.type)) {
searchParams.type.forEach(t => urlParams.append('type', t))
} else {
urlParams.set('type', searchParams.type)
}
}
if (searchParams.from) urlParams.set('from', searchParams.from)
if (searchParams.to) urlParams.set('to', searchParams.to)
if (searchParams.before) urlParams.set('before', searchParams.before)
if (searchParams.after) urlParams.set('after', searchParams.after)
if (searchParams.kinds) {
searchParams.kinds.forEach(k => urlParams.append('k', k.toString()))
}
push(`/notes?${urlParams.toString()}`)
return
}
}
// Default behavior - route to SearchPage
push(toSearch(params)) push(toSearch(params))
} }
} }
@ -53,7 +133,14 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
> >
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="text-2xl font-bold mb-4">Search Nostr</div>
<div className="flex items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} /> <SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div>
<div className="flex-shrink-0 relative z-50">
<SearchInfo />
</div>
</div>
<div className="h-4"></div> <div className="h-4"></div>
<div className="text-xl font-semibold mb-4">Trending Notes</div> <div className="text-xl font-semibold mb-4">Trending Notes</div>
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />

11
src/services/navigation.service.ts

@ -228,7 +228,16 @@ export class NavigationService {
} }
if (viewType === 'hashtag') return 'Hashtag' if (viewType === 'hashtag') return 'Hashtag'
if (viewType === 'relay') return 'Relay' if (viewType === 'relay') return 'Relay'
if (viewType === 'note') return 'Note' if (viewType === 'note') {
// Try to get title from sessionStorage if NotePage has set it
// NotePage will store the title when it determines the event kind
const storedTitle = sessionStorage.getItem('notePageTitle')
if (storedTitle) {
sessionStorage.removeItem('notePageTitle') // Clean up after use
return storedTitle
}
return 'Note'
}
if (viewType === 'following') return 'Following' if (viewType === 'following') return 'Following'
if (viewType === 'mute') return 'Muted Users' if (viewType === 'mute') return 'Muted Users'
if (viewType === 'others-relay-settings') return 'Relay Settings' if (viewType === 'others-relay-settings') return 'Relay Settings'

2
src/types/index.d.ts vendored

@ -176,7 +176,7 @@ export type TPollCreateData = {
endsAt?: number endsAt?: number
} }
export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay' export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay' | 'dtag'
export type TSearchParams = { export type TSearchParams = {
type: TSearchType type: TSearchType

Loading…
Cancel
Save