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