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.
 
 
 

1739 lines
71 KiB

import Sidebar from '@/components/Sidebar'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { ChevronLeft } from 'lucide-react'
import { NavigationService } from '@/services/navigation.service'
import NoteListPage from '@/pages/primary/NoteListPage'
import SecondaryNoteListPage from '@/pages/secondary/NoteListPage'
// Page imports needed for primary note view
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 RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NoteDrawer from '@/components/NoteDrawer'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import storage from '@/services/local-storage.service'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
import MuteListPage from '@/pages/secondary/MuteListPage'
import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage'
import SecondaryRelayPage from '@/pages/secondary/RelayPage'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { NotificationProvider } from '@/providers/NotificationProvider'
// DEPRECATED: useUserPreferences removed - double-panel functionality disabled
import { TPageRef } from '@/types'
import {
cloneElement,
createContext,
createRef,
ReactNode,
RefObject,
useCallback,
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'
type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName, props?: object) => void
current: TPrimaryPageName | null
display: boolean
}
type TSecondaryPageContext = {
push: (url: string) => void
pop: () => void
currentIndex: number
navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void
}
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>()
}
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
// This is only evaluated when called, not at module load time
const getPrimaryPageMap = () => ({
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} />
})
// Type for primary page names - use the return type of getPrimaryPageMap
export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap>
export const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
const PrimaryNoteViewContext = createContext<{
setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => void
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
getNavigationCounter: () => number
} | undefined>(undefined)
const NoteDrawerContext = createContext<{
openDrawer: (noteId: string) => void
closeDrawer: () => void
isDrawerOpen: boolean
drawerNoteId: string | null
} | 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('useSecondaryPage 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
}
export function useNoteDrawer() {
const context = useContext(NoteDrawerContext)
if (!context) {
throw new Error('useNoteDrawer must be used within a NoteDrawerContext.Provider')
}
return context
}
// Helper function to build contextual note URL
function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string {
// Pages that should preserve context in the URL
const contextualPages: TPrimaryPageName[] = ['discussions', 'search', 'profile', 'explore', 'notifications']
if (currentPage && contextualPages.includes(currentPage) && currentPage !== 'home') {
return `/${currentPage}/notes/${noteId}`
}
return `/notes/${noteId}`
}
// Helper function to build contextual relay URL
function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string {
const encodedRelayUrl = encodeURIComponent(relayUrl)
// Only preserve context for explore page (where you discover relays)
if (currentPage === 'explore') {
return `/explore/relays/${encodedRelayUrl}`
}
return `/relays/${encodedRelayUrl}`
}
// Helper function to extract noteId and context from URL
function parseNoteUrl(url: string): { noteId: string; context?: string } {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/)
if (contextualMatch) {
return { noteId: contextualMatch[2], context: contextualMatch[1] }
}
// Match standard pattern /notes/{noteId}
const standardMatch = url.match(/\/notes\/(.+)$/)
if (standardMatch) {
return { noteId: standardMatch[1] }
}
// Fallback: extract from any /notes/ pattern
const fallbackMatch = url.replace(/.*\/notes\//, '')
return { noteId: fallbackMatch || url }
}
// Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop
export function useSmartNoteNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer, isDrawerOpen } = useNoteDrawer()
const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToNote = (url: string) => {
// Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id})
const { noteId } = parseNoteUrl(url)
// Build contextual URL based on current page
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) {
// Mobile: always push to secondary stack AND update drawer
// This ensures back button works when clicking embedded events
pushSecondaryPage(contextualUrl)
openDrawer(noteId)
} else {
// Desktop: check panel mode
const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') {
// Single-pane: if drawer is already open, push to stack AND update drawer
// Otherwise, just open drawer
if (isDrawerOpen) {
// Navigating from within drawer - push to stack for back button support
pushSecondaryPage(contextualUrl)
openDrawer(noteId)
} else {
// Opening drawer for first time
window.history.pushState(null, '', contextualUrl)
openDrawer(noteId)
}
} else {
// Double-pane: use secondary panel
pushSecondaryPage(contextualUrl)
}
}
}
return { navigateToNote }
}
// Fixed: Relay navigation now uses primary note view on mobile, secondary routing (drawer in single-pane, side panel in double-pane) on desktop
export function useSmartRelayNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToRelay = (url: string) => {
// Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url})
const relayUrlMatch = url.match(/\/(discussions|search|profile|explore|notifications)\/relays\/(.+)$/) ||
url.match(/\/relays\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
// Build contextual URL based on current page
const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage)
if (isSmallScreen) {
// Use primary note view on mobile
window.history.pushState(null, '', contextualUrl)
setPrimaryNoteView(<SecondaryRelayPage url={relayUrl} index={0} hideTitlebar={true} />, 'relay')
} else {
// Desktop: always use secondary routing (will be rendered in drawer in single-pane, side panel in double-pane)
pushSecondaryPage(contextualUrl)
}
}
return { navigateToRelay }
}
// Fixed: Profile navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartProfileNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { closeDrawer, isDrawerOpen } = useNoteDrawer()
const navigateToProfile = (url: string) => {
// Close drawer if open (profiles aren't shown in drawers)
// Navigate after drawer closes to avoid URL being restored by drawer's onOpenChange
if (isDrawerOpen) {
closeDrawer()
// Wait for drawer to close (350ms animation) before navigating
setTimeout(() => {
if (isSmallScreen) {
// Use primary note view on mobile
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}, 400) // Slightly longer than drawer close animation (350ms)
} else {
// No drawer open, navigate immediately
if (isSmallScreen) {
// Use primary note view on mobile
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
}
return { navigateToProfile }
}
// Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled
export function useSmartHashtagNavigation() {
const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView()
const navigateToHashtag = (url: string) => {
// Use primary note view to show hashtag feed since secondary panel is disabled
// Update URL first - do this synchronously before setting the view
const parsedUrl = url.startsWith('/') ? url : `/${url}`
window.history.pushState(null, '', parsedUrl)
// Extract hashtag from URL for the key to ensure unique keys for different hashtags
const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '')
const hashtag = searchParams.get('t') || ''
// Get the current navigation counter and use next value for the key
// This ensures unique keys that force remounting - setPrimaryNoteView will increment it
const counter = getNavigationCounter()
const key = `hashtag-${hashtag}-${counter + 1}`
// Use a key based on the hashtag and navigation counter to force remounting when hashtag changes
// This ensures the component reads the new URL parameters when it mounts
// setPrimaryNoteView will increment the counter, so we use counter + 1 for the key
setPrimaryNoteView(<SecondaryNoteListPage key={key} hideTitlebar={true} />, 'hashtag')
// Dispatch custom event as a fallback for components that might be reused
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
}
return { navigateToHashtag }
}
// Fixed: Following list navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartFollowingListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToFollowingList = (url: string) => {
if (isSmallScreen) {
// Use primary note view on mobile
const profileId = url.replace('/users/', '').replace('/following', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<FollowingListPage id={profileId} index={0} hideTitlebar={true} />, 'following')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
return { navigateToFollowingList }
}
// Fixed: Mute list navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartMuteListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToMuteList = (url: string) => {
if (isSmallScreen) {
// Use primary note view on mobile
window.history.pushState(null, '', url)
setPrimaryNoteView(<MuteListPage index={0} hideTitlebar={true} />, 'mute')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
return { navigateToMuteList }
}
// Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartOthersRelaySettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToOthersRelaySettings = (url: string) => {
if (isSmallScreen) {
// Use primary note view on mobile
const profileId = url.replace('/users/', '').replace('/relays', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<OthersRelaySettingsPage id={profileId} index={0} hideTitlebar={true} />, 'others-relay-settings')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
return { navigateToOthersRelaySettings }
}
// Fixed: Settings navigation now uses primary note view since secondary panel is disabled
export function useSmartSettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const navigateToSettings = (url: string) => {
// Use primary note view to show settings since secondary panel is disabled
if (url === '/settings') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<SettingsPage key="settings" index={0} hideTitlebar={true} />, 'settings')
} else if (url.startsWith('/settings/relays')) {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RelaySettingsPage key="relay-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/wallet') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<WalletPage key="wallet" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/posts') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<PostSettingsPage key="post-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/general') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<GeneralSettingsPage key="general-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/translation') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<TranslationPage key="translation" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/rss-feeds') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RssFeedSettingsPage key="rss-feed-settings" index={0} hideTitlebar={true} />, 'settings-sub')
}
}
return { navigateToSettings }
}
// DEPRECATED: ConditionalHomePage removed - double-panel functionality disabled
// Helper function to get page title based on view type and URL
function getPageTitle(viewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null, pathname: string): string {
// Create a temporary navigation service instance to use the getPageTitle method
const tempService = new NavigationService({ setPrimaryNoteView: () => {} })
return tempService.getPageTitle(viewType, pathname)
}
// DEPRECATED: Double-panel functionality removed - simplified to single column layout
function MainContentArea({
primaryPages,
currentPrimaryPage,
primaryNoteView,
primaryViewType,
goBack
}: {
primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[]
currentPrimaryPage: TPrimaryPageName
primaryNoteView: ReactNode | null
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
goBack: () => void
}) {
const [, forceUpdate] = useState(0)
// Listen for note page title updates
useEffect(() => {
const handleTitleUpdate = () => {
forceUpdate(n => n + 1)
}
window.addEventListener('notePageTitleUpdated', handleTitleUpdate)
return () => {
window.removeEventListener('notePageTitleUpdated', handleTitleUpdate)
}
}, [])
logger.debug('MainContentArea rendering:', {
currentPrimaryPage,
primaryPages: primaryPages.map(p => p.name),
primaryNoteView: !!primaryNoteView
})
// Always use single column layout since double-panel is disabled
return (
<div className="grid grid-cols-1 gap-2 w-full pr-2 py-2">
<div className="rounded-lg shadow-lg bg-background overflow-hidden">
{primaryNoteView ? (
// Show note view with back button
<div className="flex flex-col h-full w-full">
<div className="flex justify-center py-1 border-b">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Imwald
</span>
</div>
<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"
onClick={goBack}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">
Back
</div>
</Button>
</div>
<div className="flex-1 flex justify-center">
<div className="text-lg font-semibold">
{getPageTitle(primaryViewType, window.location.pathname)}
</div>
</div>
<div className="flex-1 w-0"></div>
</div>
<div className="flex-1 overflow-auto">
{primaryNoteView}
</div>
</div>
) : (
// Show normal primary pages
primaryPages.map(({ name, element, props }) => {
const isCurrentPage = currentPrimaryPage === name
logger.debug(`Primary page ${name}:`, { isCurrentPage, currentPrimaryPage })
return (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display: isCurrentPage ? 'block' : 'none'
}}
>
{(() => {
try {
logger.debug(`Rendering ${name} component`)
return props ? cloneElement(element as React.ReactElement, props) : element
} catch (error) {
logger.error(`Error rendering ${name} component:`, error)
return <div>Error rendering {name}: {error instanceof Error ? error.message : String(error)}</div>
}
})()}
</div>
)
})
)}
</div>
{/* DEPRECATED: Secondary panel removed - double-panel functionality disabled */}
</div>
)
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const { isSmallScreen } = useScreenSize()
// DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'home',
element: getPrimaryPageMap().home
}
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null)
const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null)
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
const navigationCounterRef = useRef(0)
const savedFeedStateRef = useRef<Map<TPrimaryPageName, {
tab?: string,
discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' },
trendingTab?: 'nostr' | 'relays' | 'hashtags'
}>>(new Map())
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page
const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => {
if (view && !primaryNoteView) {
// Saving current primary page before showing overlay
setSavedPrimaryPage(currentPrimaryPage)
// Get current tab state from ref (updated by components via events)
const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
// Get Discussions state if on discussions page
let discussionsState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | undefined = undefined
if (currentPrimaryPage === 'discussions') {
// Request discussions state from component
const stateEvent = new CustomEvent('requestDiscussionsState')
let receivedState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | null = null
const handler = ((e: CustomEvent) => {
receivedState = e.detail
}) as EventListener
window.addEventListener('discussionsStateResponse', handler)
window.dispatchEvent(stateEvent)
setTimeout(() => {
window.removeEventListener('discussionsStateResponse', handler)
if (receivedState) {
discussionsState = receivedState
}
}, 10)
}
// Get trending tab if on search page
const trendingTab = currentTabStateRef.current.get('search') as 'nostr' | 'relays' | 'hashtags' | undefined
// Save state (tab, discussions, trending) if any exists
if (currentTab || discussionsState || trendingTab) {
logger.info('PageManager: Saving page state', {
page: currentPrimaryPage,
tab: currentTab,
discussionsState,
trendingTab
})
savedFeedStateRef.current.set(currentPrimaryPage, {
tab: currentTab,
discussionsState,
trendingTab
})
}
}
// Increment navigation counter when setting a new view to ensure unique keys
// This forces React to remount components even when navigating between items of the same type
if (view) {
navigationCounterRef.current += 1
}
// Always update the view state - even if the type is the same, the component might be different
// This ensures that navigation works even when navigating between items of the same type (e.g., different hashtags)
setPrimaryNoteViewState(view)
setPrimaryViewType(type || null)
// If clearing the view, restore to the saved primary page
if (!view && savedPrimaryPage) {
const newUrl = savedPrimaryPage === 'home' ? '/' : `/${savedPrimaryPage}`
window.history.replaceState(null, '', newUrl)
const savedFeedState = savedFeedStateRef.current.get(savedPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Restoring tab state', { page: savedPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: savedPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab)
}
// Restore Discussions state
if (savedFeedState?.discussionsState && savedPrimaryPage === 'discussions') {
logger.info('PageManager: Restoring Discussions state', {
page: savedPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: savedPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page
if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') {
logger.info('PageManager: Restoring trending tab', {
page: savedPrimaryPage,
trendingTab: savedFeedState.trendingTab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab: savedFeedState.trendingTab }
}))
currentTabStateRef.current.set('search', savedFeedState.trendingTab)
}
}
}
const goBack = () => {
// Special handling for settings sub-pages - go back to main settings page
if (primaryViewType === 'settings-sub') {
window.history.pushState(null, '', '/settings')
setPrimaryNoteView(<SettingsPage index={0} hideTitlebar={true} />, 'settings')
} else if (primaryViewType === 'following' || primaryViewType === 'mute' || primaryViewType === 'others-relay-settings') {
// Special handling for profile sub-pages - go back to main profile page
const currentPath = window.location.pathname
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
const profileUrl = `/users/${profileId}`
window.history.pushState(null, '', profileUrl)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
// Use browser's back functionality for other pages
window.history.back()
}
}
// Drawer handlers
const openDrawer = useCallback((noteId: string) => {
setDrawerNoteId(noteId)
setDrawerOpen(true)
}, [])
const closeDrawer = useCallback(() => {
if (!drawerOpen) return // Already closed
setDrawerOpen(false)
// Don't clear noteId here - let onOpenChange handle it when animation completes
}, [drawerOpen])
const ignorePopStateRef = useRef(false)
// Handle browser back button for primary note view
useEffect(() => {
const handlePopState = () => {
if (ignorePopStateRef.current) {
ignorePopStateRef.current = false
return
}
// If we have a primary note view open (and drawer is not open), close it
if (primaryNoteView && !drawerOpen) {
setPrimaryNoteView(null)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [primaryNoteView, drawerOpen])
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
const pathname = window.location.pathname
// Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id}
const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/)
const standardNoteMatch = pathname.match(/\/notes\/(.+)$/)
const noteUrlMatch = contextualNoteMatch || standardNoteMatch
if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) {
// If this is a contextual note URL, set the primary page first
if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1] as TPrimaryPageName
if (pageContext in getPrimaryPageMap()) {
// Open drawer immediately, then load background page asynchronously
// This prevents the background page loading from blocking the drawer
if (isSmallScreen || panelMode === 'single') {
// Single-pane mode or mobile: open drawer first
openDrawer(noteId)
// Load background page asynchronously after drawer opens
setTimeout(() => {
setCurrentPrimaryPage(pageContext)
setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === pageContext)
if (!exists) {
return [...prev, { name: pageContext, element: getPrimaryPageMap()[pageContext] }]
}
return prev
})
setSavedPrimaryPage(pageContext)
}, 0)
return
} else {
// Double-pane mode: set page immediately (no drawer)
setCurrentPrimaryPage(pageContext)
setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === pageContext)
if (!exists) {
return [...prev, { name: pageContext, element: getPrimaryPageMap()[pageContext] }]
}
return prev
})
setSavedPrimaryPage(pageContext)
}
}
}
// Build contextual URL based on current page (for both single and double-pane)
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
// Check pane mode to determine how to open the note
if (isSmallScreen || panelMode === 'single') {
// Single-pane mode or mobile: open in drawer
openDrawer(noteId)
// Update URL to contextual URL if different
if (url !== contextualUrl) {
window.history.replaceState(null, '', contextualUrl)
}
return
} else {
// Double-pane mode: push to secondary stack with contextual URL
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, contextualUrl)) return prevStack
const { newStack, newItem } = pushNewPageToStack(
prevStack,
contextualUrl,
maxStackSize,
window.history.state?.index
)
if (newItem) {
window.history.replaceState({ index: newItem.index, url: contextualUrl }, '', contextualUrl)
}
return newStack
})
return
}
}
}
// Check if this is a primary page URL - don't push primary pages to secondary stack
const pathnameOnly = pathname.split('?')[0].split('#')[0]
const isPrimaryPageUrl = pathnameOnly === '/' || pathnameOnly === '/home' ||
(pathnameOnly.startsWith('/') && pathnameOnly.slice(1).split('/')[0] in getPrimaryPageMap() &&
!pathnameOnly.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/))
if (isPrimaryPageUrl) {
// This is a primary page - just navigate to it, don't push to secondary stack
const pageName = pathnameOnly === '/' || pathnameOnly === '/home' ? 'home' : pathnameOnly.slice(1).split('/')[0] as TPrimaryPageName
if (pageName in getPrimaryPageMap()) {
navigatePrimaryPage(pageName)
}
return
}
// For relay URLs and other non-note URLs, push to secondary stack
// (will be rendered in drawer in single-pane mode, side panel in double-pane mode)
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 {
// Check for relay URL in query params (legacy support)
const searchParams = new URLSearchParams(window.location.search)
const r = searchParams.get('r')
if (r) {
const url = normalizeUrl(r)
if (url) {
navigatePrimaryPage('relay', { url })
return
}
}
// Parse pathname to determine primary page
const pathname: string = window.location.pathname
// Handle dedicated paths for primary pages
if (pathname === '/' || pathname === '/home') {
navigatePrimaryPage('home')
} else {
// Check if pathname matches a primary page name
// First, check if it's a contextual note URL (e.g., /discussions/notes/...)
const contextualNoteMatch = pathname.match(/^\/(discussions|search|profile|explore|notifications)\/notes\//)
if (contextualNoteMatch) {
// Extract the page context from the URL
const pageContext = contextualNoteMatch[1] as TPrimaryPageName
if (pageContext in getPrimaryPageMap()) {
navigatePrimaryPage(pageContext)
// The note URL will be handled by the note URL parsing above
}
return
}
// Check if it's a standard primary page path
const pageName: string = pathname.slice(1).split('/')[0] // Get first segment after slash
if (pageName && pageName in getPrimaryPageMap()) {
// For relay page, check if there's a URL prop
if (pageName === 'relay') {
// Relay URLs are handled via secondary routing, not primary pages
// This should be caught earlier in the URL parsing
} else {
navigatePrimaryPage(pageName as TPrimaryPageName)
}
}
// If pathname doesn't match a primary page, it might be a secondary route
// which is handled elsewhere
}
}
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
// Use state.url if available, otherwise fall back to current pathname
const urlToCheck = state?.url || window.location.pathname
// Check if it's a note URL (we'll update drawer after stack is synced)
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) ||
urlToCheck.match(/\/notes\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
// If not a note URL and drawer is open - close the drawer immediately
// Only in single-pane mode or mobile
if (!noteIdToShow && drawerOpen && (isSmallScreen || panelMode === 'single')) {
setDrawerOpen(false)
setTimeout(() => {
setDrawerNoteId(null)
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
window.history.replaceState(null, '', pageUrl)
}, 350)
}
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) {
// Stack is empty - check if this is a primary page URL or a secondary route
const pathname = state.url.split('?')[0].split('#')[0]
const isPrimaryPage = pathname === '/' || pathname === '/home' ||
(pathname.startsWith('/') && pathname.slice(1).split('/')[0] in getPrimaryPageMap() &&
!pathname.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/))
// If it's a primary page URL, return empty stack (right panel will close)
if (isPrimaryPage) {
// On mobile or single-pane: if drawer is open, close it
if (drawerOpen && (isSmallScreen || panelMode === 'single')) {
setDrawerOpen(false)
setTimeout(() => {
setDrawerNoteId(null)
// Ensure URL matches the primary page
const pageUrl = pathname === '/' || pathname === '/home' ? '/' : pathname
window.history.replaceState(null, '', pageUrl)
}, 350)
}
return []
}
// Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id})
const noteUrlMatch = state.url.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) ||
state.url.match(/\/notes\/(.+)$/)
if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) {
if (isSmallScreen || panelMode === 'single') {
// Single-pane mode or mobile: open in drawer
openDrawer(noteId)
return pre
}
// Double-pane mode: continue with stack creation
}
}
// Create a new stack item if it's a secondary route (e.g., /follow-packs, /mutes)
const { component, ref } = findAndCreateComponent(state.url, state.index)
if (component) {
newStack.push({
index: state.index,
url: state.url,
component,
ref
})
} else {
// No component found - likely a primary page, return empty stack
// On mobile or single-pane: if drawer is open, close it
if (drawerOpen && (isSmallScreen || panelMode === 'single')) {
closeDrawer()
}
return []
}
} 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) {
// On mobile or single-pane: if drawer is open, close it
if (drawerOpen && (isSmallScreen || panelMode === 'single')) {
closeDrawer()
}
// DO NOT update URL when closing panel - closing should NEVER affect the main page
} else if (newStack.length > 0) {
// Stack still has items - update drawer to show the top item's note (for mobile/single-pane)
// Only update drawer if drawer is currently open (not in the process of closing)
if ((isSmallScreen || panelMode === 'single') && drawerOpen && drawerNoteId) {
// Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) {
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) ||
topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (topNoteId && topNoteId !== drawerNoteId) {
// Use setTimeout to ensure drawer update happens after stack state is committed
setTimeout(() => {
// Double-check drawer is still open before updating
if (drawerOpen) {
openDrawer(topNoteId)
}
}, 0)
}
}
}
}
}
// If newStack.length === 0, we're closing - don't reopen the drawer
return newStack
})
}
window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', onPopState)
}
}, [isSmallScreen, openDrawer, closeDrawer, panelMode, drawerOpen])
// Listen for tab state changes from components
useEffect(() => {
const handleTabChange = (e: CustomEvent<{ page: TPrimaryPageName, tab: string }>) => {
currentTabStateRef.current.set(e.detail.page, e.detail.tab)
logger.debug('PageManager: Tab state updated', { page: e.detail.page, tab: e.detail.tab })
}
window.addEventListener('pageTabChanged', handleTabChange as EventListener)
return () => {
window.removeEventListener('pageTabChanged', handleTabChange as EventListener)
}
}, [])
// Listen for panel mode changes from toggle
useEffect(() => {
const handlePanelModeChange = (e: CustomEvent<{ mode: 'single' | 'double' }>) => {
setPanelMode(e.detail.mode)
logger.debug('PageManager: Panel mode changed', { mode: e.detail.mode })
}
window.addEventListener('panelModeChanged', handlePanelModeChange as EventListener)
return () => {
window.removeEventListener('panelModeChanged', handlePanelModeChange as EventListener)
}
}, [])
// Restore tab state when returning to primary page from browser back button
useEffect(() => {
if (secondaryStack.length === 0 && currentPrimaryPage) {
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Browser back - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
// Update ref immediately
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') {
logger.info('PageManager: Browser back - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
logger.info('PageManager: Browser back - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: savedFeedState.trendingTab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab: savedFeedState.trendingTab }
}))
currentTabStateRef.current.set('search', savedFeedState.trendingTab)
}
}
}, [secondaryStack.length, currentPrimaryPage])
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
// Clear any primary note view when navigating to a new primary page
// This ensures menu clicks always take you to the primary page, not stuck on overlays
setPrimaryNoteView(null)
// Always clear secondary pages when navigating to a primary page via menu
// This ensures clicking menu items always takes you to that page, not stuck on profile/note pages
clearSecondaryPages()
// Update primary pages and current page
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: getPrimaryPageMap()[page], props }]
}
return prev
})
setCurrentPrimaryPage(page)
// Update URL for primary pages - use dedicated paths
// Home can be either / or /home, but we'll use / for home
const newUrl = page === 'home' ? '/' : `/${page}`
window.history.pushState(null, '', newUrl)
// NEVER scroll to top - feed should maintain scroll position at all times
}
const pushSecondaryPage = (url: string, index?: number) => {
logger.component('PageManager', 'pushSecondaryPage called', { url })
// Save tab state before navigating
const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
const trendingTab = currentTabStateRef.current.get('search') as 'nostr' | 'relays' | 'hashtags' | undefined
if (currentPrimaryPage && (currentTab || trendingTab)) {
logger.info('PageManager: Desktop - Saving page state', {
page: currentPrimaryPage,
tab: currentTab,
trendingTab
})
savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab, trendingTab })
}
setSecondaryStack((prevStack) => {
logger.component('PageManager', 'Current secondary stack length', { length: prevStack.length })
// For relay pages, clear the stack and start fresh to avoid confusion
if (url.startsWith('/relays/')) {
logger.component('PageManager', 'Clearing stack for relay navigation')
const { newStack, newItem } = pushNewPageToStack([], url, maxStackSize, 0)
logger.component('PageManager', 'New stack created', {
newStackLength: newStack.length,
hasNewItem: !!newItem
})
if (newItem) {
window.history.pushState({ index: newItem.index, url }, '', url)
}
return newStack
}
if (isCurrentPage(prevStack, url)) {
logger.component('PageManager', 'Page already exists, not scrolling')
// NEVER scroll to top - maintain scroll position
return prevStack
}
logger.component('PageManager', 'Creating new page for URL', { url, prevStackLength: prevStack.length })
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index)
logger.component('PageManager', 'New page created', {
newStackLength: newStack.length,
prevStackLength: prevStack.length,
hasNewItem: !!newItem,
newItemUrl: newItem?.url,
newItemIndex: newItem?.index
})
if (newItem) {
window.history.pushState({ index: newItem.index, url }, '', url)
} else {
logger.error('PageManager', 'Failed to create component for URL - component will not be displayed', { url, path: url.split('?')[0].split('#')[0] })
}
return newStack
})
}
const popSecondaryPage = () => {
// In double-pane mode, never open drawer - just pop from stack
if (panelMode === 'double' && !isSmallScreen) {
if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL
// Closing panel should NEVER affect the main page
setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') {
logger.info('PageManager: Desktop - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
logger.info('PageManager: Desktop - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: savedFeedState.trendingTab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab: savedFeedState.trendingTab }
}))
currentTabStateRef.current.set('search', savedFeedState.trendingTab)
}
} else if (secondaryStack.length > 1) {
// Pop from stack directly instead of using history.go(-1)
// This ensures the stack is updated immediately
setSecondaryStack((prevStack) => {
const newStack = prevStack.slice(0, -1)
const topItem = newStack[newStack.length - 1]
if (topItem) {
// Update URL to match the top item
window.history.replaceState({ index: topItem.index, url: topItem.url }, '', topItem.url)
}
return newStack
})
} else {
// Just go back in history - popstate will handle stack update
window.history.go(-1)
}
return
}
// Single-pane mode or mobile: check if drawer is open and stack is empty - close drawer instead
if (drawerOpen && secondaryStack.length === 0) {
// Close drawer and reveal the background page
setDrawerOpen(false)
setTimeout(() => setDrawerNoteId(null), 350)
return
}
// On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack
if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) {
// Close drawer (this will restore the URL to the correct primary page)
setDrawerOpen(false)
setTimeout(() => setDrawerNoteId(null), 350)
// Clear stack
setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') {
logger.info('PageManager: Mobile/Single-pane - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
logger.info('PageManager: Mobile/Single-pane - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: savedFeedState.trendingTab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab: savedFeedState.trendingTab }
}))
currentTabStateRef.current.set('search', savedFeedState.trendingTab)
}
return
}
if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL
// Closing panel should NEVER affect the main page
setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') {
logger.info('PageManager: Desktop - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
logger.info('PageManager: Desktop - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: savedFeedState.trendingTab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab: savedFeedState.trendingTab }
}))
currentTabStateRef.current.set('search', savedFeedState.trendingTab)
}
} else {
window.history.go(-1)
}
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
// Capture the length before clearing
const stackLength = secondaryStack.length
// Clear the state immediately for instant navigation
setSecondaryStack([])
// Also update browser history to keep it in sync
window.history.go(-stackLength)
}
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,
navigateToPrimaryPage: navigatePrimaryPage
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
{primaryNoteView ? (
// Show primary note view with back button on mobile
<div className="flex flex-col h-full w-full">
<div className="flex justify-center py-1 border-b">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Imwald
</span>
</div>
<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' :
primaryViewType === 'profile' ? 'Back' :
primaryViewType === 'hashtag' ? 'Hashtag' :
primaryViewType === 'note' ? getPageTitle(primaryViewType, window.location.pathname) : 'Note'}
</div>
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{primaryNoteView}
</div>
</div>
) : (
<>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
logger.component('PageManager', 'Rendering secondary stack item', {
index,
isLast,
url: item.url,
hasComponent: !!item.component,
display: isLast ? 'block' : 'none'
})
return (
<div
key={item.index}
style={{
display: isLast ? '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>
))}
</>
)}
{drawerNoteId && (
<NoteDrawer
open={drawerOpen}
onOpenChange={(open) => {
setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes)
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350)
}
}}
noteId={drawerNoteId}
/>
)}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>
</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,
navigateToPrimaryPage: navigatePrimaryPage
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
<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 />
{(() => {
if (panelMode === 'double') {
// Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer)
return (
<div className="flex-1 flex overflow-hidden">
{/* Left panel: Feed (flexible, takes remaining space after 1042px) */}
<div className="flex-1 min-w-0 overflow-auto border-r">
<MainContentArea
primaryPages={primaryPages}
currentPrimaryPage={currentPrimaryPage}
primaryNoteView={primaryNoteView}
primaryViewType={primaryViewType}
goBack={goBack}
/>
</div>
{/* Right panel: Secondary stack (1042px fixed width, same as drawer) */}
<div className="w-[1042px] shrink-0 overflow-auto">
{secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
return (
<div
key={item.index}
style={{
display: isLast ? 'block' : 'none'
}}
>
{item.component}
</div>
)
})
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
{/* Empty state - no secondary content */}
</div>
)}
</div>
</div>
)
} else {
// Single-pane mode: show feed only, drawer overlay for notes
return (
<MainContentArea
primaryPages={primaryPages}
currentPrimaryPage={currentPrimaryPage}
primaryNoteView={primaryNoteView}
primaryViewType={primaryViewType}
goBack={goBack}
/>
)
}
})()}
</div>
</div>
{drawerNoteId && (
<NoteDrawer
open={drawerOpen}
onOpenChange={(open) => {
setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes)
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350)
}
}}
noteId={drawerNoteId}
/>
)}
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && (
<Sheet
open={true}
onOpenChange={(open) => {
if (!open) {
// Close drawer and go back
popSecondaryPage()
}
}}
>
<SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0">
<div className="h-full">
{secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
if (!isLast) return null
return (
<div key={item.index}>
{item.component}
</div>
)
})}
</div>
</SheetContent>
</Sheet>
)}
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>
</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
logger.component('PageManager', 'isCurrentPage check', { currentUrl: currentPage.url, newUrl: url, match: currentPage.url === url })
return currentPage.url === url
}
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 })
return {}
}
const ref = createRef<TPageRef>()
// Decode URL parameters for relay pages
const params = { ...(match as any).params }
if (params.url && typeof params.url === 'string') {
params.url = decodeURIComponent(params.url)
logger.component('PageManager', 'Decoded URL parameter', { url: params.url })
}
logger.component('PageManager', 'Creating component with params', { params, index })
try {
const component = cloneElement(element, { ...params, index, ref } as any)
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(
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) {
logger.error('PageManager', 'pushNewPageToStack: No component created', { url, currentIndex, path: url.split('?')[0].split('#')[0] })
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
}
logger.component('PageManager', 'pushNewPageToStack: Success', { url, newStackLength: newStack.length, newItemIndex: currentIndex })
return { newStack, newItem }
}