You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

664 lines
22 KiB

import Sidebar from '@/components/Sidebar'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ChevronLeft } from 'lucide-react'
import NoteListPage from '@/pages/primary/NoteListPage'
import HomePage from '@/pages/secondary/HomePage'
import NotePage from '@/pages/secondary/NotePage'
import SettingsPage from '@/pages/secondary/SettingsPage'
import RelaySettingsPage from '@/pages/secondary/RelaySettingsPage'
import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { NotificationProvider } from '@/providers/NotificationProvider'
import { UserPreferencesProvider, useUserPreferences } from '@/providers/UserPreferencesProvider'
import { TPageRef } from '@/types'
import {
cloneElement,
createContext,
createRef,
ReactNode,
RefObject,
useContext,
useEffect,
useRef,
useState
} from 'react'
import BottomNavigationBar from './components/BottomNavigationBar'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import ExplorePage from './pages/primary/ExplorePage'
import MePage from './pages/primary/MePage'
import NotificationListPage from './pages/primary/NotificationListPage'
import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import DiscussionsPage from './pages/primary/DiscussionsPage'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
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
component: React.ReactElement | null
ref: RefObject<TPageRef> | null
}
const PRIMARY_PAGE_REF_MAP = {
home: createRef<TPageRef>(),
explore: createRef<TPageRef>(),
notifications: createRef<TPageRef>(),
me: createRef<TPageRef>(),
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
discussions: createRef<TPageRef>()
}
const PRIMARY_PAGE_MAP = {
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
const PrimaryNoteViewContext = createContext<{
setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub') => void
} | undefined>(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 usePrimaryNoteView() {
const context = useContext(PrimaryNoteViewContext)
if (!context) {
throw new Error('usePrimaryNoteView must be used within a PrimaryNoteViewContext.Provider')
}
return context
}
// Custom hook for intelligent note navigation
export function useSmartNoteNavigation() {
const { hideRecommendedRelaysPanel } = useUserPreferences()
const { push: pushSecondary } = useSecondaryPage()
const { setPrimaryNoteView } = usePrimaryNoteView()
const navigateToNote = (url: string) => {
if (hideRecommendedRelaysPanel) {
// When right panel is hidden, show note in primary area
// Extract note ID from URL (e.g., "/notes/note1..." -> "note1...")
const noteId = url.replace('/notes/', '')
setPrimaryNoteView(<NotePage id={noteId} index={0} hideTitlebar={true} />, 'note')
} else {
// Normal behavior - use secondary navigation
pushSecondary(url)
}
}
return { navigateToNote }
}
// Custom hook for intelligent relay navigation
export function useSmartRelayNavigation() {
const { hideRecommendedRelaysPanel } = useUserPreferences()
const { push: pushSecondary } = useSecondaryPage()
const { navigate: navigatePrimary } = usePrimaryPage()
const navigateToRelay = (url: string) => {
if (hideRecommendedRelaysPanel) {
// When right panel is hidden, navigate to relay page in primary area
// Extract relay URL from the path (e.g., "/relays/wss%3A%2F%2F..." -> "wss://...")
const relayUrl = url.startsWith('/relays/') ? decodeURIComponent(url.replace('/relays/', '')) : url
navigatePrimary('relay', { url: relayUrl })
} else {
// Normal behavior - use secondary navigation
pushSecondary(url)
}
}
return { navigateToRelay }
}
// Custom hook for intelligent settings navigation
export function useSmartSettingsNavigation() {
const { hideRecommendedRelaysPanel } = useUserPreferences()
const { push: pushSecondary } = useSecondaryPage()
const { setPrimaryNoteView } = usePrimaryNoteView()
const navigateToSettings = (url: string) => {
if (hideRecommendedRelaysPanel) {
// When right panel is hidden, show settings page in primary area
if (url === '/settings') {
setPrimaryNoteView(<SettingsPage index={0} hideTitlebar={true} />, 'settings')
} else if (url === '/settings/relays') {
setPrimaryNoteView(<RelaySettingsPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/wallet') {
setPrimaryNoteView(<WalletPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/posts') {
setPrimaryNoteView(<PostSettingsPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/general') {
setPrimaryNoteView(<GeneralSettingsPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/translation') {
setPrimaryNoteView(<TranslationPage index={0} hideTitlebar={true} />, 'settings-sub')
}
} else {
// Normal behavior - use secondary navigation
pushSecondary(url)
}
}
return { navigateToSettings }
}
function ConditionalHomePage() {
const { hideRecommendedRelaysPanel } = useUserPreferences()
if (hideRecommendedRelaysPanel) {
return null
}
return <HomePage />
}
function MainContentArea({
primaryPages,
currentPrimaryPage,
secondaryStack,
primaryNoteView,
primaryViewType,
setPrimaryNoteView
}: {
primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[]
currentPrimaryPage: TPrimaryPageName
secondaryStack: { index: number; component: ReactNode }[]
primaryNoteView: ReactNode | null
primaryViewType: 'note' | 'settings' | 'settings-sub' | null
setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub') => void
}) {
const { hideRecommendedRelaysPanel } = useUserPreferences()
// If recommended relays panel is hidden, use single column layout
// Otherwise use two-column grid layout
const gridClass = hideRecommendedRelaysPanel ? "grid-cols-1" : "grid-cols-2"
return (
<div className={`grid ${gridClass} gap-2 w-full pr-2 py-2`}>
<div className="rounded-lg shadow-lg bg-background overflow-hidden">
{hideRecommendedRelaysPanel && primaryNoteView ? (
// Show note view with back button when right panel is hidden
<div className="flex flex-col h-full w-full">
<div className="flex gap-1 p-1 items-center justify-between font-semibold border-b">
<div className="flex items-center flex-1 w-0">
<Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title="Back to feed"
onClick={() => setPrimaryNoteView(null)}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">
{primaryViewType === 'settings' ? 'Settings' :
primaryViewType === 'settings-sub' ? 'Settings' : 'Note'}
</div>
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{primaryNoteView}
</div>
</div>
) : (
// Show normal primary pages
primaryPages.map(({ name, element, props }) => (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display: currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))
)}
</div>
{!hideRecommendedRelaysPanel && (
<div className="rounded-lg shadow-lg bg-background overflow-hidden">
{secondaryStack.map((item, index) => (
<div
key={item.index}
className="flex flex-col h-full w-full"
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
>
{item.component}
</div>
))}
<div
key="home"
className="w-full"
style={{ display: secondaryStack.length === 0 ? 'block' : 'none' }}
>
<ConditionalHomePage />
</div>
</div>
)}
</div>
)
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const { isSmallScreen } = useScreenSize()
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'home',
element: PRIMARY_PAGE_MAP.home
}
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null)
const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | null>(null)
const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub') => {
setPrimaryNoteViewState(view)
setPrimaryViewType(type || null)
}
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')
const page = searchParams.get('page')
if (r) {
const url = normalizeUrl(r)
if (url) {
navigatePrimaryPage('relay', { url })
}
} else if (page && page in PRIMARY_PAGE_MAP) {
navigatePrimaryPage(page as TPrimaryPageName)
}
}
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 { component, ref } = findAndCreateComponent(state.url, state.index)
if (component) {
newStack.push({
index: state.index,
url: state.url,
component,
ref
})
}
} else if (!topItem.component) {
// Load the component if it's not cached
const { component, ref } = findAndCreateComponent(topItem.url, state.index)
if (component) {
topItem.component = component
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)
// Update URL for primary pages (except home)
const newUrl = page === 'home' ? '/' : `/?page=${page}`
window.history.pushState(null, '', newUrl)
if (needScrollToTop) {
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
}
if (isSmallScreen) {
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 = () => {
if (secondaryStack.length === 1) {
// back to home page
window.history.replaceState(null, '', '/')
setSecondaryStack([])
} else {
window.history.go(-1)
}
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
window.history.go(-secondaryStack.length)
}
if (isSmallScreen) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<UserPreferencesProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView }}>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.component}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</PrimaryNoteViewContext.Provider>
</UserPreferencesProvider>
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: true
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<UserPreferencesProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView }}>
<div className="flex flex-col items-center bg-surface-background">
<div
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<Sidebar />
<MainContentArea
primaryPages={primaryPages}
currentPrimaryPage={currentPrimaryPage}
secondaryStack={secondaryStack}
primaryNoteView={primaryNoteView}
primaryViewType={primaryViewType}
setPrimaryNoteView={setPrimaryNoteView}
/>
</div>
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</PrimaryNoteViewContext.Provider>
</UserPreferencesProvider>
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
export function SecondaryPageLink({
to,
children,
className,
onClick
}: {
to: string
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
const { push } = useSecondaryPage()
return (
<span
className={cn('cursor-pointer', className)}
onClick={(e) => {
if (onClick) {
onClick(e)
}
push(to)
}}
>
{children}
</span>
)
}
function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return currentPage.url === url
}
function findAndCreateComponent(url: string, index: number) {
const path = url.split('?')[0].split('#')[0]
for (const { matcher, element } of routes) {
const match = matcher(path)
if (!match) continue
if (!element) return {}
const ref = createRef<TPageRef>()
return { component: 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 { component, ref } = findAndCreateComponent(url, currentIndex)
if (!component) return { newStack: stack, newItem: null }
const newItem = { component, ref, url, index: currentIndex }
const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
// Clear the oldest cached component if there are too many cached components
if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].component = null
}
return { newStack, newItem }
}