Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
8b78a2751a
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 71
      src/PageManager.tsx
  4. 3
      src/lib/live-activities.ts
  5. 6
      src/pages/secondary/NotePage/index.tsx
  6. 117
      src/providers/LiveActivitiesProvider.tsx
  7. 5
      src/providers/NostrProvider/index.tsx
  8. 94
      src/routes.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.13.6", "version": "23.13.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.13.6", "version": "23.13.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

71
src/PageManager.tsx

@ -56,7 +56,7 @@ import {
import { normalizeUrl } from './lib/url' import { normalizeUrl } from './lib/url'
import modalManager from './services/modal-manager.service' import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
import { routes } from './routes' import { matchAppRoute } from './routes'
import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider' import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider'
import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context' import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context'
import { import {
@ -67,6 +67,9 @@ import {
} from '@/contexts/primary-note-view-context' } from '@/contexts/primary-note-view-context'
import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-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). */ /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage')) const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
/** Lazy NoteList pages break: PageManager → … → NoteList → NoteCard → useSmartNoteNavigation → PageManager */ /** 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. // Don't clear noteId here — scheduled in the drawer-close effect after the sheet animation.
}, [drawerOpen]) }, [drawerOpen])
const ignorePopStateRef = useRef(false) 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). */ /** When set before closing the note drawer, replaceState uses this URL instead of buildPrimaryPageUrl (popstate edge cases). */
const pendingDrawerCloseUrlRef = useRef<string | null>(null) const pendingDrawerCloseUrlRef = useRef<string | null>(null)
@ -1285,8 +1286,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}, [primaryNoteView, drawerOpen]) }, [primaryNoteView, drawerOpen])
useEffect(() => { useEffect(() => {
if (!historySeedDoneRef.current) { if (historyLocationSeedApplied) return
historySeedDoneRef.current = true historyLocationSeedApplied = true
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
window.history.replaceState( window.history.replaceState(
null, null,
@ -1564,7 +1565,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// which is handled elsewhere // which is handled elsewhere
} }
} }
}
const onPopState = (e: PopStateEvent) => { const onPopState = (e: PopStateEvent) => {
if (ignorePopStateRef.current) { if (ignorePopStateRef.current) {
@ -2579,49 +2579,34 @@ function syncSecondaryStackWhenPopStateStateIsNull(pre: TStackItem[], locUrl: st
function findAndCreateComponent(url: string, index: number) { function findAndCreateComponent(url: string, index: number) {
const path = url.split('?')[0].split('#')[0] const path = url.split('?')[0].split('#')[0]
logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length }) const matched = matchAppRoute(path)
if (!matched?.element) {
for (const { matcher, element } of routes) { logger.component('PageManager', 'No matching route found', { path, url })
const match = matcher(path) return {}
logger.component('PageManager', 'Trying route matcher', { path, matchResult: !!match, matchParams: match ? (match as any).params : null }) }
if (!match) continue
if (!element) { const ref = createRef<TPageRef>()
logger.component('PageManager', 'No element for this route', { path })
return {}
}
const ref = createRef<TPageRef>()
// Decode URL parameters for relay pages // Decode URL parameters for relay pages
const params = { ...(match as any).params } const params = { ...matched.params }
if (params.url && typeof params.url === 'string') { if (params.url && typeof params.url === 'string') {
params.url = decodeURIComponent(params.url) params.url = decodeURIComponent(params.url)
logger.component('PageManager', 'Decoded URL parameter', { url: params.url }) }
}
const noteRouteId = typeof params.id === 'string' ? params.id : undefined const noteRouteId = typeof params.id === 'string' ? params.id : undefined
const initialEvent = noteRouteId ? navigationEventStore.peekEvent(noteRouteId) : undefined const initialEvent = noteRouteId ? navigationEventStore.peekEvent(noteRouteId) : undefined
logger.component('PageManager', 'Creating component with params', { try {
params, const component = cloneSecondaryRouteElement(matched.element, {
...params,
index, index,
hasInitialEvent: !!initialEvent ref,
...(initialEvent ? { initialEvent } : {})
}) })
try { return { component, ref }
const component = cloneSecondaryRouteElement(element, { } catch (error) {
...params, logger.error('PageManager', 'Error creating component', { error, params })
index, return {}
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 {}
}
} }
logger.component('PageManager', 'No matching route found', { path, url })
return {}
} }
function pushNewPageToStack( function pushNewPageToStack(

3
src/lib/live-activities.ts

@ -823,8 +823,9 @@ async function isInlinePlaybackUrlReachable(url: string, timeoutMs: number): Pro
*/ */
export async function filterLiveActivityItemsByReachableMedia( export async function filterLiveActivityItemsByReachableMedia(
items: TLiveActivityItem[], items: TLiveActivityItem[],
timeoutMs = LIVE_ACTIVITIES_STREAM_PROBE_MS options?: { timeoutMs?: number }
): Promise<TLiveActivityItem[]> { ): Promise<TLiveActivityItem[]> {
const timeoutMs = options?.timeoutMs ?? LIVE_ACTIVITIES_STREAM_PROBE_MS
const checked = await Promise.all( const checked = await Promise.all(
items.map(async (item) => { items.map(async (item) => {
if (item.kind !== 30311) return item if (item.kind !== 30311) return item

6
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) // Fetch profile for author (for OpenGraph metadata)
const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey) 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. */ /** Resolve nostr embeds with the open note (parent relay hints), before embed cards mount. */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!finalEvent) return if (!finalEvent) return

117
src/providers/LiveActivitiesProvider.tsx

@ -45,6 +45,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set()) const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set())
const rawItemsRef = useRef<TLiveActivityItem[]>([]) const rawItemsRef = useRef<TLiveActivityItem[]>([])
const hiddenCarouselRef = useRef<Set<string>>(new Set()) const hiddenCarouselRef = useRef<Set<string>>(new Set())
const refreshInFlightRef = useRef<Promise<void> | 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 relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList])
const relayWrite = relayList?.write ?? [] const relayWrite = relayList?.write ?? []
@ -55,47 +59,70 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
setItems([]) setItems([])
return return
} }
const loggedIn = Boolean(pubkey) const now = Date.now()
const urls = buildLiveActivitiesRelayUrls({ if (refreshInFlightRef.current) {
loggedIn, return refreshInFlightRef.current
favoriteRelays, }
blockedRelays, if (now - lastRefreshFinishedAtRef.current < LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS) {
relayListRead: relayRead,
relayListWrite: relayWrite,
includeGlobalFastRead: useGlobalBootstrap
})
if (urls.length === 0) {
rawItemsRef.current = []
setItems([])
return return
} }
setLoading(true)
try { const run = async () => {
const events = await client.fetchEvents( const loggedIn = Boolean(pubkey)
urls, const urls = buildLiveActivitiesRelayUrls({
{ kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 }, loggedIn,
{ eoseTimeout: 6000, globalTimeout: 14_000 } favoriteRelays,
) blockedRelays,
const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) => relayListRead: relayRead,
client.fetchEvents(u, f, o) relayListWrite: relayWrite,
) includeGlobalFastRead: useGlobalBootstrap
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
}) })
} catch (e) { if (urls.length === 0) {
logger.warn('[LiveActivities] poll failed', { err: e }) rawItemsRef.current = []
rawItemsRef.current = [] setItems([])
setItems([]) return
} finally { }
setLoading(false) 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, showLiveActivitiesBanner,
pubkey, pubkey,
@ -150,7 +177,21 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
} }
if (!isInitialized) return if (!isInitialized) return
if (pubkey && isAccountSessionHydrating) 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, showLiveActivitiesBanner,
isInitialized, isInitialized,

5
src/providers/NostrProvider/index.tsx

@ -72,6 +72,9 @@ import { NsecSigner } from './nsec.signer'
export { useNostr } from '@/providers/nostr-context' export { useNostr } from '@/providers/nostr-context'
export type { TNostrContext } 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. */ /** Kind 10012 `relay` tags for publish / target-relay prioritization. */
function favoriteRelayUrlsForPublish( function favoriteRelayUrlsForPublish(
favoriteRelaysEvent: Event | null, favoriteRelaysEvent: Event | null,
@ -176,6 +179,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, [account]) }, [account])
useEffect(() => { useEffect(() => {
if (nostrSessionRestoreStarted) return
nostrSessionRestoreStarted = true
const init = async () => { const init = async () => {
logger.debug('[NostrProvider] Restoring session (login / first account)…') logger.debug('[NostrProvider] Restoring session (login / first account)…')
if (hasNostrLoginHash()) { if (hasNostrLoginHash()) {

94
src/routes.tsx

@ -59,27 +59,44 @@ function SR(C: LazyExoticComponent<ComponentType<any>>): 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 = [ const ROUTES = [
{ path: '/notes', element: SR(NoteListPageLazy) }, { path: '/notes', element: noteListPageElement },
{ path: '/notes/:id', element: SR(NotePageLazy) }, { path: '/notes/:id', element: notePageElement },
{ path: '/discussions/notes/:id', element: SR(NotePageLazy) }, { path: '/discussions/notes/:id', element: notePageElement },
{ path: '/search/notes/:id', element: SR(NotePageLazy) }, { path: '/search/notes/:id', element: notePageElement },
{ path: '/profile/notes/:id', element: SR(NotePageLazy) }, { path: '/profile/notes/:id', element: notePageElement },
{ path: '/explore/notes/:id', element: SR(NotePageLazy) }, { path: '/explore/notes/:id', element: notePageElement },
{ path: '/home/notes/:id', element: SR(NotePageLazy) }, { path: '/home/notes/:id', element: notePageElement },
{ path: '/feed/notes/:id', element: SR(NotePageLazy) }, { path: '/feed/notes/:id', element: notePageElement },
{ path: '/spells/notes/:id', element: SR(NotePageLazy) }, { path: '/spells/notes/:id', element: notePageElement },
{ path: '/rss/notes/:id', element: SR(NotePageLazy) }, { path: '/rss/notes/:id', element: notePageElement },
{ path: '/calendar/notes/:id', element: SR(NotePageLazy) }, { path: '/calendar/notes/:id', element: notePageElement },
{ path: '/calendar/day/:ymd', element: SR(CalendarDayEventsPageLazy) }, { path: '/calendar/day/:ymd', element: SR(CalendarDayEventsPageLazy) },
{ path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/rss/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/feed/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/search/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/profile/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/spells/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/explore/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/home/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/home/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/users', element: SR(ProfileListPageLazy) }, { path: '/users', element: SR(ProfileListPageLazy) },
{ path: '/users/:id/following', element: SR(FollowingListPageLazy) }, { path: '/users/:id/following', element: SR(FollowingListPageLazy) },
{ path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) }, { path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) },
@ -116,3 +133,42 @@ export const routes = ROUTES.map(({ path, element }) => ({
element: isValidElement(element) ? element : null, element: isValidElement(element) ? element : null,
matcher: match(path) matcher: match(path)
})) }))
export type TMatchedAppRoute = {
element: ReactElement | null
params: Record<string, string>
}
/**
* 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<string, string> }).params }
}
}
return null
}

Loading…
Cancel
Save