diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 15ef4d3..eeb7d6e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -14,8 +14,10 @@ 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 NotePage from '@/pages/secondary/NotePage' +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' @@ -51,7 +53,6 @@ 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 @@ -84,7 +85,9 @@ const PRIMARY_PAGE_REF_MAP = { discussions: createRef() } -const PRIMARY_PAGE_MAP = { +// 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: , explore: , notifications: , @@ -93,7 +96,10 @@ const PRIMARY_PAGE_MAP = { relay: , search: , discussions: -} +}) + +// Type for primary page names - use the return type of getPrimaryPageMap +export type TPrimaryPageName = keyof ReturnType const PrimaryPageContext = createContext(undefined) @@ -105,6 +111,13 @@ const PrimaryNoteViewContext = createContext<{ 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) { @@ -129,44 +142,124 @@ export function usePrimaryNoteView() { return context } -// Fixed: Note navigation now uses primary note view on mobile, secondary routing on desktop +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 { setPrimaryNoteView } = usePrimaryNoteView() const { push: pushSecondaryPage } = useSecondaryPage() + const { openDrawer, isDrawerOpen } = useNoteDrawer() const { isSmallScreen } = useScreenSize() + const { current: currentPrimaryPage } = usePrimaryPage() const navigateToNote = (url: string) => { - // Event ID will be saved when setPrimaryNoteView or pushSecondaryPage is called + // 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) { - // Use primary note view on mobile - const noteId = url.replace('/notes/', '') - window.history.pushState(null, '', url) - setPrimaryNoteView(, 'note') + // Mobile: always push to secondary stack AND update drawer + // This ensures back button works when clicking embedded events + pushSecondaryPage(contextualUrl) + openDrawer(noteId) } else { - // Use secondary routing on desktop - pushSecondaryPage(url) + // 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 on desktop +// 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 - const relayUrl = decodeURIComponent(url.replace('/relays/', '')) - window.history.pushState(null, '', url) + window.history.pushState(null, '', contextualUrl) setPrimaryNoteView(, 'relay') } else { - // Use secondary routing on desktop - pushSecondaryPage(url) + // Desktop: always use secondary routing (will be rendered in drawer in single-pane, side panel in double-pane) + pushSecondaryPage(contextualUrl) } } @@ -439,578 +532,29 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { >([ { name: 'home', - element: PRIMARY_PAGE_MAP.home + element: getPrimaryPageMap().home } ]) const [secondaryStack, setSecondaryStack] = useState([]) const [primaryNoteView, setPrimaryNoteViewState] = useState(null) const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) + const [drawerNoteId, setDrawerNoteId] = useState(null) + const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const navigationCounterRef = useRef(0) - const savedEventIdsRef = useRef>(new Map()) const savedFeedStateRef = useRef>(new Map()) - const restoringScrollRef = useRef>(new Set()) // Track which eventIds are currently being restored const currentTabStateRef = useRef>(new Map()) // Track current tab state for each page - // Helper function to wait for an event element to appear and scroll to it - // Optionally uses cached feed state to restore scroll position first - const waitForEventAndScroll = useCallback((eventId: string, page: TPrimaryPageName, maxAttempts = 100, delay = 100) => { - // Prevent duplicate restoration attempts - if (restoringScrollRef.current.has(eventId)) { - logger.debug('PageManager: Already restoring scroll for event', { eventId }) - return - } - restoringScrollRef.current.add(eventId) - - logger.info('PageManager: Starting waitForEventAndScroll', { eventId, page, isSmallScreen }) - - let attempts = 0 - let timeoutId: NodeJS.Timeout | null = null - let observer: MutationObserver | null = null - let isResolved = false - let lastScrollHeight = 0 - let stuckAttempts = 0 - - const scrollToEvent = () => { - if (isResolved) return - - if (isSmallScreen) { - // Find all elements with this event ID (there might be multiple - original and embedded quotes) - const allEventElements = Array.from(document.querySelectorAll(`[data-event-id="${eventId}"]`)) as HTMLElement[] - - // Filter out embedded notes - they're inside [data-embedded-note] containers or are embedded themselves - const mainEventElements = allEventElements.filter(el => { - // Check if this element is inside an embedded note container - const isInsideEmbedded = el.closest('[data-embedded-note]') !== null - // Check if this element itself is an embedded note - const isEmbedded = el.hasAttribute('data-embedded-note') - return !isInsideEmbedded && !isEmbedded - }) - - // If we have cached scroll position, find the element closest to it - // Otherwise, just use the first main event element - let eventElement: HTMLElement | null = null - if (mainEventElements.length > 0) { - const cachedFeedState = savedFeedStateRef.current.get(page) - if (cachedFeedState && cachedFeedState.scrollPosition > 0) { - // Find the element closest to the cached scroll position - let closestElement: HTMLElement | null = null - let closestDistance = Infinity - - mainEventElements.forEach(el => { - const rect = el.getBoundingClientRect() - const elementTop = rect.top + window.scrollY - const distance = Math.abs(elementTop - cachedFeedState.scrollPosition) - if (distance < closestDistance) { - closestDistance = distance - closestElement = el - } - }) - - eventElement = closestElement || mainEventElements[0] - } else { - eventElement = mainEventElements[0] - } - } - - if (eventElement) { - // Scroll to top of the feed (event at the top) - eventElement.scrollIntoView({ behavior: 'instant', block: 'start' }) - logger.info('PageManager: Mobile - Scrolled to saved event at top', { - eventId, - attempts, - totalElements: allEventElements.length, - mainElements: mainEventElements.length - }) - isResolved = true - cleanup() - return true - } - } else { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - if (scrollArea) { - // Find all elements with this event ID (there might be multiple - original and embedded quotes) - const allEventElements = Array.from(scrollArea.querySelectorAll(`[data-event-id="${eventId}"]`)) as HTMLElement[] - - // Filter out embedded notes - they're inside [data-embedded-note] containers or are embedded themselves - const mainEventElements = allEventElements.filter(el => { - // Check if this element is inside an embedded note container - const isInsideEmbedded = el.closest('[data-embedded-note]') !== null - // Check if this element itself is an embedded note - const isEmbedded = el.hasAttribute('data-embedded-note') - return !isInsideEmbedded && !isEmbedded - }) - - // If we have cached scroll position, find the element closest to it - // Otherwise, just use the first main event element - let eventElement: HTMLElement | null = null - if (mainEventElements.length > 0) { - const cachedFeedState = savedFeedStateRef.current.get(page) - if (cachedFeedState && cachedFeedState.scrollPosition > 0) { - // Find the element closest to the cached scroll position - let closestElement: HTMLElement | null = null - let closestDistance = Infinity - - mainEventElements.forEach(el => { - const rect = el.getBoundingClientRect() - const scrollAreaRect = scrollArea.getBoundingClientRect() - const elementTop = rect.top - scrollAreaRect.top + scrollArea.scrollTop - const distance = Math.abs(elementTop - cachedFeedState.scrollPosition) - if (distance < closestDistance) { - closestDistance = distance - closestElement = el - } - }) - - eventElement = closestElement || mainEventElements[0] - } else { - eventElement = mainEventElements[0] - } - } - - if (eventElement) { - // Get the element's current position relative to the scroll container - const scrollAreaRect = scrollArea.getBoundingClientRect() - const elementRect = eventElement.getBoundingClientRect() - - // Calculate where the element currently is relative to the scroll container's viewport - const elementTopInViewport = elementRect.top - scrollAreaRect.top - - // The element's position in the scroll container's content = current viewport position + current scroll position - const elementTopInContent = elementTopInViewport + scrollArea.scrollTop - - // Scroll to position the element at the top (scrollTop = element's position in content) - scrollArea.scrollTop = elementTopInContent - - // Verify after a brief delay to allow scroll to complete - setTimeout(() => { - const verifyRect = eventElement.getBoundingClientRect() - const verifyScrollAreaRect = scrollArea.getBoundingClientRect() - const actualTop = verifyRect.top - verifyScrollAreaRect.top - - // If still not at top, try one more time with a small adjustment - if (Math.abs(actualTop) > 10) { - const adjustedScrollTop = scrollArea.scrollTop + actualTop - scrollArea.scrollTop = adjustedScrollTop - - // Verify again - setTimeout(() => { - const finalRect = eventElement.getBoundingClientRect() - const finalScrollAreaRect = scrollArea.getBoundingClientRect() - const finalTop = finalRect.top - finalScrollAreaRect.top - logger.info('PageManager: Desktop - Scrolled to saved event at top (adjusted)', { - eventId, - attempts, - elementTopInContent, - adjustedScrollTop, - actualScrollTop: scrollArea.scrollTop, - elementTopRelativeToViewport: finalTop - }) - }, 10) - } else { - logger.info('PageManager: Desktop - Scrolled to saved event at top', { - eventId, - attempts, - elementTopInContent, - actualScrollTop: scrollArea.scrollTop, - elementTopRelativeToViewport: actualTop - }) - } - }, 10) - - isResolved = true - cleanup() - return true - } else { - // Event not found - check if we need to trigger lazy loading by scrolling down - const allEvents = scrollArea.querySelectorAll('[data-event-id]') - const loadedEventIds = Array.from(allEvents).map(el => el.getAttribute('data-event-id')) - const eventIsLoaded = loadedEventIds.includes(eventId) - - // If event is not loaded, try to trigger lazy loading - if (!eventIsLoaded) { - const currentScrollTop = scrollArea.scrollTop - const scrollHeight = scrollArea.scrollHeight - const clientHeight = scrollArea.clientHeight - const maxScroll = Math.max(0, scrollHeight - clientHeight) - const cachedFeedState = savedFeedStateRef.current.get(page) - - // Check if the target event is in the cached event IDs - if so, we know it should be loaded - const eventIsInCachedList = cachedFeedState?.eventIds.includes(eventId) ?? false - - // If we have cached state and the event is in the cached list, we know it should exist - // Scroll to bottom to trigger lazy loading, but only if we're not already at the bottom - if (eventIsInCachedList && cachedFeedState) { - // Track if scroll height is increasing (content is loading) - const scrollHeightIncreased = scrollHeight > lastScrollHeight - if (scrollHeightIncreased) { - lastScrollHeight = scrollHeight - stuckAttempts = 0 - } else if (lastScrollHeight > 0) { - stuckAttempts++ - } else { - lastScrollHeight = scrollHeight - } - - // If cached position is beyond current scroll height, we need to load more content - // Scroll to the bottom to trigger the IntersectionObserver - if (cachedFeedState.scrollPosition > maxScroll) { - // If we're stuck (scroll height not increasing), wait longer before trying again - if (stuckAttempts > 5 && attempts > 10) { - // Wait a bit longer - content might be loading but DOM hasn't updated yet - if (attempts % 10 === 0) { - logger.debug('PageManager: Desktop - Scroll height not increasing, waiting for content to load', { - eventId, - attempts, - stuckAttempts, - scrollHeight, - lastScrollHeight, - loadedEvents: allEvents.length - }) - } - return false - } - - // Scroll to bottom to trigger lazy loading - scrollArea.scrollTop = maxScroll - - if (attempts % 3 === 0 || attempts < 5) { - logger.info('PageManager: Desktop - Scrolling to bottom to trigger lazy loading (event in cached list)', { - eventId, - attempts, - currentScrollTop, - maxScroll, - cachedPosition: cachedFeedState.scrollPosition, - scrollHeight, - loadedEvents: allEvents.length, - cachedEventCount: cachedFeedState.eventIds.length, - scrollHeightIncreased - }) - } - return false - } else { - // Cached position is within current scroll height, but event not found yet - // This might mean the event order changed or it's not rendered yet - // Scroll towards cached position to trigger loading - const distanceToCached = Math.abs(currentScrollTop - cachedFeedState.scrollPosition) - if (distanceToCached > 10) { - const targetScroll = Math.min(cachedFeedState.scrollPosition, maxScroll) - scrollArea.scrollTop = targetScroll - - if (attempts % 3 === 0 || attempts < 5) { - logger.info('PageManager: Desktop - Scrolling towards cached position (event in cached list)', { - eventId, - attempts, - currentScrollTop, - targetScroll, - cachedPosition: cachedFeedState.scrollPosition, - scrollHeight, - maxScroll, - loadedEvents: allEvents.length - }) - } - return false - } - } - } else { - // Event not in cached list or no cached state - scroll down gradually to trigger lazy loading - if (maxScroll > 0 && currentScrollTop < maxScroll * 0.95) { - // Scroll down more aggressively - by a full viewport or 1000px, whichever is smaller - const scrollIncrement = Math.min(clientHeight * 1.0, 1000) - const newScrollTop = Math.min(currentScrollTop + scrollIncrement, maxScroll) - scrollArea.scrollTop = newScrollTop - - if (attempts % 5 === 0) { - logger.info('PageManager: Desktop - Scrolling down to trigger lazy loading', { - eventId, - attempts, - currentScrollTop, - newScrollTop, - scrollHeight, - maxScroll, - loadedEvents: allEvents.length, - scrollIncrement - }) - } - } else { - if (attempts % 10 === 0) { - logger.debug('PageManager: Desktop - Cannot scroll further or at bottom', { - eventId, - attempts, - currentScrollTop, - maxScroll, - scrollPercentage: maxScroll > 0 ? (currentScrollTop / maxScroll).toFixed(2) : 'N/A', - loadedEvents: allEvents.length - }) - } - } - } - } - - // Log debug info periodically - if (attempts === 0 || attempts % 10 === 0) { - logger.debug('PageManager: Desktop - Event not found yet', { - eventId, - attempts, - totalEventsInDocument: document.querySelectorAll('[data-event-id]').length, - totalEventsInScrollArea: allEvents.length, - eventIsLoaded - }) - } - } - } else { - if (attempts === 0 || attempts % 10 === 0) { - logger.debug('PageManager: Desktop - ScrollArea not found yet', { eventId, attempts }) - } - } - } - return false - } - - const tryScroll = () => { - if (isResolved) return - attempts++ - - if (scrollToEvent()) { - return - } - - if (attempts < maxAttempts) { - timeoutId = setTimeout(tryScroll, delay) - } else { - // Final debug: Check what events are actually in the DOM - const allEvents = document.querySelectorAll('[data-event-id]') - const eventIds = Array.from(allEvents).slice(0, 10).map(el => el.getAttribute('data-event-id')) - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - const scrollAreaEvents = scrollArea ? scrollArea.querySelectorAll('[data-event-id]') : [] - const scrollAreaEventIds = Array.from(scrollAreaEvents).slice(0, 10).map(el => el.getAttribute('data-event-id')) - - logger.warn('PageManager: Could not find saved event element after max attempts', { - eventId, - page, - attempts, - totalEventsInDocument: allEvents.length, - totalEventsInScrollArea: scrollAreaEvents.length, - sampleEventIds: eventIds, - sampleScrollAreaEventIds: scrollAreaEventIds - }) - cleanup() - } - } - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - if (observer) { - observer.disconnect() - observer = null - } - restoringScrollRef.current.delete(eventId) - } - - // Wait a bit for the page to render before trying - setTimeout(() => { - // First, restore scroll position from cached feed state to trigger lazy loading - const cachedFeedState = savedFeedStateRef.current.get(page) - const eventIsInCachedList = cachedFeedState?.eventIds.includes(eventId) ?? false - logger.debug('PageManager: Checking cached feed state for restoration', { - page, - hasCachedState: !!cachedFeedState, - cachedScrollPosition: cachedFeedState?.scrollPosition, - cachedEventCount: cachedFeedState?.eventIds.length, - eventIdInCachedList: eventIsInCachedList, - allCachedPages: Array.from(savedFeedStateRef.current.keys()) - }) - - if (cachedFeedState && !eventIsInCachedList) { - logger.warn('PageManager: Target event not in cached event list - may not exist in feed', { - eventId, - page, - cachedEventCount: cachedFeedState.eventIds.length, - sampleCachedIds: cachedFeedState.eventIds.slice(0, 5) - }) - } - - if (cachedFeedState && cachedFeedState.scrollPosition > 0) { - if (isSmallScreen) { - window.scrollTo({ top: cachedFeedState.scrollPosition, behavior: 'instant' }) - logger.info('PageManager: Mobile - Restored scroll position from cache', { - page, - scrollPosition: cachedFeedState.scrollPosition, - cachedEventCount: cachedFeedState.eventIds.length - }) - } else { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - if (scrollArea) { - const maxScroll = Math.max(0, scrollArea.scrollHeight - scrollArea.clientHeight) - const needsMoreContent = cachedFeedState.scrollPosition > maxScroll - - // If cached position is beyond current scroll height, scroll to bottom to trigger loading - // Otherwise, scroll to the cached position - const targetScroll = needsMoreContent ? maxScroll : Math.min(cachedFeedState.scrollPosition, maxScroll) - scrollArea.scrollTop = targetScroll - - logger.info('PageManager: Desktop - Restored scroll position from cache', { - page, - scrollPosition: cachedFeedState.scrollPosition, - targetScroll, - cachedEventCount: cachedFeedState.eventIds.length, - scrollAreaScrollHeight: scrollArea.scrollHeight, - maxScroll, - needsMoreContent, - eventIdInCachedList: cachedFeedState.eventIds.includes(eventId) - }) - } else { - logger.warn('PageManager: Desktop - ScrollArea not found when trying to restore cached position', { page }) - } - } - } else { - logger.debug('PageManager: No cached scroll position to restore', { - page, - hasCachedState: !!cachedFeedState, - cachedScrollPosition: cachedFeedState?.scrollPosition - }) - } - - // Wait a bit longer for lazy loading to trigger after restoring scroll position - setTimeout(() => { - // Try to find and scroll to the event - if (scrollToEvent()) { - return - } - - // Set up MutationObserver to watch for when the element appears - const targetNode = isSmallScreen ? document.body : document.querySelector('[data-radix-scroll-area-viewport]') || document.body - if (targetNode) { - observer = new MutationObserver(() => { - if (!isResolved && scrollToEvent()) { - return - } - }) - - observer.observe(targetNode, { - childList: true, - subtree: true, - attributes: false - }) - logger.debug('PageManager: MutationObserver set up', { eventId, targetNode: targetNode.tagName }) - } else { - logger.warn('PageManager: Could not find target node for MutationObserver', { eventId, isSmallScreen }) - } - - // Also poll as a fallback - timeoutId = setTimeout(tryScroll, delay) - }, 300) // Wait 300ms after restoring scroll position for lazy loading to trigger - - // Cleanup after max time (maxAttempts * delay) - setTimeout(() => { - if (!isResolved) { - // Final debug: Check what events are actually in the DOM - const allEvents = document.querySelectorAll('[data-event-id]') - const eventIds = Array.from(allEvents).slice(0, 20).map(el => el.getAttribute('data-event-id')) - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - const scrollAreaEvents = scrollArea ? scrollArea.querySelectorAll('[data-event-id]') : [] - const scrollAreaEventIds = Array.from(scrollAreaEvents).slice(0, 20).map(el => el.getAttribute('data-event-id')) - const eventExistsInDocument = Array.from(allEvents).some(el => el.getAttribute('data-event-id') === eventId) - const eventExistsInScrollArea = scrollArea ? Array.from(scrollAreaEvents).some(el => el.getAttribute('data-event-id') === eventId) : false - - logger.warn('PageManager: waitForEventAndScroll timed out', { - eventId, - page, - attempts, - totalEventsInDocument: allEvents.length, - totalEventsInScrollArea: scrollAreaEvents.length, - eventExistsInDocument, - eventExistsInScrollArea, - sampleEventIds: eventIds, - sampleScrollAreaEventIds: scrollAreaEventIds, - scrollAreaExists: !!scrollArea - }) - cleanup() - } - }, maxAttempts * delay) - }, 200) // Wait 200ms for initial render - }, [isSmallScreen]) - 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) - // Find the event that's currently visible in the viewport and save its ID - // Also cache the feed state (all visible event IDs and scroll position) - const findVisibleEventIdAndCacheFeedState = () => { - if (isSmallScreen) { - // On mobile, find event in window viewport - const viewportCenter = window.scrollY + window.innerHeight / 2 - const allEvents = document.querySelectorAll('[data-event-id]') - let closestEvent: HTMLElement | null = null - let closestDistance = Infinity - const eventIds: string[] = [] - - allEvents.forEach((el) => { - const eventId = el.getAttribute('data-event-id') - if (eventId) { - eventIds.push(eventId) - } - const rect = el.getBoundingClientRect() - const elementCenter = rect.top + window.scrollY + rect.height / 2 - const distance = Math.abs(elementCenter - viewportCenter) - if (distance < closestDistance) { - closestDistance = distance - closestEvent = el as HTMLElement - } - }) - - const visibleEventId = (closestEvent as HTMLElement | null)?.getAttribute('data-event-id') - const scrollPosition = window.scrollY - - return { visibleEventId, eventIds, scrollPosition } - } else { - // On desktop, find event in ScrollArea viewport - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement - if (scrollArea) { - const viewportCenter = scrollArea.scrollTop + scrollArea.clientHeight / 2 - const allEvents = scrollArea.querySelectorAll('[data-event-id]') - let closestEvent: HTMLElement | null = null - let closestDistance = Infinity - const eventIds: string[] = [] - - allEvents.forEach((el) => { - const eventId = el.getAttribute('data-event-id') - if (eventId) { - eventIds.push(eventId) - } - const rect = el.getBoundingClientRect() - const scrollAreaRect = scrollArea.getBoundingClientRect() - const elementTop = rect.top - scrollAreaRect.top + scrollArea.scrollTop - const elementCenter = elementTop + rect.height / 2 - const distance = Math.abs(elementCenter - viewportCenter) - if (distance < closestDistance) { - closestDistance = distance - closestEvent = el as HTMLElement - } - }) - - const visibleEventId = (closestEvent as HTMLElement | null)?.getAttribute('data-event-id') - const scrollPosition = scrollArea.scrollTop - - return { visibleEventId, eventIds, scrollPosition } - } - } - return { visibleEventId: null, eventIds: [], scrollPosition: 0 } - } - - const { visibleEventId, eventIds, scrollPosition } = findVisibleEventIdAndCacheFeedState() - // Get current tab state from ref (updated by components via events) const currentTab = currentTabStateRef.current.get(currentPrimaryPage) @@ -1036,36 +580,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Get trending tab if on search page const trendingTab = currentTabStateRef.current.get('search') as 'nostr' | 'relays' | 'hashtags' | undefined - if (visibleEventId) { - logger.info('PageManager: Saving visible event ID and feed state', { - page: currentPrimaryPage, - eventId: visibleEventId, - eventCount: eventIds.length, - scrollPosition, - tab: currentTab, - discussionsState, - trendingTab - }) - savedEventIdsRef.current.set(currentPrimaryPage, visibleEventId) - savedFeedStateRef.current.set(currentPrimaryPage, { - eventIds, - scrollPosition, - tab: currentTab, - discussionsState, - trendingTab - }) - } else if (scrollPosition > 0 || currentTab || discussionsState || trendingTab) { - // Save scroll position even if no event ID (for pages without event IDs like notifications, explore) - logger.info('PageManager: Saving scroll position and state (no event ID)', { + // Save state (tab, discussions, trending) if any exists + if (currentTab || discussionsState || trendingTab) { + logger.info('PageManager: Saving page state', { page: currentPrimaryPage, - scrollPosition, tab: currentTab, discussionsState, trendingTab }) savedFeedStateRef.current.set(currentPrimaryPage, { - eventIds: [], - scrollPosition, tab: currentTab, discussionsState, trendingTab @@ -1086,11 +609,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // If clearing the view, restore to the saved primary page if (!view && savedPrimaryPage) { - const newUrl = savedPrimaryPage === 'home' ? '/' : `/?page=${savedPrimaryPage}` + const newUrl = savedPrimaryPage === 'home' ? '/' : `/${savedPrimaryPage}` window.history.replaceState(null, '', newUrl) const savedFeedState = savedFeedStateRef.current.get(savedPrimaryPage) - const savedEventId = savedEventIdsRef.current.get(savedPrimaryPage) // Restore tab state first if (savedFeedState?.tab) { @@ -1123,43 +645,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { })) currentTabStateRef.current.set('search', savedFeedState.trendingTab) } - - // Scroll to the saved event or position - if (savedEventId) { - logger.info('PageManager: Restoring to saved event', { page: savedPrimaryPage, eventId: savedEventId }) - try { - waitForEventAndScroll(savedEventId, savedPrimaryPage) - } catch (error) { - logger.error('PageManager: Error calling waitForEventAndScroll', { error, savedEventId, savedPrimaryPage }) - } - } else if (savedFeedState && savedFeedState.scrollPosition > 0) { - // Restore scroll position for pages without event IDs - logger.info('PageManager: Restoring scroll position (no event ID)', { - page: savedPrimaryPage, - scrollPosition: savedFeedState.scrollPosition - }) - // Wait longer for content to load, then restore scroll position - setTimeout(() => { - const restoreScroll = () => { - if (isSmallScreen) { - window.scrollTo({ top: savedFeedState.scrollPosition, behavior: 'instant' }) - } else { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - if (scrollArea) { - const maxScroll = Math.max(0, scrollArea.scrollHeight - scrollArea.clientHeight) - const targetScroll = Math.min(savedFeedState.scrollPosition, maxScroll) - scrollArea.scrollTop = targetScroll - - // If content hasn't loaded enough yet, try again after a delay - if (targetScroll < savedFeedState.scrollPosition && maxScroll < savedFeedState.scrollPosition) { - setTimeout(restoreScroll, 200) - } - } - } - } - restoreScroll() - }, 300) - } } } @@ -1180,9 +665,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { window.history.back() } } + + // Drawer handlers + const openDrawer = useCallback((noteId: string) => { + setDrawerNoteId(noteId) + setDrawerOpen(true) + }, []) + + const closeDrawer = () => { + setDrawerOpen(false) + // Clear noteId after animation completes (Sheet animation is 300ms) + setTimeout(() => setDrawerNoteId(null), 300) + } const ignorePopStateRef = useRef(false) - // Handle browser back button + // Handle browser back button for primary note view useEffect(() => { const handlePopState = () => { if (ignorePopStateRef.current) { @@ -1190,15 +687,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return } - // If we have a primary note view open, close it and go back to the main page - if (primaryNoteView) { + // 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]) + }, [primaryNoteView, drawerOpen]) useEffect(() => { if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { @@ -1221,8 +718,91 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { 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 - // DEPRECATED: Double-panel logic removed - always add to secondary stack + 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 + } + } + } + + // For relay URLs and other non-note URLs, always push to secondary stack + // (will be rendered in drawer in single-pane mode, side panel in double-pane mode) + + // For other non-note URLs, push to secondary stack setSecondaryStack((prevStack) => { if (isCurrentPage(prevStack, url)) return prevStack @@ -1238,17 +818,51 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return newStack }) } else { + // Check for relay URL in query params (legacy support) 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 }) + 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) + } } - } else if (page && page in PRIMARY_PAGE_MAP) { - navigatePrimaryPage(page as TPrimaryPageName) + // If pathname doesn't match a primary page, it might be a secondary route + // which is handled elsewhere } } @@ -1266,6 +880,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } 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 + // Only in single-pane mode or mobile + if (!noteIdToShow && drawerOpen && (isSmallScreen || panelMode === 'single')) { + closeDrawer() + } + setSecondaryStack((pre) => { const currentItem = pre[pre.length - 1] as TStackItem | undefined const currentIndex = currentItem?.index @@ -1292,8 +921,38 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // 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) + // 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')) { + closeDrawer() + } + 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({ @@ -1302,6 +961,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { 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 @@ -1312,7 +978,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } } if (newStack.length === 0) { - window.history.replaceState(null, '', '/') + // 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 { + // Stack still has items - update drawer to show the top item's note (for mobile/single-pane) + if (isSmallScreen || panelMode === 'single') { + // 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) { + // Use setTimeout to ensure drawer update happens after stack state is committed + setTimeout(() => { + openDrawer(topNoteId) + }, 0) + } + } + } + } } return newStack }) @@ -1323,7 +1012,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return () => { window.removeEventListener('popstate', onPopState) } - }, []) + }, [isSmallScreen, openDrawer, closeDrawer, panelMode, drawerOpen]) // Listen for tab state changes from components useEffect(() => { @@ -1337,12 +1026,24 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { 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 scroll position and tab state when returning to primary page from browser back button + // Restore tab state when returning to primary page from browser back button useEffect(() => { if (secondaryStack.length === 0 && currentPrimaryPage) { const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) - const savedEventId = savedEventIdsRef.current.get(currentPrimaryPage) // Restore tab state first if (savedFeedState?.tab) { @@ -1376,50 +1077,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { })) currentTabStateRef.current.set('search', savedFeedState.trendingTab) } - - // Restore scroll position - if (savedEventId) { - logger.info('PageManager: Browser back - Restoring to saved event', { page: currentPrimaryPage, eventId: savedEventId }) - try { - waitForEventAndScroll(savedEventId, currentPrimaryPage) - } catch (error) { - logger.error('PageManager: Error calling waitForEventAndScroll from useEffect', { error, savedEventId, currentPrimaryPage }) - } - } else if (savedFeedState && savedFeedState.scrollPosition > 0) { - // Restore scroll position for pages without event IDs - logger.info('PageManager: Browser back - Restoring scroll position (no event ID)', { - page: currentPrimaryPage, - scrollPosition: savedFeedState.scrollPosition - }) - // Wait longer for content to load, then restore scroll position - setTimeout(() => { - const restoreScroll = () => { - if (isSmallScreen) { - window.scrollTo({ top: savedFeedState.scrollPosition, behavior: 'instant' }) - } else { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - if (scrollArea) { - const maxScroll = Math.max(0, scrollArea.scrollHeight - scrollArea.clientHeight) - const targetScroll = Math.min(savedFeedState.scrollPosition, maxScroll) - scrollArea.scrollTop = targetScroll - - // If content hasn't loaded enough yet, try again after a delay - if (targetScroll < savedFeedState.scrollPosition && maxScroll < savedFeedState.scrollPosition) { - setTimeout(restoreScroll, 200) - } - } - } - } - restoreScroll() - }, 300) - } } - }, [secondaryStack.length, currentPrimaryPage, waitForEventAndScroll, isSmallScreen]) + }, [secondaryStack.length, currentPrimaryPage]) const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => { - const needScrollToTop = page === currentPrimaryPage - // 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) @@ -1435,85 +1097,35 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { exists.props = props return [...prev] } else if (!exists) { - return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }] + return [...prev, { name: page, element: getPrimaryPageMap()[page], props }] } return prev }) setCurrentPrimaryPage(page) - // Update URL for primary pages (except home) - const newUrl = page === 'home' ? '/' : `/?page=${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) - if (needScrollToTop) { - PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth') - } + // NEVER scroll to top - feed should maintain scroll position at all times } const pushSecondaryPage = (url: string, index?: number) => { logger.component('PageManager', 'pushSecondaryPage called', { url }) - // Find and save the visible event ID and feed state before navigating - const findVisibleEventIdAndCacheFeedState = () => { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement - if (scrollArea) { - const viewportCenter = scrollArea.scrollTop + scrollArea.clientHeight / 2 - const allEvents = scrollArea.querySelectorAll('[data-event-id]') - let closestEvent: HTMLElement | null = null - let closestDistance = Infinity - const eventIds: string[] = [] - - allEvents.forEach((el) => { - const eventId = el.getAttribute('data-event-id') - if (eventId) { - eventIds.push(eventId) - } - const rect = el.getBoundingClientRect() - const scrollAreaRect = scrollArea.getBoundingClientRect() - const elementTop = rect.top - scrollAreaRect.top + scrollArea.scrollTop - const elementCenter = elementTop + rect.height / 2 - const distance = Math.abs(elementCenter - viewportCenter) - if (distance < closestDistance) { - closestDistance = distance - closestEvent = el as HTMLElement - } - }) - - const visibleEventId = (closestEvent as HTMLElement | null)?.getAttribute('data-event-id') - const scrollPosition = scrollArea.scrollTop - - return { visibleEventId, eventIds, scrollPosition } - } - return { visibleEventId: null, eventIds: [], scrollPosition: 0 } - } - - const { visibleEventId, eventIds, scrollPosition } = findVisibleEventIdAndCacheFeedState() + // Save tab state before navigating const currentTab = currentTabStateRef.current.get(currentPrimaryPage) - - // Get trending tab if on search page const trendingTab = currentTabStateRef.current.get('search') as 'nostr' | 'relays' | 'hashtags' | undefined - if (visibleEventId && currentPrimaryPage) { - logger.info('PageManager: Desktop - Saving visible event ID and feed state', { + if (currentPrimaryPage && (currentTab || trendingTab)) { + logger.info('PageManager: Desktop - Saving page state', { page: currentPrimaryPage, - eventId: visibleEventId, - eventCount: eventIds.length, - scrollPosition, tab: currentTab, trendingTab }) - savedEventIdsRef.current.set(currentPrimaryPage, visibleEventId) - savedFeedStateRef.current.set(currentPrimaryPage, { eventIds, scrollPosition, tab: currentTab, trendingTab }) - } else if (currentPrimaryPage && (scrollPosition > 0 || currentTab || trendingTab)) { - // Save scroll position even if no event ID (for pages without event IDs) - logger.info('PageManager: Desktop - Saving scroll position and state (no event ID)', { - page: currentPrimaryPage, - scrollPosition, - tab: currentTab, - trendingTab - }) - savedFeedStateRef.current.set(currentPrimaryPage, { eventIds: [], scrollPosition, tab: currentTab, trendingTab }) + savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab, trendingTab }) } setSecondaryStack((prevStack) => { @@ -1534,11 +1146,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } if (isCurrentPage(prevStack, url)) { - logger.component('PageManager', 'Page already exists, scrolling to top') - const currentItem = prevStack[prevStack.length - 1] - if (currentItem?.ref?.current) { - currentItem.ref.current.scrollToTop('instant') - } + logger.component('PageManager', 'Page already exists, not scrolling') + // NEVER scroll to top - maintain scroll position return prevStack } @@ -1561,17 +1170,72 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } const popSecondaryPage = () => { - if (secondaryStack.length === 1) { - // back to home page - restore to saved event - window.history.replaceState(null, '', '/') + // 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 { + // 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 + closeDrawer() + 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) + closeDrawer() + // Clear stack setSecondaryStack([]) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) - const savedEventId = savedEventIdsRef.current.get(currentPrimaryPage) // Restore tab state first if (savedFeedState?.tab) { - logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: 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 } })) @@ -1580,7 +1244,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Restore Discussions state if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { - logger.info('PageManager: Desktop - Restoring Discussions state', { + logger.info('PageManager: Mobile/Single-pane - Restoring Discussions state', { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }) @@ -1591,7 +1255,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Restore trending tab for search page if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { - logger.info('PageManager: Desktop - Restoring trending tab', { + logger.info('PageManager: Mobile/Single-pane - Restoring trending tab', { page: currentPrimaryPage, trendingTab: savedFeedState.trendingTab }) @@ -1600,42 +1264,46 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { })) 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([]) - // Scroll to the saved event or position - if (savedEventId) { - logger.info('PageManager: Desktop - Restoring to saved event', { page: currentPrimaryPage, eventId: savedEventId }) - try { - waitForEventAndScroll(savedEventId, currentPrimaryPage) - } catch (error) { - logger.error('PageManager: Error calling waitForEventAndScroll from popSecondaryPage', { error, savedEventId, currentPrimaryPage }) - } - } else if (savedFeedState && savedFeedState.scrollPosition > 0) { - // Restore scroll position for pages without event IDs - logger.info('PageManager: Desktop - Restoring scroll position (no event ID)', { + 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, - scrollPosition: savedFeedState.scrollPosition + discussionsState: savedFeedState.discussionsState }) - // Wait longer for content to load, then restore scroll position - setTimeout(() => { - const restoreScroll = () => { - if (isSmallScreen) { - window.scrollTo({ top: savedFeedState.scrollPosition, behavior: 'instant' }) - } else { - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null - if (scrollArea) { - const maxScroll = Math.max(0, scrollArea.scrollHeight - scrollArea.clientHeight) - const targetScroll = Math.min(savedFeedState.scrollPosition, maxScroll) - scrollArea.scrollTop = targetScroll - - // If content hasn't loaded enough yet, try again after a delay - if (targetScroll < savedFeedState.scrollPosition && maxScroll < savedFeedState.scrollPosition) { - setTimeout(restoreScroll, 200) - } - } - } - } - restoreScroll() - }, 300) + 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) @@ -1674,6 +1342,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { navigationCounterRef.current }}> + {primaryNoteView ? ( // Show primary note view with back button on mobile
@@ -1742,9 +1411,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { ))} )} + {drawerNoteId && ( + { + if (!open) { + closeDrawer() + } else { + setDrawerOpen(open) + } + }} + noteId={drawerNoteId} + /> + )} + @@ -1772,6 +1455,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { navigationCounterRef.current }}> +
- {secondaryStack.length > 0 ? ( - // Show secondary pages when there are any in the stack -
+ {(() => { + if (panelMode === 'double') { + // Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer) + return ( +
+ {/* Left panel: Feed (flexible, takes remaining space after 1042px) */} +
+ +
+ {/* Right panel: Secondary stack (1042px fixed width, same as drawer) */} +
+ {secondaryStack.length > 0 ? ( + secondaryStack.map((item, index) => { + const isLast = index === secondaryStack.length - 1 + return ( +
+ {item.component} +
+ ) + }) + ) : ( +
+ {/* Empty state - no secondary content */} +
+ )} +
+
+ ) + } else { + // Single-pane mode: show feed only, drawer overlay for notes + return ( + + ) + } + })()} +
+
+ {drawerNoteId && ( + { + if (!open) { + closeDrawer() + } else { + setDrawerOpen(open) + } + }} + noteId={drawerNoteId} + /> + )} + {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} + {panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && ( + { + if (!open) { + // Close drawer and go back + popSecondaryPage() + } + }} + > + +
{secondaryStack.map((item, index) => { const isLast = index === secondaryStack.length - 1 - logger.component('PageManager', 'Rendering desktop secondary stack item', { - index, - isLast, - url: item.url, - hasComponent: !!item.component, - display: isLast ? 'block' : 'none' - }) + if (!isLast) return null return ( -
+
{item.component}
) })}
- ) : ( - // Show primary pages when no secondary pages - - )} -
-
+ + + )} +
diff --git a/src/components/NoteDrawer/index.tsx b/src/components/NoteDrawer/index.tsx new file mode 100644 index 0000000..7c1fd60 --- /dev/null +++ b/src/components/NoteDrawer/index.tsx @@ -0,0 +1,22 @@ +import { Sheet, SheetContent } from '@/components/ui/sheet' +import NotePage from '@/pages/secondary/NotePage' + +interface NoteDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + noteId: string | null +} + +export default function NoteDrawer({ open, onOpenChange, noteId }: NoteDrawerProps) { + if (!noteId) return null + + return ( + + +
+ +
+
+
+ ) +} diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index ed1bf68..e479f21 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -1,7 +1,7 @@ import { ExtendedKind } from '@/constants' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' -import { toNjump, toAlexandria } from '@/lib/link' +import { toAlexandria } from '@/lib/link' import logger from '@/lib/logger' import { pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl, simplifyUrl } from '@/lib/url' @@ -18,6 +18,7 @@ import { useMemo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import RelayIcon from '../RelayIcon' +import { usePrimaryPage } from '@/PageManager' export interface SubMenuAction { label: React.ReactNode @@ -55,6 +56,7 @@ export function useMenuActions({ openHighlightEditor }: UseMenuActionsProps) { const { t } = useTranslation() + const { current: currentPrimaryPage } = usePrimaryPage() const { pubkey, attemptDelete, publish } = useNostr() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() @@ -455,9 +457,15 @@ export function useMenuActions({ }, { icon: Link, - label: t('Share with Njump'), + label: t('Share with Jumble'), onClick: () => { - navigator.clipboard.writeText(toNjump(getNoteBech32Id(event))) + const noteId = getNoteBech32Id(event) + // Only include context for discussions page, use plain /notes/{id} for others + const path = currentPrimaryPage === 'discussions' + ? `/discussions/notes/${noteId}` + : `/notes/${noteId}` + const jumbleUrl = `https://jumble.imwald.eu${path}` + navigator.clipboard.writeText(jumbleUrl) closeDrawer() } }, diff --git a/src/components/PaneModeToggle/index.tsx b/src/components/PaneModeToggle/index.tsx new file mode 100644 index 0000000..8954db3 --- /dev/null +++ b/src/components/PaneModeToggle/index.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import storage from '@/services/local-storage.service' +import { PanelLeft, PanelsLeftRight } from 'lucide-react' +import { useState } from 'react' + +export default function PaneModeToggle() { + const { isSmallScreen } = useScreenSize() + const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) + + // Hide on mobile + if (isSmallScreen) return null + + const toggleMode = () => { + const newMode = panelMode === 'single' ? 'double' : 'single' + setPanelMode(newMode) + storage.setPanelMode(newMode) + } + + return ( + + ) +} diff --git a/src/components/ScrollToTopButton/index.tsx b/src/components/ScrollToTopButton/index.tsx index b66a70f..9ad2b51 100644 --- a/src/components/ScrollToTopButton/index.tsx +++ b/src/components/ScrollToTopButton/index.tsx @@ -32,7 +32,8 @@ export default function ScrollToTopButton({ style={{ bottom: isSmallScreen ? 'calc(env(safe-area-inset-bottom) + 3.75rem)' - : 'calc(env(safe-area-inset-bottom) + 0.75rem)' + : 'calc(env(safe-area-inset-bottom) + 0.75rem)', + willChange: 'opacity' // Hint to browser for better scroll performance }} > + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 122eaf6..108f9a9 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -11,6 +11,7 @@ import ProfileButton from './ProfileButton' import RssButton from './RssButton' import SearchButton from './SearchButton' import SettingsButton from './SettingsButton' +import PaneModeToggle from './PaneModeToggle' import storage from '@/services/local-storage.service' export default function PrimaryPageSidebar() { @@ -40,7 +41,10 @@ export default function PrimaryPageSidebar() {
- +
+ + +
) } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 51a0fc0..269b40c 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,16 +35,16 @@ const CommandDialog = ({ }: DialogProps & { classNames?: { content?: string } }) => { return ( - - Command Menu - Search and select a command - + + Command Menu + Search and select a command + {!hideClose && ( - + Close diff --git a/src/constants.ts b/src/constants.ts index 5d2ef09..2f521ee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -54,6 +54,7 @@ export const StorageKey = { RESPECT_QUIET_TAGS: 'respectQuietTags', GLOBAL_QUIET_MODE: 'globalQuietMode', SHOW_RSS_FEED: 'showRssFeed', + PANE_MODE: 'paneMode', MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 0290d6c..63f83fe 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -63,7 +63,7 @@ export default { Add: 'إضافة', 'n relays': '{{n}} ريلايات', Rename: 'إعادة تسمية', - 'Share with Njump': 'مشاركة مع Njump', + 'Share with Jumble': 'مشاركة مع Jumble', 'Share with Alexandria': 'مشاركة مع Alexandria', Delete: 'حذف', 'Relay already exists': 'الريلاي موجود بالفعل', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 2e5db32..21178bd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -63,7 +63,7 @@ export default { Add: 'Hinzufügen', 'n relays': '{{n}} Relays', Rename: 'Umbenennen', - 'Share with Njump': 'Mit Njump teilen', + 'Share with Jumble': 'Mit Jumble teilen', 'Share with Alexandria': 'Mit Alexandria teilen', Delete: 'Löschen', 'Relay already exists': 'Relay existiert bereits', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 06d6fe2..4d709db 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -66,7 +66,7 @@ export default { 'n relays': '{{n}} relays', Rename: 'Rename', 'Copy share link': 'Copy share link', - 'Share with Njump': 'Share with Njump', + 'Share with Jumble': 'Share with Jumble', 'Share with Alexandria': 'Share with Alexandria', Delete: 'Delete', 'Relay already exists': 'Relay already exists', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 77c8a3b..d6cb966 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -63,7 +63,7 @@ export default { Add: 'Agregar', 'n relays': '{{n}} relés', Rename: 'Renombrar', - 'Share with Njump': 'Compartir con Njump', + 'Share with Jumble': 'Compartir con Jumble', 'Share with Alexandria': 'Compartir con Alexandria', Delete: 'Eliminar', 'Relay already exists': 'El relé ya existe', diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index 44c5496..f43da81 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -62,7 +62,7 @@ export default { Add: 'افزودن', 'n relays': '{{n}} رله', Rename: 'تغییر نام', - 'Share with Njump': 'اشتراک‌گذاری با Njump', + 'Share with Jumble': 'اشتراک‌گذاری با Jumble', 'Share with Alexandria': 'اشتراک‌گذاری با Alexandria', Delete: 'حذف', 'Relay already exists': 'رله از قبل موجود است', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index e4928ab..723a119 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -63,7 +63,7 @@ export default { Add: 'Ajouter', 'n relays': '{{n}} relais', Rename: 'Renommer', - 'Share with Njump': 'Partager avec Njump', + 'Share with Jumble': 'Partager avec Jumble', 'Share with Alexandria': 'Partager avec Alexandria', Delete: 'Supprimer', 'Relay already exists': 'Le relais existe déjà', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 276fe62..a3de556 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -62,7 +62,7 @@ export default { Add: 'जोड़ें', 'n relays': '{{n}} रिले', Rename: 'नाम बदलें', - 'Share with Njump': 'Njump के साथ शेयर करें', + 'Share with Jumble': 'Jumble के साथ शेयर करें', 'Share with Alexandria': 'Alexandria के साथ शेयर करें', Delete: 'हटाएं', 'Relay already exists': 'रिले पहले से मौजूद है', diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 726ef0c..431ef4f 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -62,7 +62,7 @@ export default { Add: 'Aggiungi', 'n relays': '{{n}} relays', Rename: 'Rinomina', - 'Share with Njump': 'Condividi con Njump', + 'Share with Jumble': 'Condividi con Jumble', 'Share with Alexandria': 'Condividi con Alexandria', Delete: 'Cancella', 'Relay already exists': 'Relay già esistente', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 7cacf5e..f7ef559 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -63,7 +63,7 @@ export default { Add: '追加', 'n relays': '{{n}} 個のリレイ', Rename: '名前変更', - 'Share with Njump': 'Njumpで共有', + 'Share with Jumble': 'Jumbleで共有', 'Share with Alexandria': 'Alexandriaで共有', Delete: '削除', 'Relay already exists': 'リレイは既に存在します', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index db72398..9f144fe 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -62,7 +62,7 @@ export default { Add: '추가', 'n relays': '{{n}}개의 릴레이', Rename: '이름 변경', - 'Share with Njump': 'Njump로 공유', + 'Share with Jumble': 'Jumble로 공유', 'Share with Alexandria': 'Alexandria로 공유', Delete: '삭제', 'Relay already exists': '릴레이가 이미 존재합니다', diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 5f602c2..adb0eac 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -62,7 +62,7 @@ export default { Add: 'Dodaj', 'n relays': '{{n}} szt.', Rename: 'Zmień nazwę', - 'Share with Njump': 'Udostępnij przez Njump', + 'Share with Jumble': 'Udostępnij przez Jumble', 'Share with Alexandria': 'Udostępnij przez Alexandria', Delete: 'Usuń', 'Relay already exists': 'Transmiter już istnieje', diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 5c96ab4..2176880 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -62,7 +62,7 @@ export default { Add: 'Adicionar', 'n relays': '{{n}} relays', Rename: 'Renomear', - 'Share with Njump': 'Compartilhar com Njump', + 'Share with Jumble': 'Compartilhar com Jumble', 'Share with Alexandria': 'Compartilhar com Alexandria', Delete: 'Excluir', 'Relay already exists': 'Relay já existe', diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index f77bad6..b9a7ea6 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -63,7 +63,7 @@ export default { Add: 'Adicionar', 'n relays': '{{n}} relés', Rename: 'Renomear', - 'Share with Njump': 'Compartilhar com Njump', + 'Share with Jumble': 'Compartilhar com Jumble', 'Share with Alexandria': 'Compartilhar com Alexandria', Delete: 'Excluir', 'Relay already exists': 'Relé já existe', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 5fbd5b7..9b4f186 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -63,7 +63,7 @@ export default { Add: 'Добавить', 'n relays': '{{n}} ретрансляторов', Rename: 'Переименовать', - 'Share with Njump': 'Поделиться через Njump', + 'Share with Jumble': 'Поделиться через Jumble', 'Share with Alexandria': 'Поделиться через Alexandria', Delete: 'Удалить', 'Relay already exists': 'Ретранслятор уже существует', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 6890f6e..6c3e326 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -62,7 +62,7 @@ export default { Add: 'เพิ่ม', 'n relays': '{{n}} รีเลย์', Rename: 'เปลี่ยนชื่อ', - 'Share with Njump': 'แชร์ผ่าน Njump', + 'Share with Jumble': 'แชร์ผ่าน Jumble', 'Share with Alexandria': 'แชร์ผ่าน Alexandria', Delete: 'ลบ', 'Relay already exists': 'รีเลย์มีอยู่แล้ว', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 112826f..3f94a1d 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -62,7 +62,7 @@ export default { Add: '添加', 'n relays': '{{n}} 个服务器', Rename: '重命名', - 'Share with Njump': '通过Njump分享', + 'Share with Jumble': '通过Jumble分享', 'Share with Alexandria': '通过Alexandria分享', Delete: '删除', 'Relay already exists': '服务器已存在', diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 7b7fa14..08c1c01 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -38,11 +38,8 @@ const NoteListPage = forwardRef((_, ref) => { const [showRelayDetails, setShowRelayDetails] = useState(false) useImperativeHandle(ref, () => layoutRef.current) - useEffect(() => { - if (layoutRef.current) { - layoutRef.current.scrollToTop('instant') - } - }, [JSON.stringify(relayUrls), feedInfo]) + // REMOVED: Scroll-to-top logic - feed should NEVER scroll to top when drawer opens/closes + // The feed stays mounted and maintains scroll position at all times useEffect(() => { if (relayUrls.length) { diff --git a/src/providers/DeepBrowsingProvider.tsx b/src/providers/DeepBrowsingProvider.tsx index 440d3ce..7337996 100644 --- a/src/providers/DeepBrowsingProvider.tsx +++ b/src/providers/DeepBrowsingProvider.tsx @@ -33,30 +33,41 @@ export function DeepBrowsingProvider({ useEffect(() => { if (!active) return + let rafId: number | null = null const handleScroll = () => { - const scrollTop = (!scrollAreaRef ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0 - const diff = scrollTop - lastScrollTopRef.current - lastScrollTopRef.current = scrollTop - setLastScrollTop(scrollTop) - if (scrollTop <= 800) { - setDeepBrowsing(false) - return - } + // Use requestAnimationFrame to throttle scroll updates and prevent scroll-linked positioning warnings + if (rafId !== null) return + + rafId = requestAnimationFrame(() => { + const scrollTop = (!scrollAreaRef ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0 + const diff = scrollTop - lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + setLastScrollTop(scrollTop) + if (scrollTop <= 800) { + setDeepBrowsing(false) + rafId = null + return + } - if (diff > 20) { - setDeepBrowsing(true) - } else if (diff < -20) { - setDeepBrowsing(false) - } + if (diff > 20) { + setDeepBrowsing(true) + } else if (diff < -20) { + setDeepBrowsing(false) + } + rafId = null + }) } const target = scrollAreaRef ? scrollAreaRef.current : window - target?.addEventListener('scroll', handleScroll) + target?.addEventListener('scroll', handleScroll, { passive: true }) return () => { target?.removeEventListener('scroll', handleScroll) + if (rafId !== null) { + cancelAnimationFrame(rafId) + } } - }, [active]) + }, [active, scrollAreaRef]) return ( diff --git a/src/routes.tsx b/src/routes.tsx index fa56a90..6a9aad4 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -23,12 +23,20 @@ import FollowPacksPage from './pages/secondary/FollowPacksPage' const ROUTES = [ { path: '/notes', element: }, { path: '/notes/:id', element: }, + // Contextual note routes (e.g., /discussions/notes/:id, /search/notes/:id) + { path: '/discussions/notes/:id', element: }, + { path: '/search/notes/:id', element: }, + { path: '/profile/notes/:id', element: }, + { path: '/explore/notes/:id', element: }, + { path: '/notifications/notes/:id', element: }, { path: '/users', element: }, { path: '/users/:id', element: }, { path: '/users/:id/following', element: }, { path: '/users/:id/relays', element: }, { path: '/relays/:url', element: }, { path: '/relays/:url/reviews', element: }, + // Contextual relay route (only for explore page where you discover relays) + { path: '/explore/relays/:url', element: }, { path: '/search', element: }, { path: '/settings', element: }, { path: '/settings/relays', element: }, diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index b52cf7b..70dc83a 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -60,6 +60,7 @@ class LocalStorageService { private respectQuietTags: boolean = true private globalQuietMode: boolean = false private showRssFeed: boolean = true + private panelMode: 'single' | 'double' = 'single' constructor() { if (!LocalStorageService.instance) { @@ -296,6 +297,9 @@ class LocalStorageService { const showRssFeedStr = window.localStorage.getItem(StorageKey.SHOW_RSS_FEED) this.showRssFeed = showRssFeedStr === null ? true : showRssFeedStr === 'true' // Default to true + const panelModeStr = window.localStorage.getItem(StorageKey.PANE_MODE) + this.panelMode = panelModeStr === 'double' ? 'double' : 'single' // Default to 'single' + // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) @@ -675,6 +679,15 @@ class LocalStorageService { this.showRssFeed = show window.localStorage.setItem(StorageKey.SHOW_RSS_FEED, show.toString()) } + + getPanelMode(): 'single' | 'double' { + return this.panelMode + } + + setPanelMode(mode: 'single' | 'double') { + this.panelMode = mode + window.localStorage.setItem(StorageKey.PANE_MODE, mode) + } } const instance = new LocalStorageService()