PageManager.tsx raw
1 import ActionModeOverlay from '@/components/ActionModeOverlay'
2 import FloatingActions from '@/components/FloatingActions'
3 import Sidebar from '@/components/Sidebar'
4 import SidebarDrawer from '@/components/SidebarDrawer'
5 import { cn } from '@/lib/utils'
6 import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
7 import { KeyboardNavigationProvider } from '@/providers/KeyboardNavigationProvider'
8 import { TPageRef } from '@/types'
9 import {
10 cloneElement,
11 createContext,
12 createRef,
13 ReactNode,
14 RefObject,
15 useContext,
16 useEffect,
17 useRef,
18 useState
19 } from 'react'
20 import BackgroundAudio from './components/BackgroundAudio'
21 import CreateWalletGuideToast from './components/CreateWalletGuideToast'
22 import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
23 import { normalizeUrl } from './lib/url'
24 import { NotificationProvider } from './providers/NotificationProvider'
25 import { useScreenSize } from './providers/ScreenSizeProvider'
26 import { useTheme } from './providers/ThemeProvider'
27 import { useUserPreferences } from './providers/UserPreferencesProvider'
28 import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary'
29 import { SECONDARY_ROUTES } from './routes/secondary'
30 import modalManager from './services/modal-manager.service'
31
32 type TPrimaryPageContext = {
33 navigate: (page: TPrimaryPageName, props?: object) => void
34 current: TPrimaryPageName | null
35 display: boolean
36 }
37
38 type TSecondaryPageContext = {
39 push: (url: string) => void
40 pop: () => void
41 currentIndex: number
42 }
43
44 type TStackItem = {
45 index: number
46 url: string
47 element: React.ReactElement | null
48 ref: RefObject<TPageRef> | null
49 }
50
51 const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
52
53 const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
54
55 type TSidebarDrawerContext = {
56 isOpen: boolean
57 open: () => void
58 close: () => void
59 toggle: () => void
60 }
61
62 const SidebarDrawerContext = createContext<TSidebarDrawerContext | undefined>(undefined)
63
64 export function usePrimaryPage() {
65 const context = useContext(PrimaryPageContext)
66 if (!context) {
67 throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider')
68 }
69 return context
70 }
71
72 export function useSecondaryPage() {
73 const context = useContext(SecondaryPageContext)
74 if (!context) {
75 throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
76 }
77 return context
78 }
79
80 export function useSidebarDrawer() {
81 const context = useContext(SidebarDrawerContext)
82 // Return a no-op fallback when not inside the provider (e.g., desktop sidebar)
83 if (!context) {
84 return {
85 isOpen: false,
86 open: () => {},
87 close: () => {},
88 toggle: () => {}
89 }
90 }
91 return context
92 }
93
94 export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
95 const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
96 const [primaryPages, setPrimaryPages] = useState<
97 { name: TPrimaryPageName; element: ReactNode; props?: any }[]
98 >([
99 {
100 name: 'home',
101 element: PRIMARY_PAGE_MAP.home
102 }
103 ])
104 const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
105 const [sidebarDrawerOpen, setSidebarDrawerOpen] = useState(false)
106 const { isSmallScreen } = useScreenSize()
107 const { themeSetting } = useTheme()
108 const { enableSingleColumnLayout } = useUserPreferences()
109 const ignorePopStateRef = useRef(false)
110
111 const sidebarDrawerContext: TSidebarDrawerContext = {
112 isOpen: sidebarDrawerOpen,
113 open: () => setSidebarDrawerOpen(true),
114 close: () => setSidebarDrawerOpen(false),
115 toggle: () => setSidebarDrawerOpen((prev) => !prev)
116 }
117
118 useEffect(() => {
119 if (isSmallScreen) return
120
121 const handleKeyDown = (e: KeyboardEvent) => {
122 if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
123 e.preventDefault()
124 navigatePrimaryPage('search')
125 }
126 }
127 window.addEventListener('keydown', handleKeyDown)
128 return () => {
129 window.removeEventListener('keydown', handleKeyDown)
130 }
131 }, [isSmallScreen])
132
133 useEffect(() => {
134 if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
135 window.history.replaceState(
136 null,
137 '',
138 '/users' + window.location.pathname + window.location.search + window.location.hash
139 )
140 } else if (
141 ['/note1', '/nevent1', '/naddr1'].some((prefix) =>
142 window.location.pathname.startsWith(prefix)
143 )
144 ) {
145 window.history.replaceState(
146 null,
147 '',
148 '/notes' + window.location.pathname + window.location.search + window.location.hash
149 )
150 }
151 window.history.pushState(null, '', window.location.href)
152 if (window.location.pathname !== '/') {
153 const url = window.location.pathname + window.location.search + window.location.hash
154 setSecondaryStack((prevStack) => {
155 if (isCurrentPage(prevStack, url)) return prevStack
156
157 const { newStack, newItem } = pushNewPageToStack(
158 prevStack,
159 url,
160 maxStackSize,
161 window.history.state?.index
162 )
163 if (newItem) {
164 window.history.replaceState({ index: newItem.index, url }, '', url)
165 }
166 return newStack
167 })
168 } else {
169 const searchParams = new URLSearchParams(window.location.search)
170 const r = searchParams.get('r')
171 if (r) {
172 const url = normalizeUrl(r)
173 if (url) {
174 navigatePrimaryPage('relay', { url })
175 }
176 }
177 }
178
179 const onPopState = (e: PopStateEvent) => {
180 if (ignorePopStateRef.current) {
181 ignorePopStateRef.current = false
182 return
183 }
184
185 const closeModal = modalManager.pop()
186 if (closeModal) {
187 ignorePopStateRef.current = true
188 window.history.forward()
189 return
190 }
191
192 let state = e.state as { index: number; url: string } | null
193 setSecondaryStack((pre) => {
194 const currentItem = pre[pre.length - 1] as TStackItem | undefined
195 const currentIndex = currentItem?.index
196 if (!state) {
197 if (window.location.pathname + window.location.search + window.location.hash !== '/') {
198 // Just change the URL
199 return pre
200 } else {
201 // Back to root
202 state = { index: -1, url: '/' }
203 }
204 }
205
206 // Go forward
207 if (currentIndex === undefined || state.index > currentIndex) {
208 const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
209 return newStack
210 }
211
212 if (state.index === currentIndex) {
213 return pre
214 }
215
216 // Go back
217 const newStack = pre.filter((item) => item.index <= state!.index)
218 const topItem = newStack[newStack.length - 1] as TStackItem | undefined
219 if (!topItem) {
220 // Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)
221 const { element, ref } = findAndCloneElement(state.url, state.index)
222 if (element) {
223 newStack.push({
224 index: state.index,
225 url: state.url,
226 element,
227 ref
228 })
229 }
230 } else if (!topItem.element) {
231 // Load the element if it's not cached
232 const { element, ref } = findAndCloneElement(topItem.url, state.index)
233 if (element) {
234 topItem.element = element
235 topItem.ref = ref
236 }
237 }
238 if (newStack.length === 0) {
239 window.history.replaceState(null, '', '/')
240 }
241 return newStack
242 })
243 }
244
245 window.addEventListener('popstate', onPopState)
246
247 return () => {
248 window.removeEventListener('popstate', onPopState)
249 }
250 }, [])
251
252 const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
253 const needScrollToTop = page === currentPrimaryPage
254 setPrimaryPages((prev) => {
255 const exists = prev.find((p) => p.name === page)
256 if (exists && props) {
257 exists.props = props
258 return [...prev]
259 } else if (!exists) {
260 return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }]
261 }
262 return prev
263 })
264 setCurrentPrimaryPage(page)
265 if (needScrollToTop) {
266 PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
267 }
268 if (enableSingleColumnLayout) {
269 clearSecondaryPages()
270 }
271 }
272
273 const pushSecondaryPage = (url: string, index?: number) => {
274 setSecondaryStack((prevStack) => {
275 if (isCurrentPage(prevStack, url)) {
276 const currentItem = prevStack[prevStack.length - 1]
277 if (currentItem?.ref?.current) {
278 currentItem.ref.current.scrollToTop('instant')
279 }
280 return prevStack
281 }
282
283 const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index)
284 if (newItem) {
285 window.history.pushState({ index: newItem.index, url }, '', url)
286 }
287 return newStack
288 })
289 }
290
291 const popSecondaryPage = (delta = -1) => {
292 if (secondaryStack.length <= -delta) {
293 // back to home page
294 window.history.replaceState(null, '', '/')
295 setSecondaryStack([])
296 } else {
297 window.history.go(delta)
298 }
299 }
300
301 const clearSecondaryPages = () => {
302 if (secondaryStack.length === 0) return
303 popSecondaryPage(-secondaryStack.length)
304 }
305
306 if (isSmallScreen) {
307 return (
308 <PrimaryPageContext.Provider
309 value={{
310 navigate: navigatePrimaryPage,
311 current: currentPrimaryPage,
312 display: secondaryStack.length === 0
313 }}
314 >
315 <SecondaryPageContext.Provider
316 value={{
317 push: pushSecondaryPage,
318 pop: popSecondaryPage,
319 currentIndex: secondaryStack.length
320 ? secondaryStack[secondaryStack.length - 1].index
321 : 0
322 }}
323 >
324 <SidebarDrawerContext.Provider value={sidebarDrawerContext}>
325 <CurrentRelaysProvider>
326 <NotificationProvider>
327 <KeyboardNavigationProvider
328 secondaryStackLength={secondaryStack.length}
329 sidebarDrawerOpen={sidebarDrawerOpen}
330 onBack={() => popSecondaryPage()}
331 onCloseSecondary={() => clearSecondaryPages()}
332 >
333 {!!secondaryStack.length &&
334 secondaryStack.map((item, index) => (
335 <div
336 key={item.index}
337 style={{
338 display: index === secondaryStack.length - 1 ? 'block' : 'none'
339 }}
340 >
341 {item.element}
342 </div>
343 ))}
344 {primaryPages.map(({ name, element, props }) => (
345 <div
346 key={name}
347 style={{
348 display:
349 secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
350 }}
351 >
352 {props ? cloneElement(element as React.ReactElement, props) : element}
353 </div>
354 ))}
355 <SidebarDrawer
356 open={sidebarDrawerOpen}
357 onOpenChange={setSidebarDrawerOpen}
358 />
359 <TooManyRelaysAlertDialog />
360 <CreateWalletGuideToast />
361 <ActionModeOverlay />
362 <FloatingActions />
363 </KeyboardNavigationProvider>
364 </NotificationProvider>
365 </CurrentRelaysProvider>
366 </SidebarDrawerContext.Provider>
367 </SecondaryPageContext.Provider>
368 </PrimaryPageContext.Provider>
369 )
370 }
371
372 if (enableSingleColumnLayout) {
373 return (
374 <PrimaryPageContext.Provider
375 value={{
376 navigate: navigatePrimaryPage,
377 current: currentPrimaryPage,
378 display: secondaryStack.length === 0
379 }}
380 >
381 <SecondaryPageContext.Provider
382 value={{
383 push: pushSecondaryPage,
384 pop: popSecondaryPage,
385 currentIndex: secondaryStack.length
386 ? secondaryStack[secondaryStack.length - 1].index
387 : 0
388 }}
389 >
390 <CurrentRelaysProvider>
391 <NotificationProvider>
392 <KeyboardNavigationProvider
393 secondaryStackLength={secondaryStack.length}
394 sidebarDrawerOpen={false}
395 onBack={() => popSecondaryPage()}
396 onCloseSecondary={() => clearSecondaryPages()}
397 >
398 <div className="flex lg:justify-around w-full bg-chrome-background">
399 <div className="sticky top-0 z-40 lg:w-full flex self-start h-[var(--vh)]">
400 <Sidebar />
401 </div>
402 <div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
403 {!!secondaryStack.length &&
404 secondaryStack.map((item, index) => (
405 <div
406 key={item.index}
407 style={{
408 display: index === secondaryStack.length - 1 ? 'block' : 'none'
409 }}
410 >
411 {item.element}
412 </div>
413 ))}
414 {primaryPages.map(({ name, element, props }) => (
415 <div
416 key={name}
417 style={{
418 display:
419 secondaryStack.length === 0 && currentPrimaryPage === name
420 ? 'block'
421 : 'none'
422 }}
423 >
424 {props ? cloneElement(element as React.ReactElement, props) : element}
425 </div>
426 ))}
427 </div>
428 <div className="hidden lg:w-full lg:block" />
429 </div>
430 <TooManyRelaysAlertDialog />
431 <CreateWalletGuideToast />
432 <BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
433 <ActionModeOverlay />
434 <FloatingActions />
435 </KeyboardNavigationProvider>
436 </NotificationProvider>
437 </CurrentRelaysProvider>
438 </SecondaryPageContext.Provider>
439 </PrimaryPageContext.Provider>
440 )
441 }
442
443 return (
444 <PrimaryPageContext.Provider
445 value={{
446 navigate: navigatePrimaryPage,
447 current: currentPrimaryPage,
448 display: true
449 }}
450 >
451 <SecondaryPageContext.Provider
452 value={{
453 push: pushSecondaryPage,
454 pop: popSecondaryPage,
455 currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
456 }}
457 >
458 <CurrentRelaysProvider>
459 <NotificationProvider>
460 <KeyboardNavigationProvider
461 secondaryStackLength={secondaryStack.length}
462 sidebarDrawerOpen={false}
463 onBack={() => popSecondaryPage()}
464 onCloseSecondary={() => clearSecondaryPages()}
465 >
466 <div className="flex flex-col items-center bg-surface-background">
467 <div
468 className="flex h-[var(--vh)] w-full bg-surface-background"
469 style={{
470 maxWidth: '1920px'
471 }}
472 >
473 <Sidebar />
474 <div
475 className={cn(
476 'grid grid-cols-2 w-full',
477 themeSetting === 'dark' ? '' : 'gap-2 pr-2 py-2'
478 )}
479 >
480 <div
481 className={cn(
482 'bg-background overflow-hidden',
483 themeSetting === 'dark' ? 'border-l' : 'rounded-2xl shadow-lg'
484 )}
485 >
486 {primaryPages.map(({ name, element, props }) => (
487 <div
488 key={name}
489 className="flex flex-col h-full w-full"
490 style={{
491 display: currentPrimaryPage === name ? 'block' : 'none'
492 }}
493 >
494 {props ? cloneElement(element as React.ReactElement, props) : element}
495 </div>
496 ))}
497 </div>
498 <div
499 className={cn(
500 'bg-background overflow-hidden',
501 themeSetting === 'dark' ? 'border-l' : 'rounded-2xl',
502 themeSetting !== 'dark' && secondaryStack.length > 0 && 'shadow-lg',
503 secondaryStack.length === 0 ? 'bg-surface' : ''
504 )}
505 >
506 {secondaryStack.map((item, index) => (
507 <div
508 key={item.index}
509 className="flex flex-col h-full w-full"
510 style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
511 >
512 {item.element}
513 </div>
514 ))}
515 </div>
516 </div>
517 </div>
518 </div>
519 <TooManyRelaysAlertDialog />
520 <CreateWalletGuideToast />
521 <BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
522 <ActionModeOverlay />
523 </KeyboardNavigationProvider>
524 </NotificationProvider>
525 </CurrentRelaysProvider>
526 </SecondaryPageContext.Provider>
527 </PrimaryPageContext.Provider>
528 )
529 }
530
531 export function SecondaryPageLink({
532 to,
533 children,
534 className,
535 onClick
536 }: {
537 to: string
538 children: React.ReactNode
539 className?: string
540 onClick?: (e: React.MouseEvent) => void
541 }) {
542 const { push } = useSecondaryPage()
543
544 return (
545 <span
546 className={cn('cursor-pointer', className)}
547 onClick={(e) => {
548 if (onClick) {
549 onClick(e)
550 }
551 push(to)
552 }}
553 >
554 {children}
555 </span>
556 )
557 }
558
559 function isCurrentPage(stack: TStackItem[], url: string) {
560 const currentPage = stack[stack.length - 1]
561 if (!currentPage) return false
562
563 return currentPage.url === url
564 }
565
566 function findAndCloneElement(url: string, index: number) {
567 const path = url.split('?')[0].split('#')[0]
568 for (const { matcher, element } of SECONDARY_ROUTES) {
569 const match = matcher(path)
570 if (!match) continue
571
572 if (!element) return {}
573 const ref = createRef<TPageRef>()
574 return { element: cloneElement(element, { ...match.params, index, ref } as any), ref }
575 }
576 return {}
577 }
578
579 function pushNewPageToStack(
580 stack: TStackItem[],
581 url: string,
582 maxStackSize = 5,
583 specificIndex?: number
584 ) {
585 const currentItem = stack[stack.length - 1]
586 const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0)
587
588 const { element, ref } = findAndCloneElement(url, currentIndex)
589 if (!element) return { newStack: stack, newItem: null }
590
591 const newItem = { element, ref, url, index: currentIndex }
592 const newStack = [...stack, newItem]
593 const lastCachedIndex = newStack.findIndex((stack) => stack.element)
594 // Clear the oldest cached element if there are too many cached elements
595 if (newStack.length - lastCachedIndex > maxStackSize) {
596 newStack[lastCachedIndex].element = null
597 }
598 return { newStack, newItem }
599 }
600