// This file exports both React components and hooks alongside inline JSX in callbacks,
// making it incompatible with Vite's Fast Refresh auto-detection. Opting into explicit
// full-reload mode to suppress the "incompatible export" HMR warning.
// @refresh reset
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react'
import { NavigationService } from '@/services/navigation.service'
// Page imports needed for primary note view
import { ImwaldBrandBar } from '@/assets/Logo'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
import NoteDrawer from '@/components/NoteDrawer'
import storage from '@/services/local-storage.service'
import client from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import type { Event } from 'nostr-tools'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
// DEPRECATED: useUserPreferences removed - double-panel functionality disabled
import { TPageRef } from '@/types'
import {
cloneElement,
createRef,
isValidElement,
lazy,
type ReactElement,
type ReactNode,
RefObject,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
import {
PrimaryPageContext,
usePrimaryPage,
usePrimaryPageOptional,
type PrimaryPageContextValue
} from '@/contexts/primary-page-context'
import {
applyRouteDocumentMeta,
isNoteDetailPathname,
isProfileDetailPathname
} from '@/lib/document-meta'
import { normalizeUrl } from './lib/url'
import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
import { routes } from './routes'
import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider'
import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context'
import {
PrimaryNoteViewContext,
usePrimaryNoteView,
usePrimaryNoteViewOptional,
type TPrimaryOverlayViewType
} from '@/contexts/primary-note-view-context'
import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-context'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
/** Lazy NoteList pages break: PageManager → … → NoteList → NoteCard → useSmartNoteNavigation → PageManager */
const NoteListPageLazy = lazy(() => import('@/pages/primary/NoteListPage'))
const SecondaryNoteListPageLazy = lazy(() => import('@/pages/secondary/NoteListPage'))
const primaryPageLazyFallback = (
Loading…
)
/** Lazy primary pages: each may import PrimaryPageLayout → usePrimaryPage → would sync-import PageManager. */
const ExplorePageLazy = lazy(() => import('./pages/primary/ExplorePage'))
const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
/** Lazy chrome: Sidebar / bottom bar / dialogs import hooks from PageManager — must not be sync-imported here. */
const SidebarLazy = lazy(() => import('@/components/Sidebar'))
const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar'))
const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog'))
const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast'))
const RelayPulseActiveNpubsSheetLazy = lazy(
() => import('@/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet').then((m) => ({ default: m.RelayPulseActiveNpubsSheet }))
)
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'))
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
function suspensePrimaryPage(page: ReactElement) {
return {page}
}
type TStackItem = {
index: number
url: string
component: React.ReactElement | null
ref: RefObject | null
}
const PRIMARY_PAGE_REF_MAP = {
explore: createRef(),
feed: createRef(),
me: createRef(),
profile: createRef(),
relay: createRef(),
search: createRef(),
'follows-latest': createRef(),
rss: createRef(),
settings: createRef(),
spells: createRef()
}
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
// This is only evaluated when called, not at module load time
const getPrimaryPageMap = () => ({
explore: (
),
feed: (
),
me: (
),
profile: (
),
relay: (
),
search: (
),
'follows-latest': (
),
rss: (
),
settings: (
),
spells: (
)
})
/** Spells is wrapped in ``; navigated props must go to the lazy page, not the boundary. */
function applyPrimaryPageProps(element: ReactNode, props: object): ReactNode {
if (!isValidElement(element)) return element
if (element.type === Suspense) {
const inner = element.props.children
if (isValidElement(inner)) {
return cloneElement(element, undefined, cloneElement(inner, props))
}
}
return cloneElement(element, props)
}
// Type for primary page names - use the return type of getPrimaryPageMap
export type TPrimaryPageName = keyof ReturnType
type TPrimaryPageStateEntry = { name: TPrimaryPageName; element: ReactNode; props?: any }
function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageName; props?: object } | null {
if (pageContext === 'discussions') {
return { name: 'spells', props: { spell: 'discussions' } }
}
if (pageContext === 'explore' || pageContext === 'home') {
return { name: 'explore' }
}
const map = getPrimaryPageMap()
if (pageContext in map) {
return { name: pageContext as TPrimaryPageName }
}
return null
}
function mergePrimaryPageEntry(
prev: TPrimaryPageStateEntry[],
entry: { name: TPrimaryPageName; props?: object }
): TPrimaryPageStateEntry[] {
const map = getPrimaryPageMap()
const element = map[entry.name]
const exists = prev.find((p) => p.name === entry.name)
if (exists) {
if (entry.props) {
exists.props = { ...(exists.props || {}), ...entry.props }
}
return [...prev]
}
return [...prev, { name: entry.name, element, props: entry.props }]
}
export { PrimaryPageContext, usePrimaryPage }
export { useSecondaryPage, useSecondaryPageOptional }
// 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[] = [
'search',
'profile',
'feed',
'spells',
'rss',
'explore',
'follows-latest'
]
if (currentPage && contextualPages.includes(currentPage)) {
return `/${currentPage}/notes/${noteId}`
}
return `/notes/${noteId}`
}
function buildRssArticleUrl(
articleUrl: string,
currentPage: TPrimaryPageName | null,
options?: { rssFeedReadOnly?: boolean }
): string {
const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [
'search',
'profile',
'feed',
'spells',
'rss',
'explore',
'follows-latest'
]
let path =
currentPage && contextualPages.includes(currentPage)
? `/${currentPage}/rss-item/${key}`
: `/rss-item/${key}`
if (options?.rssFeedReadOnly) {
path += `${path.includes('?') ? '&' : '?'}rssFeedReadOnly=1`
}
return path
}
/** True for secondary routes that show an RSS / web article in the panel (contextual or bare). */
function replaceHistoryWithPrimaryPageUrl(
page: TPrimaryPageName,
props?: { spell?: string } | Record | null
) {
const pageUrl = buildPrimaryPageUrl(page, props as { spell?: string } | undefined)
window.history.replaceState(null, '', pageUrl)
}
/** Open an RSS article in the secondary panel (same routing pattern as contextual note URLs). */
export function useSmartRssArticleNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToRssArticle = (
articleUrl: string,
navOptions?: { rssFeedReadOnly?: boolean }
) => {
pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage, navOptions))
}
return { navigateToRssArticle }
}
// Helper function to build contextual relay URL
function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string {
const encodedRelayUrl = encodeURIComponent(relayUrl)
if (currentPage === 'explore') {
return `/explore/relays/${encodedRelayUrl}`
}
return `/relays/${encodedRelayUrl}`
}
/** Path (+ query for spells) pushed when navigating primary pages — shareable URLs for faux spells. */
function buildPrimaryPageUrl(
page: TPrimaryPageName,
props?: { spell?: string } | Record | null
): string {
if (page === 'feed') return '/'
if (page === 'explore') return '/explore'
if (page === 'spells') {
const spell =
props && typeof (props as { spell?: unknown }).spell === 'string'
? String((props as { spell: string }).spell).trim()
: ''
if (spell) return `/spells?spell=${encodeURIComponent(spell)}`
return '/spells'
}
return `/${page}`
}
function spellPropsFromSearch(search: string): { spell: string } | undefined {
const spell = new URLSearchParams(search).get('spell')?.trim()
return spell ? { spell } : undefined
}
/** Primary URL for drawer/overlay restore when we only have pathname + optional full URL for query. */
function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): string {
const popSegments = pathname.split('/').filter(Boolean)
const popFirstSeg = popSegments[0] ?? ''
if (popSegments.length === 0) {
return '/'
}
if (popSegments.length === 1 && popFirstSeg === 'home') {
return '/explore'
}
if (popSegments.length === 1 && popFirstSeg === 'spells') {
try {
const sp = new URL(fullUrlForQuery, window.location.origin).searchParams.get('spell')?.trim()
return buildPrimaryPageUrl('spells', sp ? { spell: sp } : undefined)
} catch {
return '/spells'
}
}
if (popSegments.length === 1) return `/${popFirstSeg}`
return pathname
}
// Helper function to extract noteId and context from URL
function extractValidNoteId(raw: string): string | null {
const decoded = (() => {
try {
return decodeURIComponent(raw).trim()
} catch {
return raw.trim()
}
})()
const withoutPrefix = decoded.startsWith('nostr:') ? decoded.slice(6) : decoded
if (/^[0-9a-f]{64}$/i.test(withoutPrefix)) return withoutPrefix.toLowerCase()
const lower = withoutPrefix.toLowerCase()
if (
lower.startsWith('note1') ||
lower.startsWith('nevent1') ||
lower.startsWith('naddr1')
) {
return withoutPrefix
}
return null
}
function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/
)
if (contextualMatch) {
const noteId = extractValidNoteId(contextualMatch[2])
if (!noteId) return null
return { noteId, context: contextualMatch[1] }
}
// Match standard pattern /notes/{noteId}
const standardMatch = url.match(/\/notes\/(.+)$/)
if (standardMatch) {
const noteId = extractValidNoteId(standardMatch[1])
if (!noteId) return null
return { noteId }
}
return null
}
// Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop
export function useSmartNoteNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer } = useNoteDrawer()
const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => {
// Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id})
const parsed = parseNoteUrl(url)
if (!parsed) {
logger.warn('navigateToNote ignored invalid note URL', { url })
return
}
const { noteId } = parsed
// If event is provided, store it in navigation event store to avoid re-fetching
if (event) {
navigationEventStore.clear()
navigationEventStore.setEvent(event)
client.addEventToCache(event)
}
// Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) client.addEventToCache(ev)
}
}
// 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, event)
} else {
// Desktop: check panel mode
const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') {
// Always push so the secondary stack matches the drawer; otherwise the first note is not on
// the stack and Back after opening a quote only closes the drawer instead of the parent note.
pushSecondaryPage(contextualUrl)
openDrawer(noteId, event)
} else {
// Double-pane: use secondary panel
pushSecondaryPage(contextualUrl)
}
}
}
return { navigateToNote }
}
/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */
export function useSmartNoteNavigationOptional() {
const pushSecondaryPage = useSecondaryPageOptional()
const noteDrawer = useNoteDrawerOptional()
const screenSize = useScreenSizeOptional()
const primaryPage = usePrimaryPageOptional()
if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) {
return {
navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => {
window.location.href = url
}
}
}
const { push } = pushSecondaryPage
const { openDrawer } = noteDrawer
const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage
const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => {
const parsed = parseNoteUrl(url)
if (!parsed) {
logger.warn('navigateToNote (optional) ignored invalid note URL', { url })
return
}
const { noteId } = parsed
if (event) {
navigationEventStore.clear()
navigationEventStore.setEvent(event)
client.addEventToCache(event)
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) client.addEventToCache(ev)
}
}
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) {
push(contextualUrl)
openDrawer(noteId, event)
} else {
const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') {
push(contextualUrl)
openDrawer(noteId, event)
} else {
push(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|home|feed|spells|explore|follows-latest)\/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(
suspensePrimaryPage(),
'relay'
)
} else {
// Desktop: always use secondary routing (will be rendered in drawer in single-pane, side panel in double-pane)
pushSecondaryPage(contextualUrl)
}
}
return { navigateToRelay }
}
/** Safe variant for createRoot trees. Returns fallback navigation when outside providers. */
export function useSmartRelayNavigationOptional() {
const primaryNoteView = usePrimaryNoteViewOptional()
const secondaryPage = useSecondaryPageOptional()
const screenSize = useScreenSizeOptional()
const primaryPage = usePrimaryPageOptional()
if (!primaryNoteView || !secondaryPage || !screenSize || !primaryPage) {
return { navigateToRelay: (url: string) => { window.location.href = url } }
}
const { setPrimaryNoteView } = primaryNoteView
const { push: pushSecondaryPage } = secondaryPage
const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage
const navigateToRelay = (url: string) => {
const relayUrlMatch =
url.match(
/\/(discussions|search|profile|home|feed|spells|explore|follows-latest)\/relays\/(.+)$/
) ||
url.match(/\/relays\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage)
if (isSmallScreen) {
window.history.pushState(null, '', contextualUrl)
setPrimaryNoteView(
suspensePrimaryPage(),
'relay'
)
} else {
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(
suspensePrimaryPage(),
'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(
suspensePrimaryPage(),
'profile'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
}
return { navigateToProfile }
}
/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded mentions). Returns fallback navigation when outside providers. */
export function useSmartProfileNavigationOptional() {
const primaryNoteView = usePrimaryNoteViewOptional()
const secondaryPage = useSecondaryPageOptional()
const screenSize = useScreenSizeOptional()
const noteDrawer = useNoteDrawerOptional()
if (!primaryNoteView || !secondaryPage || !screenSize || !noteDrawer) {
return {
navigateToProfile: (url: string) => {
window.location.href = url
}
}
}
const { setPrimaryNoteView } = primaryNoteView
const { push: pushSecondaryPage } = secondaryPage
const { isSmallScreen } = screenSize
const { closeDrawer, isDrawerOpen } = noteDrawer
const navigateToProfile = (url: string) => {
if (isDrawerOpen) {
closeDrawer()
setTimeout(() => {
if (isSmallScreen) {
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'profile'
)
} else {
pushSecondaryPage(url)
}
}, 400)
} else {
if (isSmallScreen) {
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'profile'
)
} else {
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(
,
'hashtag'
)
// Dispatch custom event as a fallback for components that might be reused
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
}
return { navigateToHashtag }
}
/** Safe variant for createRoot trees. Returns fallback navigation when outside providers. */
export function useSmartHashtagNavigationOptional() {
const primaryNoteView = usePrimaryNoteViewOptional()
if (!primaryNoteView) {
return { navigateToHashtag: (url: string) => { window.location.href = url.startsWith('/') ? url : `/${url}` } }
}
const { setPrimaryNoteView, getNavigationCounter } = primaryNoteView
const navigateToHashtag = (url: string) => {
const parsedUrl = url.startsWith('/') ? url : `/${url}`
window.history.pushState(null, '', parsedUrl)
const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '')
const hashtag = searchParams.get('t') || ''
const counter = getNavigationCounter()
const key = `hashtag-${hashtag}-${counter + 1}`
setPrimaryNoteView(
,
'hashtag'
)
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(
suspensePrimaryPage(),
'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(suspensePrimaryPage(), 'mute')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
return { navigateToMuteList }
}
export function useSmartBookmarkListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToBookmarkList = (url: string) => {
if (isSmallScreen) {
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'bookmarks'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToBookmarkList }
}
export function useSmartPinListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToPinList = (url: string) => {
if (isSmallScreen) {
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'pins'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToPinList }
}
export function useSmartInterestListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToInterestList = (url: string) => {
if (isSmallScreen) {
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'interests'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToInterestList }
}
export function useSmartUserEmojiListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToUserEmojiList = (url: string) => {
if (isSmallScreen) {
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(),
'user-emojis'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToUserEmojiList }
}
// 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(
suspensePrimaryPage(
),
'others-relay-settings'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
}
}
return { navigateToOthersRelaySettings }
}
/** Settings index is a normal primary page; sub-routes open on the secondary stack (panel / drawer). */
export function useSmartSettingsNavigation() {
const { navigate: navigatePrimary } = usePrimaryPage()
const { push: pushSecondaryPage } = useSecondaryPage()
const navigateToSettings = (url: string) => {
const base = url.split('?')[0].split('#')[0]
if (base === '/settings') {
navigatePrimary('settings')
return
}
pushSecondaryPage(url)
}
return { navigateToSettings }
}
// DEPRECATED: ConditionalHomePage removed - double-panel functionality disabled
// Helper function to get page title based on view type and URL
function getPageTitle(viewType: TPrimaryOverlayViewType | 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,
onPrimaryPanelRefresh
}: {
primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[]
currentPrimaryPage: TPrimaryPageName
primaryNoteView: ReactNode | null
primaryViewType: TPrimaryOverlayViewType | null
goBack: () => void
onPrimaryPanelRefresh: () => 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
})
// flex + min-h-0 + min-w-0 so primary pages get a real height in flex parents and can shrink horizontally (double-pane).
return (
{primaryNoteView ? (
// Show note view with back button
{getPageTitle(primaryViewType, window.location.pathname)}
{primaryNoteView}
) : (
// Show normal primary pages
primaryPages.map(({ name, element, props }) => {
const isCurrentPage = currentPrimaryPage === name
logger.debug(`Primary page ${name}:`, { isCurrentPage, currentPrimaryPage })
return (
{(() => {
try {
logger.debug(`Rendering ${name} component`)
return props ? applyPrimaryPageProps(element, props) : element
} catch (error) {
logger.error(`Error rendering ${name} component:`, error)
return
Error rendering {name}: {error instanceof Error ? error.message : String(error)}
}
})()}
)
})
)}
{/* DEPRECATED: Secondary panel removed - double-panel functionality disabled */}
)
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
// DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled
const [currentPrimaryPage, setCurrentPrimaryPage] = useState('feed')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'feed',
element: getPrimaryPageMap().feed
}
])
const [secondaryStack, setSecondaryStack] = useState([])
/** Latest stack for popstate / pop() — avoids stale length when history and React state race. */
const secondaryStackRef = useRef([])
useLayoutEffect(() => {
secondaryStackRef.current = secondaryStack
}, [secondaryStack])
const [primaryNoteView, setPrimaryNoteViewState] = useState(null)
const [primaryViewType, setPrimaryViewType] = useState(null)
const [savedPrimaryPage, setSavedPrimaryPage] = useState(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState(null)
const [singlePaneSheetOpen, setSinglePaneSheetOpen] = useState(false)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
/** Latest primary page for async callbacks (drawer-close timer) without resubscribing effects on every primary change. */
const currentPrimaryPageRef = useRef(currentPrimaryPage)
useLayoutEffect(() => {
currentPrimaryPageRef.current = currentPrimaryPage
}, [currentPrimaryPage])
const navigationCounterRef = useRef(0)
const primaryPanelRefreshRef = useRef<(() => void) | null>(null)
const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => {
primaryPanelRefreshRef.current = fn
}, [])
const triggerPrimaryPanelRefresh = useCallback(() => {
primaryPanelRefreshRef.current?.()
}, [])
const savedFeedStateRef = useRef