From 33dade8dcb9113b9f729b620eea74d2391bb7888 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 08:13:40 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteCard/MainNoteCard.tsx | 2 + src/hooks/useFetchEvent.tsx | 45 ++---- src/lib/resolve-note-event-sync.ts | 27 ++++ .../NotePage/NotePageInstantShell.tsx | 128 ++++++++++++++++++ .../secondary/NotePage/NotePageRoute.tsx | 23 ++++ src/routes.tsx | 4 +- vite.config.ts | 59 ++++++-- 7 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 src/lib/resolve-note-event-sync.ts create mode 100644 src/pages/secondary/NotePage/NotePageInstantShell.tsx create mode 100644 src/pages/secondary/NotePage/NotePageRoute.tsx diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 03e8016c..ab2d76fd 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -3,6 +3,7 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { Separator } from '@/components/ui/separator' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' +import { preloadNotePageChunk } from '@/pages/secondary/NotePage/NotePageRoute' import { useSmartNoteNavigationOptional } from '@/PageManager' import client from '@/services/client.service' import { Pin } from 'lucide-react' @@ -85,6 +86,7 @@ function MainNoteCard({
{ // Don't navigate when user has selected text (e.g. for creating a highlight) const sel = window.getSelection() diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index a83c5ef3..cc574ff6 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -1,9 +1,8 @@ -import { getNoteBech32Id } from '@/lib/event' +import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync' import { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local' import { useIsEventDeleted } from '@/providers/DeletedEventProvider' import { useReplyIngress } from '@/hooks/useReplyIngress' import { eventService } from '@/services/client.service' -import { navigationEventStore } from '@/services/navigation-event-store' import { Event } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react' @@ -15,8 +14,13 @@ export function useFetchEvent( const isEventDeleted = useIsEventDeleted() const { addReplies } = useReplyIngress() const [error, setError] = useState(null) - const [event, setEvent] = useState(initialEvent) - const [isFetching, setIsFetching] = useState(!initialEvent) + const [event, setEvent] = useState(() => + eventId ? resolveNoteEventSync(eventId, initialEvent) : initialEvent + ) + const [isFetching, setIsFetching] = useState(() => { + if (!eventId) return false + return !resolveNoteEventSync(eventId, initialEvent) + }) const [refetchToken, setRefetchToken] = useState(0) const refetch = useCallback(() => { @@ -41,34 +45,11 @@ export function useFetchEvent( const skipShortcuts = refetchToken > 0 - // If we have an initial event that matches the eventId, use it and skip fetching - const initialMatches = - initialEvent && - (initialEvent.id === eventId || - (() => { - try { - return getNoteBech32Id(initialEvent) === eventId - } catch { - return false - } - })()) - if (!skipShortcuts && initialMatches && initialEvent) { - if (!isEventDeleted(initialEvent)) { - setEvent(initialEvent) - addReplies([initialEvent]) - setIsFetching(false) - } - return () => { - cancelled = true - } - } - - // Check navigation event store first (events passed through navigation) — peek so remounts still see it. if (!skipShortcuts) { - const navigationEvent = navigationEventStore.peekEvent(eventId) - if (navigationEvent && !isEventDeleted(navigationEvent)) { - setEvent(navigationEvent) - addReplies([navigationEvent]) + const syncHit = resolveNoteEventSync(eventId, initialEvent) + if (syncHit && !isEventDeleted(syncHit)) { + setEvent(syncHit) + addReplies([syncHit]) setIsFetching(false) return () => { cancelled = true @@ -88,7 +69,7 @@ export function useFetchEvent( if (!skipShortcuts) { const fromLocal = await resolveThreadContextEventFromLocalStores( eventId, - initialMatches ? initialEvent : undefined + resolveNoteEventSync(eventId, initialEvent) ) if (cancelled) return if (fromLocal && !isEventDeleted(fromLocal)) { diff --git a/src/lib/resolve-note-event-sync.ts b/src/lib/resolve-note-event-sync.ts new file mode 100644 index 00000000..ff6a6b57 --- /dev/null +++ b/src/lib/resolve-note-event-sync.ts @@ -0,0 +1,27 @@ +import { getNoteBech32Id } from '@/lib/event' +import client from '@/services/client.service' +import { navigationEventStore } from '@/services/navigation-event-store' +import type { Event } from 'nostr-tools' + +function initialEventMatchesId(initialEvent: Event, eventId: string): boolean { + if (initialEvent.id === eventId) return true + try { + return getNoteBech32Id(initialEvent) === eventId + } catch { + return false + } +} + +/** Synchronous note lookup: navigation store → session cache (feed clicks seed both before the panel mounts). */ +export function resolveNoteEventSync(eventId: string | undefined, initialEvent?: Event): Event | undefined { + if (!eventId?.trim()) return initialEvent + + if (initialEvent && initialEventMatchesId(initialEvent, eventId)) { + return initialEvent + } + + const fromNav = navigationEventStore.peekEvent(eventId) + if (fromNav) return fromNav + + return client.peekSessionCachedEvent(eventId.trim()) +} diff --git a/src/pages/secondary/NotePage/NotePageInstantShell.tsx b/src/pages/secondary/NotePage/NotePageInstantShell.tsx new file mode 100644 index 00000000..1683fe4e --- /dev/null +++ b/src/pages/secondary/NotePage/NotePageInstantShell.tsx @@ -0,0 +1,128 @@ +import ContentPreview from '@/components/ContentPreview' +import UserAvatar from '@/components/UserAvatar' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { + getParentBech32Id, + getParentEventHexId, + getRootBech32Id, + getRootEventHexId +} from '@/lib/event' +import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' +import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync' +import client from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { useTranslation } from 'react-i18next' + +function peekThreadContextEvent(bech32OrHex: string | undefined): Event | undefined { + if (!bech32OrHex?.trim()) return undefined + return client.peekSessionCachedEvent(bech32OrHex.trim()) +} + +function ThreadContextPreview({ event }: { event: Event }) { + return ( +
+
+ +
+ +
+
+
+ ) +} + +/** + * Shown while the lazy NotePage chunk loads. Renders the clicked note from the navigation/session + * cache when available so the secondary panel is not blank. + */ +export default function NotePageInstantShell({ + id, + index, + hideTitlebar = false, + initialEvent +}: { + id?: string + index?: number + hideTitlebar?: boolean + initialEvent?: Event +}) { + const { t } = useTranslation() + const event = resolveNoteEventSync(id, initialEvent) + + if (!event) { + return ( + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ ) + } + + const cachedContext = getCachedThreadContextEvents(event) + const parentBech32 = getParentBech32Id(event) + const rootBech32 = getRootBech32Id(event) + const parentHex = getParentEventHexId(event)?.toLowerCase() + const rootHex = getRootEventHexId(event)?.toLowerCase() + const selfHex = event.id.toLowerCase() + + const parentEvent = + cachedContext.find((e) => parentHex && e.id.toLowerCase() === parentHex) ?? + peekThreadContextEvent(parentBech32) + const rootEvent = + cachedContext.find((e) => rootHex && e.id.toLowerCase() === rootHex) ?? + peekThreadContextEvent(rootBech32) + + const parentEventForStrip = + parentEvent && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined + const rootEventForStrip = + rootEvent && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined + + return ( + +
+ {rootEventForStrip && parentEventForStrip?.id !== rootEventForStrip.id && ( + + )} + {parentEventForStrip && ( + <> + +
+ + )} + {(rootEventForStrip || parentEventForStrip) && } +
+
+ +
+ +
+
+
+
+ + ) +} diff --git a/src/pages/secondary/NotePage/NotePageRoute.tsx b/src/pages/secondary/NotePage/NotePageRoute.tsx new file mode 100644 index 00000000..e532ad7f --- /dev/null +++ b/src/pages/secondary/NotePage/NotePageRoute.tsx @@ -0,0 +1,23 @@ +import type { TPageRef } from '@/types' +import type { Event } from 'nostr-tools' +import { forwardRef, lazy, Suspense } from 'react' +import NotePageInstantShell from './NotePageInstantShell' + +const NotePageLazy = lazy(() => import('./index')) + +/** Sync entry: instant shell while the lazy NotePage chunk loads. */ +const NotePageRoute = forwardRef< + TPageRef, + { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event } +>((props, ref) => ( + }> + + +)) +NotePageRoute.displayName = 'NotePageRoute' +export default NotePageRoute + +/** Warm the note panel chunk on feed hover / pointer-down. */ +export function preloadNotePageChunk(): void { + void import('./index') +} diff --git a/src/routes.tsx b/src/routes.tsx index 814ea2cb..52e70133 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -7,6 +7,7 @@ import { type LazyExoticComponent, type ReactElement } from 'react' +import NotePageRoute from './pages/secondary/NotePage/NotePageRoute' /** Lazy + Suspense so importing `routes` does not sync-pull pages that depend on PageManager (breaks Vite HMR cycles). */ const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage')) @@ -28,7 +29,6 @@ const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage')) const ProfileBadgesListPageLazy = lazy(() => import('./pages/secondary/ProfileBadgesListPage')) const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) -const NotePageLazy = lazy(() => import('./pages/secondary/NotePage')) const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage')) const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPage')) const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage')) @@ -60,7 +60,7 @@ function SR(C: LazyExoticComponent>): ReactElement { ) } -const notePageElement = SR(NotePageLazy) +const notePageElement = const noteListPageElement = SR(NoteListPageLazy) const rssArticlePageElement = SR(RssArticlePageLazy) diff --git a/vite.config.ts b/vite.config.ts index 5ff69fdf..67dd7809 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,7 +37,11 @@ function fullReloadOnProvidersAndPages(): Plugin { apply: 'serve', handleHotUpdate({ file, server }) { const normalized = file.replace(/\\/g, '/') - if (normalized.includes('/src/providers/') || normalized.includes('/src/pages/')) { + if ( + normalized.includes('/src/providers/') || + normalized.includes('/src/pages/') || + normalized.endsWith('/src/PageManager.tsx') + ) { server.ws.send({ type: 'full-reload' }) return [] } @@ -61,25 +65,38 @@ function blobFromLogArgs(args: unknown[]): string { /** * `http-proxy` logs `Error: connect ECONNREFUSED …` via `console.error`, bypassing Vite's `logger.error`. */ +const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [ + '/api/languagetool', + '/v2/', + '/api/piper-tts', + '/api/translate', + '/sites', + '/dev-index-relay', + '/dev-cors-index-relay', + '/api/events' +] as const + +function isDevProxyConnectivityNoise(blob: string): boolean { + return ( + blob.includes('ECONNREFUSED') || + blob.includes('ETIMEDOUT') || + blob.includes('ECONNRESET') || + blob.includes('EHOSTUNREACH') + ) +} + function isOptionalDevProxyConnRefusedNoise(args: unknown[]): boolean { const blob = blobFromLogArgs(args) - if (!blob.includes('ECONNREFUSED')) return false + if (!isDevProxyConnectivityNoise(blob)) return false + if (DEV_INDEX_RELAY_PROXY_PATH_MARKERS.some((m) => blob.includes(m))) return true if (blob.includes('127.0.0.1:') || blob.includes('localhost:')) return true return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => new RegExp(`\\b:${port}\\b`).test(blob)) } function isOptionalDevProxyHttpError(text: string): boolean { if (!text.includes('http proxy error')) return false - if (!text.includes('ECONNREFUSED')) return false - if ( - text.includes('/api/languagetool') || - text.includes('/v2/') || - text.includes('/api/piper-tts') || - text.includes('/api/translate') || - text.includes('/sites') || - text.includes('/dev-index-relay') || - text.includes('/api/events') - ) { + if (!isDevProxyConnectivityNoise(text)) return false + if (DEV_INDEX_RELAY_PROXY_PATH_MARKERS.some((m) => text.includes(m))) { return true } return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => text.includes(`127.0.0.1:${port}`)) @@ -259,7 +276,14 @@ export default defineConfig(({ mode }) => { '/dev-index-relay': { target: devIndexRelayTarget, changeOrigin: true, - rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/' + timeout: 12_000, + proxyTimeout: 12_000, + rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/', + configure: jsonProxyErrorHandler(502, { + ok: false, + error: 'dev_index_relay_unreachable', + hint: 'Start the local index relay (VITE_DEV_INDEX_RELAY_TARGET, default :4000) or disable that kind-10243 URL in dev' + }) }, /** * Some public index relays (e.g. nos.lol) omit `Content-Type` from CORS preflight @@ -270,7 +294,14 @@ export default defineConfig(({ mode }) => { target: devCorsIndexRelayTarget, changeOrigin: true, secure: true, - rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/' + timeout: 12_000, + proxyTimeout: 12_000, + rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/', + configure: jsonProxyErrorHandler(502, { + ok: false, + error: 'cors_index_relay_unreachable', + hint: 'Remote index relay unreachable — check network or VITE_DEV_CORS_INDEX_RELAY_TARGET' + }) } } },