From 8b78a2751a832a55783f971fca4f3ab5f3d4264f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 21 May 2026 21:16:18 +0200 Subject: [PATCH] bug-fixes --- package-lock.json | 4 +- package.json | 2 +- src/PageManager.tsx | 75 ++++++--------- src/lib/live-activities.ts | 3 +- src/pages/secondary/NotePage/index.tsx | 6 -- src/providers/LiveActivitiesProvider.tsx | 117 +++++++++++++++-------- src/providers/NostrProvider/index.tsx | 5 + src/routes.tsx | 94 ++++++++++++++---- 8 files changed, 194 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index b400e5a9..f65e3851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.13.6", + "version": "23.13.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.13.6", + "version": "23.13.7", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 5f92a474..39a5c13c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.13.6", + "version": "23.13.7", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 2269001e..641465cc 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -56,7 +56,7 @@ import { import { normalizeUrl } from './lib/url' import modalManager from './services/modal-manager.service' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' -import { routes } from './routes' +import { matchAppRoute } from './routes' import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider' import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context' import { @@ -67,6 +67,9 @@ import { } from '@/contexts/primary-note-view-context' import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-context' +/** Survives React StrictMode remount so initial URL → secondary stack is not built twice. */ +let historyLocationSeedApplied = false + /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */ const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage')) /** Lazy NoteList pages break: PageManager → … → NoteList → NoteCard → useSmartNoteNavigation → PageManager */ @@ -1235,8 +1238,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Don't clear noteId here — scheduled in the drawer-close effect after the sheet animation. }, [drawerOpen]) const ignorePopStateRef = useRef(false) - /** Avoid duplicating history entries when drawer/mode deps re-run the PageManager effect. */ - const historySeedDoneRef = useRef(false) /** When set before closing the note drawer, replaceState uses this URL instead of buildPrimaryPageUrl (popstate edge cases). */ const pendingDrawerCloseUrlRef = useRef(null) @@ -1285,8 +1286,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }, [primaryNoteView, drawerOpen]) useEffect(() => { - if (!historySeedDoneRef.current) { - historySeedDoneRef.current = true + if (historyLocationSeedApplied) return + historyLocationSeedApplied = true if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { window.history.replaceState( null, @@ -1564,7 +1565,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // which is handled elsewhere } } - } const onPopState = (e: PopStateEvent) => { if (ignorePopStateRef.current) { @@ -2579,49 +2579,34 @@ function syncSecondaryStackWhenPopStateStateIsNull(pre: TStackItem[], locUrl: st function findAndCreateComponent(url: string, index: number) { const path = url.split('?')[0].split('#')[0] - logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length }) - - for (const { matcher, element } of routes) { - const match = matcher(path) - logger.component('PageManager', 'Trying route matcher', { path, matchResult: !!match, matchParams: match ? (match as any).params : null }) - if (!match) continue + const matched = matchAppRoute(path) + if (!matched?.element) { + logger.component('PageManager', 'No matching route found', { path, url }) + return {} + } - if (!element) { - logger.component('PageManager', 'No element for this route', { path }) - return {} - } - const ref = createRef() - - // Decode URL parameters for relay pages - const params = { ...(match as any).params } - if (params.url && typeof params.url === 'string') { - params.url = decodeURIComponent(params.url) - logger.component('PageManager', 'Decoded URL parameter', { url: params.url }) - } - - const noteRouteId = typeof params.id === 'string' ? params.id : undefined - const initialEvent = noteRouteId ? navigationEventStore.peekEvent(noteRouteId) : undefined - logger.component('PageManager', 'Creating component with params', { - params, + const ref = createRef() + + // Decode URL parameters for relay pages + const params = { ...matched.params } + if (params.url && typeof params.url === 'string') { + params.url = decodeURIComponent(params.url) + } + + const noteRouteId = typeof params.id === 'string' ? params.id : undefined + const initialEvent = noteRouteId ? navigationEventStore.peekEvent(noteRouteId) : undefined + try { + const component = cloneSecondaryRouteElement(matched.element, { + ...params, index, - hasInitialEvent: !!initialEvent + ref, + ...(initialEvent ? { initialEvent } : {}) }) - try { - const component = cloneSecondaryRouteElement(element, { - ...params, - index, - ref, - ...(initialEvent ? { initialEvent } : {}) - }) - logger.component('PageManager', 'Component created successfully', { hasComponent: !!component }) - return { component, ref } - } catch (error) { - logger.error('PageManager', 'Error creating component', { error, params }) - return {} - } + return { component, ref } + } catch (error) { + logger.error('PageManager', 'Error creating component', { error, params }) + return {} } - logger.component('PageManager', 'No matching route found', { path, url }) - return {} } function pushNewPageToStack( diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index 7bdb2e86..b326fae9 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -823,8 +823,9 @@ async function isInlinePlaybackUrlReachable(url: string, timeoutMs: number): Pro */ export async function filterLiveActivityItemsByReachableMedia( items: TLiveActivityItem[], - timeoutMs = LIVE_ACTIVITIES_STREAM_PROBE_MS + options?: { timeoutMs?: number } ): Promise { + const timeoutMs = options?.timeoutMs ?? LIVE_ACTIVITIES_STREAM_PROBE_MS const checked = await Promise.all( items.map(async (item) => { if (item.kind !== 30311) return item diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 11bef7c1..9bb1a114 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -217,12 +217,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: // Fetch profile for author (for OpenGraph metadata) const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey) - useEffect(() => { - const pk = finalEvent?.pubkey?.trim().toLowerCase() - if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return - void client.fetchProfilesForPubkeys([pk]) - }, [finalEvent?.id, finalEvent?.pubkey]) - /** Resolve nostr embeds with the open note (parent relay hints), before embed cards mount. */ useLayoutEffect(() => { if (!finalEvent) return diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 76328003..beb87aa0 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -45,6 +45,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState>(() => new Set()) const rawItemsRef = useRef([]) const hiddenCarouselRef = useRef>(new Set()) + const refreshInFlightRef = useRef | null>(null) + const lastRefreshFinishedAtRef = useRef(0) + /** Collapse boot + session-prewarm + StrictMode into one network pass. */ + const LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS = 8_000 const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const relayWrite = relayList?.write ?? [] @@ -55,47 +59,70 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode setItems([]) return } - const loggedIn = Boolean(pubkey) - const urls = buildLiveActivitiesRelayUrls({ - loggedIn, - favoriteRelays, - blockedRelays, - relayListRead: relayRead, - relayListWrite: relayWrite, - includeGlobalFastRead: useGlobalBootstrap - }) - if (urls.length === 0) { - rawItemsRef.current = [] - setItems([]) + const now = Date.now() + if (refreshInFlightRef.current) { + return refreshInFlightRef.current + } + if (now - lastRefreshFinishedAtRef.current < LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS) { return } - setLoading(true) - try { - const events = await client.fetchEvents( - urls, - { kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 }, - { eoseTimeout: 6000, globalTimeout: 14_000 } - ) - const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) => - client.fetchEvents(u, f, o) - ) - const merged = mergeLiveActivityEvents(events, followings, parentByAddress) - const reachable = await filterLiveActivityItemsByReachableMedia(merged) - rawItemsRef.current = reachable - setItems(reachable.filter((i) => !hiddenCarouselRef.current.has(i.address))) - logger.debug('[LiveActivities] poll done', { - relayCount: urls.length, - raw: events.length, - merged: merged.length, - afterStreamProbe: reachable.length + + const run = async () => { + const loggedIn = Boolean(pubkey) + const urls = buildLiveActivitiesRelayUrls({ + loggedIn, + favoriteRelays, + blockedRelays, + relayListRead: relayRead, + relayListWrite: relayWrite, + includeGlobalFastRead: useGlobalBootstrap }) - } catch (e) { - logger.warn('[LiveActivities] poll failed', { err: e }) - rawItemsRef.current = [] - setItems([]) - } finally { - setLoading(false) + if (urls.length === 0) { + rawItemsRef.current = [] + setItems([]) + return + } + setLoading(true) + try { + const events = await client.fetchEvents( + urls, + { kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 }, + { eoseTimeout: 6000, globalTimeout: 14_000 } + ) + const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) => + client.fetchEvents(u, f, o) + ) + const merged = mergeLiveActivityEvents(events, followings, parentByAddress) + const visible = merged.filter((i) => !hiddenCarouselRef.current.has(i.address)) + rawItemsRef.current = merged + setItems(visible) + lastRefreshFinishedAtRef.current = Date.now() + logger.debug('[LiveActivities] poll done', { + relayCount: urls.length, + raw: events.length, + merged: merged.length, + afterStreamProbe: visible.length + }) + void filterLiveActivityItemsByReachableMedia(merged, { timeoutMs: 2500 }) + .then((reachable) => { + rawItemsRef.current = reachable + setItems(reachable.filter((i) => !hiddenCarouselRef.current.has(i.address))) + }) + .catch(() => { + /* keep visible list from merged */ + }) + } catch (e) { + logger.warn('[LiveActivities] poll failed', { err: e }) + rawItemsRef.current = [] + setItems([]) + } finally { + setLoading(false) + refreshInFlightRef.current = null + } } + + refreshInFlightRef.current = run() + return refreshInFlightRef.current }, [ showLiveActivitiesBanner, pubkey, @@ -150,7 +177,21 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode } if (!isInitialized) return if (pubkey && isAccountSessionHydrating) return - void refresh() + + const schedule = () => { + void refreshRef.current() + } + const idleId = + typeof requestIdleCallback === 'function' + ? requestIdleCallback(schedule, { timeout: 6_000 }) + : window.setTimeout(schedule, 2_000) + return () => { + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(idleId as number) + } else { + window.clearTimeout(idleId as number) + } + } }, [ showLiveActivitiesBanner, isInitialized, diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 45cb6b5a..1dd0af50 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -72,6 +72,9 @@ import { NsecSigner } from './nsec.signer' export { useNostr } from '@/providers/nostr-context' export type { TNostrContext } from '@/providers/nostr-context' +/** One session-restore pass per full page load (React StrictMode remount must not re-login). */ +let nostrSessionRestoreStarted = false + /** Kind 10012 `relay` tags for publish / target-relay prioritization. */ function favoriteRelayUrlsForPublish( favoriteRelaysEvent: Event | null, @@ -176,6 +179,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }, [account]) useEffect(() => { + if (nostrSessionRestoreStarted) return + nostrSessionRestoreStarted = true const init = async () => { logger.debug('[NostrProvider] Restoring session (login / first account)…') if (hasNostrLoginHash()) { diff --git a/src/routes.tsx b/src/routes.tsx index d600c2a2..a43b280e 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -59,27 +59,44 @@ function SR(C: LazyExoticComponent>): ReactElement { ) } +const notePageElement = SR(NotePageLazy) +const noteListPageElement = SR(NoteListPageLazy) +const rssArticlePageElement = SR(RssArticlePageLazy) + +/** Primary segments used in contextual `/…/notes/:id` and `/…/rss-item/:key` routes. */ +const CONTEXTUAL_ROUTE_PREFIXES = + 'discussions|search|profile|home|feed|spells|explore|rss|calendar' + +const contextualNotePathRe = new RegExp( + `^/(${CONTEXTUAL_ROUTE_PREFIXES})/notes/([^/?#]+)$` +) +const standardNotePathRe = /^\/notes\/([^/?#]+)$/ +const contextualRssItemPathRe = new RegExp( + `^/(${CONTEXTUAL_ROUTE_PREFIXES})/rss-item/([^/?#]+)$` +) +const standardRssItemPathRe = /^\/rss-item\/([^/?#]+)$/ + const ROUTES = [ - { path: '/notes', element: SR(NoteListPageLazy) }, - { path: '/notes/:id', element: SR(NotePageLazy) }, - { path: '/discussions/notes/:id', element: SR(NotePageLazy) }, - { path: '/search/notes/:id', element: SR(NotePageLazy) }, - { path: '/profile/notes/:id', element: SR(NotePageLazy) }, - { path: '/explore/notes/:id', element: SR(NotePageLazy) }, - { path: '/home/notes/:id', element: SR(NotePageLazy) }, - { path: '/feed/notes/:id', element: SR(NotePageLazy) }, - { path: '/spells/notes/:id', element: SR(NotePageLazy) }, - { path: '/rss/notes/:id', element: SR(NotePageLazy) }, - { path: '/calendar/notes/:id', element: SR(NotePageLazy) }, + { path: '/notes', element: noteListPageElement }, + { path: '/notes/:id', element: notePageElement }, + { path: '/discussions/notes/:id', element: notePageElement }, + { path: '/search/notes/:id', element: notePageElement }, + { path: '/profile/notes/:id', element: notePageElement }, + { path: '/explore/notes/:id', element: notePageElement }, + { path: '/home/notes/:id', element: notePageElement }, + { path: '/feed/notes/:id', element: notePageElement }, + { path: '/spells/notes/:id', element: notePageElement }, + { path: '/rss/notes/:id', element: notePageElement }, + { path: '/calendar/notes/:id', element: notePageElement }, { path: '/calendar/day/:ymd', element: SR(CalendarDayEventsPageLazy) }, - { path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/home/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, + { path: '/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/rss/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/feed/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/search/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/profile/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/spells/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/explore/rss-item/:articleKey', element: rssArticlePageElement }, + { path: '/home/rss-item/:articleKey', element: rssArticlePageElement }, { path: '/users', element: SR(ProfileListPageLazy) }, { path: '/users/:id/following', element: SR(FollowingListPageLazy) }, { path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) }, @@ -116,3 +133,42 @@ export const routes = ROUTES.map(({ path, element }) => ({ element: isValidElement(element) ? element : null, matcher: match(path) })) + +export type TMatchedAppRoute = { + element: ReactElement | null + params: Record +} + +/** + * Resolve secondary-panel routes without scanning the full table for common deep links + * (contextual note / RSS article URLs are tried first; everything else falls back to matchers). + */ +export function matchAppRoute(pathname: string): TMatchedAppRoute | null { + const path = pathname.split('?')[0].split('#')[0] + + const ctxNote = contextualNotePathRe.exec(path) + if (ctxNote) { + return { element: notePageElement, params: { id: ctxNote[2]! } } + } + const stdNote = standardNotePathRe.exec(path) + if (stdNote) { + return { element: notePageElement, params: { id: stdNote[1]! } } + } + + const ctxRss = contextualRssItemPathRe.exec(path) + if (ctxRss) { + return { element: rssArticlePageElement, params: { articleKey: ctxRss[2]! } } + } + const stdRss = standardRssItemPathRe.exec(path) + if (stdRss) { + return { element: rssArticlePageElement, params: { articleKey: stdRss[1]! } } + } + + for (const { matcher, element } of routes) { + const m = matcher(path) + if (m) { + return { element, params: (m as { params: Record }).params } + } + } + return null +}