import Sidebar from '@/components/Sidebar' import { cn } from '@/lib/utils' import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider' import { TPageRef } from '@/types' import { cloneElement, createContext, createRef, ReactNode, RefObject, useContext, useEffect, useRef, useState } from 'react' import BackgroundAudio from './components/BackgroundAudio' import BottomNavigationBar from './components/BottomNavigationBar' import CreateWalletGuideToast from './components/CreateWalletGuideToast' import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' import { normalizeUrl } from './lib/url' import { NotificationProvider } from './providers/NotificationProvider' import { useScreenSize } from './providers/ScreenSizeProvider' import { useTheme } from './providers/ThemeProvider' import { useUserPreferences } from './providers/UserPreferencesProvider' import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary' import { SECONDARY_ROUTES } from './routes/secondary' import modalManager from './services/modal-manager.service' type TPrimaryPageContext = { navigate: (page: TPrimaryPageName, props?: object) => void current: TPrimaryPageName | null display: boolean } type TSecondaryPageContext = { push: (url: string) => void pop: () => void currentIndex: number } type TStackItem = { index: number url: string element: React.ReactElement | null ref: RefObject | null } const PrimaryPageContext = createContext(undefined) const SecondaryPageContext = createContext(undefined) export function usePrimaryPage() { const context = useContext(PrimaryPageContext) if (!context) { throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider') } return context } export function useSecondaryPage() { const context = useContext(SecondaryPageContext) if (!context) { throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider') } return context } export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [currentPrimaryPage, setCurrentPrimaryPage] = useState('home') const [primaryPages, setPrimaryPages] = useState< { name: TPrimaryPageName; element: ReactNode; props?: any }[] >([ { name: 'home', element: PRIMARY_PAGE_MAP.home } ]) const [secondaryStack, setSecondaryStack] = useState([]) const { isSmallScreen } = useScreenSize() const { themeSetting } = useTheme() const { enableSingleColumnLayout } = useUserPreferences() const ignorePopStateRef = useRef(false) useEffect(() => { if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { window.history.replaceState( null, '', '/users' + window.location.pathname + window.location.search + window.location.hash ) } else if ( ['/note1', '/nevent1', '/naddr1'].some((prefix) => window.location.pathname.startsWith(prefix) ) ) { window.history.replaceState( null, '', '/notes' + window.location.pathname + window.location.search + window.location.hash ) } window.history.pushState(null, '', window.location.href) if (window.location.pathname !== '/') { const url = window.location.pathname + window.location.search + window.location.hash setSecondaryStack((prevStack) => { if (isCurrentPage(prevStack, url)) return prevStack const { newStack, newItem } = pushNewPageToStack( prevStack, url, maxStackSize, window.history.state?.index ) if (newItem) { window.history.replaceState({ index: newItem.index, url }, '', url) } return newStack }) } else { const searchParams = new URLSearchParams(window.location.search) const r = searchParams.get('r') if (r) { const url = normalizeUrl(r) if (url) { navigatePrimaryPage('relay', { url }) } } } const onPopState = (e: PopStateEvent) => { if (ignorePopStateRef.current) { ignorePopStateRef.current = false return } const closeModal = modalManager.pop() if (closeModal) { ignorePopStateRef.current = true window.history.forward() return } let state = e.state as { index: number; url: string } | null setSecondaryStack((pre) => { const currentItem = pre[pre.length - 1] as TStackItem | undefined const currentIndex = currentItem?.index if (!state) { if (window.location.pathname + window.location.search + window.location.hash !== '/') { // Just change the URL return pre } else { // Back to root state = { index: -1, url: '/' } } } // Go forward if (currentIndex === undefined || state.index > currentIndex) { const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize) return newStack } if (state.index === currentIndex) { return pre } // Go back const newStack = pre.filter((item) => item.index <= state!.index) const topItem = newStack[newStack.length - 1] as TStackItem | undefined if (!topItem) { // Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty) const { element, ref } = findAndCloneElement(state.url, state.index) if (element) { newStack.push({ index: state.index, url: state.url, element, ref }) } } else if (!topItem.element) { // Load the element if it's not cached const { element, ref } = findAndCloneElement(topItem.url, state.index) if (element) { topItem.element = element topItem.ref = ref } } if (newStack.length === 0) { window.history.replaceState(null, '', '/') } return newStack }) } window.addEventListener('popstate', onPopState) return () => { window.removeEventListener('popstate', onPopState) } }, []) const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => { const needScrollToTop = page === currentPrimaryPage setPrimaryPages((prev) => { const exists = prev.find((p) => p.name === page) if (exists && props) { exists.props = props return [...prev] } else if (!exists) { return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }] } return prev }) setCurrentPrimaryPage(page) if (needScrollToTop) { PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth') } if (enableSingleColumnLayout) { clearSecondaryPages() } } const pushSecondaryPage = (url: string, index?: number) => { setSecondaryStack((prevStack) => { if (isCurrentPage(prevStack, url)) { const currentItem = prevStack[prevStack.length - 1] if (currentItem?.ref?.current) { currentItem.ref.current.scrollToTop('instant') } return prevStack } const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index) if (newItem) { window.history.pushState({ index: newItem.index, url }, '', url) } return newStack }) } const popSecondaryPage = (delta = -1) => { if (secondaryStack.length <= -delta) { // back to home page window.history.replaceState(null, '', '/') setSecondaryStack([]) } else { window.history.go(delta) } } const clearSecondaryPages = () => { if (secondaryStack.length === 0) return popSecondaryPage(-secondaryStack.length) } if (isSmallScreen) { return ( {!!secondaryStack.length && secondaryStack.map((item, index) => (
{item.element}
))} {primaryPages.map(({ name, element, props }) => (
{props ? cloneElement(element as React.ReactElement, props) : element}
))}
) } if (enableSingleColumnLayout) { return (
{!!secondaryStack.length && secondaryStack.map((item, index) => (
{item.element}
))} {primaryPages.map(({ name, element, props }) => (
{props ? cloneElement(element as React.ReactElement, props) : element}
))}
) } return (
{primaryPages.map(({ name, element, props }) => (
{props ? cloneElement(element as React.ReactElement, props) : element}
))}
0 && 'shadow-lg', secondaryStack.length === 0 ? 'bg-surface' : '' )} > {secondaryStack.map((item, index) => (
{item.element}
))}
) } export function SecondaryPageLink({ to, children, className, onClick }: { to: string children: React.ReactNode className?: string onClick?: (e: React.MouseEvent) => void }) { const { push } = useSecondaryPage() return ( { if (onClick) { onClick(e) } push(to) }} > {children} ) } function isCurrentPage(stack: TStackItem[], url: string) { const currentPage = stack[stack.length - 1] if (!currentPage) return false return currentPage.url === url } function findAndCloneElement(url: string, index: number) { const path = url.split('?')[0].split('#')[0] for (const { matcher, element } of SECONDARY_ROUTES) { const match = matcher(path) if (!match) continue if (!element) return {} const ref = createRef() return { element: cloneElement(element, { ...match.params, index, ref } as any), ref } } return {} } function pushNewPageToStack( stack: TStackItem[], url: string, maxStackSize = 5, specificIndex?: number ) { const currentItem = stack[stack.length - 1] const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0) const { element, ref } = findAndCloneElement(url, currentIndex) if (!element) return { newStack: stack, newItem: null } const newItem = { element, ref, url, index: currentIndex } const newStack = [...stack, newItem] const lastCachedIndex = newStack.findIndex((stack) => stack.element) // Clear the oldest cached element if there are too many cached elements if (newStack.length - lastCachedIndex > maxStackSize) { newStack[lastCachedIndex].element = null } return { newStack, newItem } }