diff --git a/src/PageManager.tsx b/src/PageManager.tsx
index aa4b671a..5b6ad39f 100644
--- a/src/PageManager.tsx
+++ b/src/PageManager.tsx
@@ -70,7 +70,6 @@ import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
/** Lazy NoteList pages break: PageManager → … → NoteList → NoteCard → useSmartNoteNavigation → PageManager */
const NoteListPageLazy = lazy(() => import('@/pages/primary/NoteListPage'))
-const SecondaryNoteListPageLazy = lazy(() => import('@/pages/secondary/NoteListPage'))
const primaryPageLazyFallback = (
@@ -725,60 +724,33 @@ export function useSmartProfileNavigationOptional() {
return { navigateToProfile }
}
-// Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled
+// Hashtag / d-tag note list opens on the secondary stack (right panel or single-pane sheet), same as other search routes.
export function useSmartHashtagNavigation() {
- const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView()
-
+ const { push: pushSecondaryPage } = useSecondaryPage()
+
const navigateToHashtag = (url: string) => {
- // Use primary note view to show hashtag feed since secondary panel is disabled
- // Update URL first - do this synchronously before setting the view
const parsedUrl = url.startsWith('/') ? url : `/${url}`
- window.history.pushState(null, '', parsedUrl)
-
- // Extract hashtag from URL for the key to ensure unique keys for different hashtags
- const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '')
- const hashtag = searchParams.get('t') || ''
- // Get the current navigation counter and use next value for the key
- // This ensures unique keys that force remounting - setPrimaryNoteView will increment it
- const counter = getNavigationCounter()
- const key = `hashtag-${hashtag}-${counter + 1}`
-
- // Use a key based on the hashtag and navigation counter to force remounting when hashtag changes
- // This ensures the component reads the new URL parameters when it mounts
- // setPrimaryNoteView will increment the counter, so we use counter + 1 for the key
- setPrimaryNoteView(
-
-
- ,
- 'hashtag'
- )
- // Dispatch custom event as a fallback for components that might be reused
+ pushSecondaryPage(parsedUrl)
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
}
-
+
return { navigateToHashtag }
}
/** Safe variant for createRoot trees. Returns fallback navigation when outside providers. */
export function useSmartHashtagNavigationOptional() {
- const primaryNoteView = usePrimaryNoteViewOptional()
- if (!primaryNoteView) {
- return { navigateToHashtag: (url: string) => { window.location.href = url.startsWith('/') ? url : `/${url}` } }
+ const secondaryPage = useSecondaryPageOptional()
+ if (!secondaryPage) {
+ return {
+ navigateToHashtag: (url: string) => {
+ window.location.href = url.startsWith('/') ? url : `/${url}`
+ }
+ }
}
- const { setPrimaryNoteView, getNavigationCounter } = primaryNoteView
+ const { push } = secondaryPage
const navigateToHashtag = (url: string) => {
const parsedUrl = url.startsWith('/') ? url : `/${url}`
- window.history.pushState(null, '', parsedUrl)
- const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '')
- const hashtag = searchParams.get('t') || ''
- const counter = getNavigationCounter()
- const key = `hashtag-${hashtag}-${counter + 1}`
- setPrimaryNoteView(
-
-
- ,
- 'hashtag'
- )
+ push(parsedUrl)
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
}
return { navigateToHashtag }
diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
index 05ed38df..1b106e33 100644
--- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
+++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
@@ -18,7 +18,7 @@ import {
} from '@/components/ui/select'
import logger from '@/lib/logger'
import { isLanguageToolConfigured } from '@/lib/languagetool-client'
-import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
+import { languageToolLintExtension, LT_GRAMMAR_MARK_CLASS, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import {
buildResolvedTranslateMenuLanguageOptions,
@@ -629,6 +629,7 @@ export default function AdvancedEventLabDialog({
const mkExtensions: Extension[] = [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
+ EditorView.lineWrapping,
lineNumbers(),
cmPlaceholder(
labTRef.current(
@@ -645,6 +646,20 @@ export default function AdvancedEventLabDialog({
'.cm-content': {
minHeight: '11rem',
fontFamily: 'var(--font-mono, ui-monospace, monospace)'
+ },
+ // LanguageTool hits: drop default thin SVG underline, use thick wavy line (see `LT_GRAMMAR_MARK_CLASS`).
+ [`.cm-lintRange.${LT_GRAMMAR_MARK_CLASS}`]: {
+ backgroundImage: 'none',
+ paddingBottom: '2px',
+ textDecoration: 'underline',
+ textDecorationSkipInk: 'none',
+ textDecorationStyle: 'wavy',
+ textDecorationColor: '#ea580c',
+ textDecorationThickness: '3px',
+ textUnderlineOffset: '3px'
+ },
+ [`.cm-lintRange-active.${LT_GRAMMAR_MARK_CLASS}`]: {
+ backgroundColor: 'rgba(234, 88, 12, 0.22)'
}
}),
EditorView.updateListener.of((update) => {
@@ -662,7 +677,26 @@ export default function AdvancedEventLabDialog({
languageToolLintExtension(() => ltLangRef.current, 650, () => markupMode)
)
}
- if (dark) mkExtensions.push(oneDark)
+ if (dark) {
+ mkExtensions.push(oneDark)
+ mkExtensions.push(
+ EditorView.theme({
+ [`.cm-lintRange.${LT_GRAMMAR_MARK_CLASS}`]: {
+ backgroundImage: 'none',
+ paddingBottom: '2px',
+ textDecoration: 'underline',
+ textDecorationSkipInk: 'none',
+ textDecorationStyle: 'wavy',
+ textDecorationColor: '#fdba74',
+ textDecorationThickness: '3px',
+ textUnderlineOffset: '3px'
+ },
+ [`.cm-lintRange-active.${LT_GRAMMAR_MARK_CLASS}`]: {
+ backgroundColor: 'rgba(251, 146, 60, 0.28)'
+ }
+ })
+ )
+ }
const mkState = EditorState.create({
doc: baseSlice.content,
diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx
index ebd6516c..9f3228dd 100644
--- a/src/components/SearchResult/FullTextSearchByRelay.tsx
+++ b/src/components/SearchResult/FullTextSearchByRelay.tsx
@@ -1,6 +1,7 @@
import NoteCard from '@/components/NoteCard'
import RelayIcon from '@/components/RelayIcon'
import { Skeleton } from '@/components/ui/skeleton'
+import { toRelay } from '@/lib/link'
import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
@@ -12,16 +13,19 @@ import type { Event, Filter } from 'nostr-tools'
import { Loader2 } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
+import { useSmartRelayNavigationOptional } from '@/PageManager'
type MergedHit = {
event: Event
relayUrls: string[]
}
-/** Hard cap for the whole merged search wave (from effect start). */
+/** Hard cap for the merged search wave, counted from the first relay query start (not from React effect mount). */
const SEARCH_TOTAL_WALL_MS = 10_000
-/** After the first relay reaches a terminal state, end the wave this many ms later (capped by {@link SEARCH_TOTAL_WALL_MS}). */
-const SEARCH_AFTER_FIRST_RELAY_MS = 2_000
+/** After the first results arrive from any relay, end the wave this many ms later (capped by {@link SEARCH_TOTAL_WALL_MS}). */
+const SEARCH_AFTER_FIRST_RELAY_MS = 3_000
+/** Per-relay {@link QueryService.query} budget from when that relay’s fetch starts (capped by remaining wave wall). */
+const SEARCH_PER_RELAY_QUERY_MS = 10_000
/** Avoid opening every index relay at once (pool + main thread). */
const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3
const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80
@@ -224,6 +228,11 @@ export default function FullTextSearchByRelay({
kinds: readonly number[]
}) {
const { t } = useTranslation()
+ const { navigateToRelay } = useSmartRelayNavigationOptional() ?? {
+ navigateToRelay: (url: string) => {
+ window.location.href = url
+ }
+ }
const runGeneration = useRef(0)
const [relayRows, setRelayRows] = useState
([])
const [mergedHits, setMergedHits] = useState([])
@@ -281,9 +290,10 @@ export default function FullTextSearchByRelay({
)
setMergedHits([])
- const waveT0 = Date.now()
- let waveEndAt = waveT0 + SEARCH_TOTAL_WALL_MS
- /** Only after ≥1 event from a relay: apply "first results + 2s" (empty EOSE must not shorten the wave). */
+ /** Set when the first {@link runOneRelay} begins (first real NIP-50 query); master wall clock starts then. */
+ let waveT0: number | null = null
+ let waveEndAt = 0
+ /** Only after ≥1 event from a relay: apply "first results + …ms" (empty EOSE must not shorten the wave). */
let appliedRelativeWaveCutoff = false
const scheduleMasterAbort = () => {
@@ -298,8 +308,15 @@ export default function FullTextSearchByRelay({
}, ms)
}
+ const beginWaveIfNeeded = () => {
+ if (waveT0 !== null) return
+ waveT0 = Date.now()
+ waveEndAt = waveT0 + SEARCH_TOTAL_WALL_MS
+ scheduleMasterAbort()
+ }
+
const onFirstSearchHits = () => {
- if (appliedRelativeWaveCutoff) return
+ if (appliedRelativeWaveCutoff || waveT0 === null) return
appliedRelativeWaveCutoff = true
const now = Date.now()
waveEndAt = Math.min(waveT0 + SEARCH_TOTAL_WALL_MS, now + SEARCH_AFTER_FIRST_RELAY_MS)
@@ -320,8 +337,6 @@ export default function FullTextSearchByRelay({
{ once: true }
)
- scheduleMasterAbort()
-
let relayCursor = 0
const nextRelayUrl = (): string | undefined => {
if (relayCursor >= normalizedRelays.length) return undefined
@@ -359,8 +374,10 @@ export default function FullTextSearchByRelay({
const runOneRelay = async (relayUrl: string) => {
if (myRun !== runGeneration.current || abort.signal.aborted) return
+ beginWaveIfNeeded()
const t0 = performance.now()
- const perRelayBudget = Math.max(1000, waveEndAt - Date.now())
+ const remainingWaveMs = Math.max(500, waveEndAt - Date.now())
+ const perRelayBudget = Math.min(SEARCH_PER_RELAY_QUERY_MS, remainingWaveMs)
try {
const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay(
relayUrl,
@@ -488,13 +505,18 @@ export default function FullTextSearchByRelay({
{hit.relayUrls.map((url) => (
- {
+ e.stopPropagation()
+ navigateToRelay(toRelay(url))
+ }}
>
-
+
))}
diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx
index 4f5db6b3..6dccd7f9 100644
--- a/src/components/SearchResult/index.tsx
+++ b/src/components/SearchResult/index.tsx
@@ -19,11 +19,11 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const { pubkey, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
- /** Before child effects (e.g. NIP-50) open REQs, tear down idle feed / prefetch queries so search gets the pool. */
+ /** Before child effects (e.g. NIP-50) open REQs, abort background queries and drop pooled relay sockets so search gets the pool. */
useLayoutEffect(() => {
if (!searchParams) return
if (searchParams.type === 'relay') return
- client.interruptBackgroundQueries()
+ client.interruptBackgroundQueries({ closePooledRelayConnections: true })
}, [searchParams?.type, searchParams?.search, searchParams?.input])
/** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */
diff --git a/src/lib/languagetool-cm-linter.ts b/src/lib/languagetool-cm-linter.ts
index e735365a..072d382f 100644
--- a/src/lib/languagetool-cm-linter.ts
+++ b/src/lib/languagetool-cm-linter.ts
@@ -22,6 +22,9 @@ export function requestAdvancedLabGrammarLint(view: EditorView): void {
view.dispatch({ annotations: advancedLabGrammarLangRerun.of(true) })
}
+/** Extra editor mark class so the lab can style LT hits more prominently than default `info` underlines. */
+export const LT_GRAMMAR_MARK_CLASS = 'cm-ltGrammarIssue'
+
function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null {
const from = Math.max(0, Math.min(m.offset, docLen))
const to = Math.max(from, Math.min(m.offset + m.length, docLen))
@@ -30,7 +33,13 @@ function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | n
const message = m.message + (m.rule?.id ? ` (${m.rule.id})` : '')
if (!fix) {
- return { from, to, severity: 'info', message }
+ return {
+ from,
+ to,
+ severity: 'warning',
+ markClass: LT_GRAMMAR_MARK_CLASS,
+ message
+ }
}
/**
@@ -42,7 +51,8 @@ function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | n
return {
from,
to,
- severity: 'info',
+ severity: 'warning',
+ markClass: LT_GRAMMAR_MARK_CLASS,
message,
renderMessage(view: EditorView) {
const wrap = document.createElement('span')
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 483946e7..3b7f8709 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -3125,9 +3125,27 @@ class ClientService extends EventTarget {
set.add(relay)
}
- /** Yield relay pool / HTTP index capacity to search or publish by aborting default {@link QueryService.query} work. */
- interruptBackgroundQueries(): void {
+ /**
+ * Yield capacity to foreground work: abort in-flight {@link QueryService.query} calls that did not pass
+ * `foreground: true` (feeds, prefetch, etc.).
+ * With {@link options.closePooledRelayConnections}, also closes every pooled relay socket so live timeline REQs
+ * release the pool (e.g. NIP-50 search); those subs reconnect when needed.
+ */
+ interruptBackgroundQueries(options?: { closePooledRelayConnections?: boolean }): void {
this.queryService.interruptBackgroundQueries()
+ if (!options?.closePooledRelayConnections) return
+ let urls: string[] = []
+ try {
+ urls = [...this.pool.listConnectionStatus().keys()]
+ } catch {
+ /* ignore */
+ }
+ if (urls.length === 0) return
+ try {
+ this.pool.close(urls)
+ } catch {
+ /* ignore */
+ }
}
// Delegate to QueryService
@@ -3237,6 +3255,19 @@ class ClientService extends EventTarget {
...(options?.signal ? { signal: options.signal } : {})
}
+ if (import.meta.env.DEV) {
+ const f0 = Array.isArray(filter) ? filter[0] : filter
+ const search =
+ f0 && typeof f0 === 'object' && 'search' in f0 && typeof (f0 as { search?: unknown }).search === 'string'
+ ? String((f0 as { search: string }).search).slice(0, 48)
+ : undefined
+ logger.info('[NIP-50] per-relay query', {
+ relay: normalized,
+ budgetMs: queryOpts.globalTimeout,
+ search
+ })
+ }
+
if (isHttpRelayUrl(normalized)) {
// HTTP index relay: use HTTP API instead of WebSocket pool
try {