From 8d6fe750691c2ff1a0f234fb832af4ddbf14152c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 21:47:16 +0200 Subject: [PATCH] bug-fixes --- src/PageManager.tsx | 56 +++++-------------- .../AdvancedEventLabDialog.tsx | 38 ++++++++++++- .../SearchResult/FullTextSearchByRelay.tsx | 48 +++++++++++----- src/components/SearchResult/index.tsx | 4 +- src/lib/languagetool-cm-linter.ts | 14 ++++- src/services/client.service.ts | 35 +++++++++++- 6 files changed, 132 insertions(+), 63 deletions(-) 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 {