Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
33dade8dcb
  1. 2
      src/components/NoteCard/MainNoteCard.tsx
  2. 45
      src/hooks/useFetchEvent.tsx
  3. 27
      src/lib/resolve-note-event-sync.ts
  4. 128
      src/pages/secondary/NotePage/NotePageInstantShell.tsx
  5. 23
      src/pages/secondary/NotePage/NotePageRoute.tsx
  6. 4
      src/routes.tsx
  7. 59
      vite.config.ts

2
src/components/NoteCard/MainNoteCard.tsx

@ -3,6 +3,7 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { preloadNotePageChunk } from '@/pages/secondary/NotePage/NotePageRoute'
import { useSmartNoteNavigationOptional } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
@ -85,6 +86,7 @@ function MainNoteCard({
<div <div
className={className} className={className}
data-event-id={event.id} data-event-id={event.id}
onPointerEnter={preloadNotePageChunk}
onClick={(e) => { onClick={(e) => {
// Don't navigate when user has selected text (e.g. for creating a highlight) // Don't navigate when user has selected text (e.g. for creating a highlight)
const sel = window.getSelection() const sel = window.getSelection()

45
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 { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local'
import { useIsEventDeleted } from '@/providers/DeletedEventProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
@ -15,8 +14,13 @@ export function useFetchEvent(
const isEventDeleted = useIsEventDeleted() const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress() const { addReplies } = useReplyIngress()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) const [event, setEvent] = useState<Event | undefined>(() =>
const [isFetching, setIsFetching] = useState(!initialEvent) eventId ? resolveNoteEventSync(eventId, initialEvent) : initialEvent
)
const [isFetching, setIsFetching] = useState(() => {
if (!eventId) return false
return !resolveNoteEventSync(eventId, initialEvent)
})
const [refetchToken, setRefetchToken] = useState(0) const [refetchToken, setRefetchToken] = useState(0)
const refetch = useCallback(() => { const refetch = useCallback(() => {
@ -41,34 +45,11 @@ export function useFetchEvent(
const skipShortcuts = refetchToken > 0 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) { if (!skipShortcuts) {
const navigationEvent = navigationEventStore.peekEvent(eventId) const syncHit = resolveNoteEventSync(eventId, initialEvent)
if (navigationEvent && !isEventDeleted(navigationEvent)) { if (syncHit && !isEventDeleted(syncHit)) {
setEvent(navigationEvent) setEvent(syncHit)
addReplies([navigationEvent]) addReplies([syncHit])
setIsFetching(false) setIsFetching(false)
return () => { return () => {
cancelled = true cancelled = true
@ -88,7 +69,7 @@ export function useFetchEvent(
if (!skipShortcuts) { if (!skipShortcuts) {
const fromLocal = await resolveThreadContextEventFromLocalStores( const fromLocal = await resolveThreadContextEventFromLocalStores(
eventId, eventId,
initialMatches ? initialEvent : undefined resolveNoteEventSync(eventId, initialEvent)
) )
if (cancelled) return if (cancelled) return
if (fromLocal && !isEventDeleted(fromLocal)) { if (fromLocal && !isEventDeleted(fromLocal)) {

27
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())
}

128
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 (
<div className="mb-2 rounded-lg border border-border/80 bg-muted/20 px-3 py-2 opacity-90">
<div className="flex items-center gap-2">
<UserAvatar userId={event.pubkey} size="small" className="shrink-0" deferRemoteAvatar={false} />
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
<ContentPreview event={event} />
</div>
</div>
</div>
)
}
/**
* 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 (
<SecondaryPageLayout
index={index}
title={hideTitlebar ? undefined : t('Note')}
>
<div className="px-4 pt-3">
<div className="flex items-center space-x-2">
<Skeleton className="h-10 w-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="my-1 mt-2 h-4 w-full" />
</div>
<div className="my-1">
<Skeleton className="my-1 h-4 w-2/3" />
</div>
</div>
</div>
</SecondaryPageLayout>
)
}
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 (
<SecondaryPageLayout index={index} title={hideTitlebar ? undefined : t('Note')}>
<div className="w-full px-4 pt-3">
{rootEventForStrip && parentEventForStrip?.id !== rootEventForStrip.id && (
<ThreadContextPreview event={rootEventForStrip} />
)}
{parentEventForStrip && (
<>
<ThreadContextPreview event={parentEventForStrip} />
<div className="ml-5 h-3 w-px bg-border" />
</>
)}
{(rootEventForStrip || parentEventForStrip) && <Separator className="my-3" />}
<div className="select-text">
<div className="flex items-start gap-2">
<UserAvatar userId={event.pubkey} size="normal" className="shrink-0" deferRemoteAvatar={false} />
<div className="min-w-0 flex-1">
<ContentPreview event={event} className="text-base text-foreground whitespace-pre-wrap break-words" />
</div>
</div>
</div>
</div>
</SecondaryPageLayout>
)
}

23
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) => (
<Suspense fallback={<NotePageInstantShell {...props} />}>
<NotePageLazy {...props} ref={ref} />
</Suspense>
))
NotePageRoute.displayName = 'NotePageRoute'
export default NotePageRoute
/** Warm the note panel chunk on feed hover / pointer-down. */
export function preloadNotePageChunk(): void {
void import('./index')
}

4
src/routes.tsx

@ -7,6 +7,7 @@ import {
type LazyExoticComponent, type LazyExoticComponent,
type ReactElement type ReactElement
} from 'react' } 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). */ /** 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')) 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 ProfileBadgesListPageLazy = lazy(() => import('./pages/secondary/ProfileBadgesListPage'))
const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage')) const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage')) const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage'))
const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPage')) const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPage'))
const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage')) const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage'))
@ -60,7 +60,7 @@ function SR(C: LazyExoticComponent<ComponentType<any>>): ReactElement {
) )
} }
const notePageElement = SR(NotePageLazy) const notePageElement = <NotePageRoute />
const noteListPageElement = SR(NoteListPageLazy) const noteListPageElement = SR(NoteListPageLazy)
const rssArticlePageElement = SR(RssArticlePageLazy) const rssArticlePageElement = SR(RssArticlePageLazy)

59
vite.config.ts

@ -37,7 +37,11 @@ function fullReloadOnProvidersAndPages(): Plugin {
apply: 'serve', apply: 'serve',
handleHotUpdate({ file, server }) { handleHotUpdate({ file, server }) {
const normalized = file.replace(/\\/g, '/') 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' }) server.ws.send({ type: 'full-reload' })
return [] return []
} }
@ -61,25 +65,38 @@ function blobFromLogArgs(args: unknown[]): string {
/** /**
* `http-proxy` logs `Error: connect ECONNREFUSED …` via `console.error`, bypassing Vite's `logger.error`. * `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 { function isOptionalDevProxyConnRefusedNoise(args: unknown[]): boolean {
const blob = blobFromLogArgs(args) 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 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)) return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => new RegExp(`\\b:${port}\\b`).test(blob))
} }
function isOptionalDevProxyHttpError(text: string): boolean { function isOptionalDevProxyHttpError(text: string): boolean {
if (!text.includes('http proxy error')) return false if (!text.includes('http proxy error')) return false
if (!text.includes('ECONNREFUSED')) return false if (!isDevProxyConnectivityNoise(text)) return false
if ( if (DEV_INDEX_RELAY_PROXY_PATH_MARKERS.some((m) => text.includes(m))) {
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')
) {
return true return true
} }
return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => text.includes(`127.0.0.1:${port}`)) 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': { '/dev-index-relay': {
target: devIndexRelayTarget, target: devIndexRelayTarget,
changeOrigin: true, 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 * Some public index relays (e.g. nos.lol) omit `Content-Type` from CORS preflight
@ -270,7 +294,14 @@ export default defineConfig(({ mode }) => {
target: devCorsIndexRelayTarget, target: devCorsIndexRelayTarget,
changeOrigin: true, changeOrigin: true,
secure: 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'
})
} }
} }
}, },

Loading…
Cancel
Save