Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
8b78a2751a
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 39
      src/PageManager.tsx
  4. 3
      src/lib/live-activities.ts
  5. 6
      src/pages/secondary/NotePage/index.tsx
  6. 51
      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 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

39
src/PageManager.tsx

@ -56,7 +56,7 @@ import { @@ -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 { @@ -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 }) { @@ -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<string | null>(null)
@ -1285,8 +1286,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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 }) { @@ -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 @@ -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
if (!element) {
logger.component('PageManager', 'No element for this route', { path })
const matched = matchAppRoute(path)
if (!matched?.element) {
logger.component('PageManager', 'No matching route found', { path, url })
return {}
}
const ref = createRef<TPageRef>()
// Decode URL parameters for relay pages
const params = { ...(match as any).params }
const params = { ...matched.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,
index,
hasInitialEvent: !!initialEvent
})
try {
const component = cloneSecondaryRouteElement(element, {
const component = cloneSecondaryRouteElement(matched.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 {}
}
}
logger.component('PageManager', 'No matching route found', { path, url })
return {}
}
function pushNewPageToStack(

3
src/lib/live-activities.ts

@ -823,8 +823,9 @@ async function isInlinePlaybackUrlReachable(url: string, timeoutMs: number): Pro @@ -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<TLiveActivityItem[]> {
const timeoutMs = options?.timeoutMs ?? LIVE_ACTIVITIES_STREAM_PROBE_MS
const checked = await Promise.all(
items.map(async (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 }: @@ -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

51
src/providers/LiveActivitiesProvider.tsx

@ -45,6 +45,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -45,6 +45,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set())
const rawItemsRef = useRef<TLiveActivityItem[]>([])
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 relayWrite = relayList?.write ?? []
@ -55,6 +59,15 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -55,6 +59,15 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
setItems([])
return
}
const now = Date.now()
if (refreshInFlightRef.current) {
return refreshInFlightRef.current
}
if (now - lastRefreshFinishedAtRef.current < LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS) {
return
}
const run = async () => {
const loggedIn = Boolean(pubkey)
const urls = buildLiveActivitiesRelayUrls({
loggedIn,
@ -80,14 +93,23 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -80,14 +93,23 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
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)))
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: reachable.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 })
@ -95,7 +117,12 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -95,7 +117,12 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
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 @@ -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,

5
src/providers/NostrProvider/index.tsx

@ -72,6 +72,9 @@ import { NsecSigner } from './nsec.signer' @@ -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 }) { @@ -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()) {

94
src/routes.tsx

@ -59,27 +59,44 @@ function SR(C: LazyExoticComponent<ComponentType<any>>): ReactElement { @@ -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 = [
{ 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 }) => ({ @@ -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<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