From 00c54ca426182df12113fd5a3a9227f268f4b962 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 14 Nov 2025 09:13:56 +0100 Subject: [PATCH] fixing back-navigation on feeds and tabs --- package-lock.json | 4 +- package.json | 2 +- src/PageManager.tsx | 923 +++++++++++++++++- src/components/NoteCard/MainNoteCard.tsx | 1 + src/components/NotificationList/index.tsx | 16 + .../Profile/ProfileBookmarksAndHashtags.tsx | 308 +++++- .../Profile/ProfileInteractions.tsx | 35 +- src/components/Profile/index.tsx | 19 +- src/components/TrendingNotes/index.tsx | 38 +- src/hooks/useFetchEvent.tsx | 73 +- src/hooks/useProfileTimeline.tsx | 18 +- src/pages/primary/DiscussionsPage/index.tsx | 23 + src/pages/primary/ExplorePage/index.tsx | 21 +- 13 files changed, 1401 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index b492bff..cae53d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "13.6", + "version": "13.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "13.6", + "version": "13.7", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 2228cb7..c4d1265 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "13.6", + "version": "13.7", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 21e3168..9ed1b18 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -30,6 +30,7 @@ import { createRef, ReactNode, RefObject, + useCallback, useContext, useEffect, useRef, @@ -134,9 +135,10 @@ export function useSmartNoteNavigation() { const { isSmallScreen } = useScreenSize() const navigateToNote = (url: string) => { + // Event ID will be saved when setPrimaryNoteView or pushSecondaryPage is called + if (isSmallScreen) { // Use primary note view on mobile - // Extract note ID from URL (e.g., "/notes/note1..." -> "note1...") const noteId = url.replace('/notes/', '') window.history.pushState(null, '', url) setPrimaryNoteView(, 'note') @@ -444,11 +446,630 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState(null) 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) + + // Get Discussions state if on discussions page + let discussionsState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | undefined = undefined + if (currentPrimaryPage === 'discussions') { + // Request discussions state from component + const stateEvent = new CustomEvent('requestDiscussionsState') + let receivedState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | null = null + const handler = ((e: CustomEvent) => { + receivedState = e.detail + }) as EventListener + window.addEventListener('discussionsStateResponse', handler) + window.dispatchEvent(stateEvent) + setTimeout(() => { + window.removeEventListener('discussionsStateResponse', handler) + if (receivedState) { + discussionsState = receivedState + } + }, 10) + } + + // Get trending tab if on search page + const trendingTab = currentTabStateRef.current.get('search') as 'nostr' | 'relays' | 'hashtags' | undefined + + 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)', { + page: currentPrimaryPage, + scrollPosition, + tab: currentTab, + discussionsState, + trendingTab + }) + savedFeedStateRef.current.set(currentPrimaryPage, { + eventIds: [], + scrollPosition, + tab: currentTab, + discussionsState, + trendingTab + }) + } } // Increment navigation counter when setting a new view to ensure unique keys @@ -466,6 +1087,78 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (!view && savedPrimaryPage) { const newUrl = savedPrimaryPage === 'home' ? '/' : `/?page=${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) { + logger.info('PageManager: Restoring tab state', { page: savedPrimaryPage, tab: savedFeedState.tab }) + window.dispatchEvent(new CustomEvent('restorePageTab', { + detail: { page: savedPrimaryPage, tab: savedFeedState.tab } + })) + currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab) + } + + // Restore Discussions state + if (savedFeedState?.discussionsState && savedPrimaryPage === 'discussions') { + logger.info('PageManager: Restoring Discussions state', { + page: savedPrimaryPage, + discussionsState: savedFeedState.discussionsState + }) + window.dispatchEvent(new CustomEvent('restoreDiscussionsState', { + detail: { page: savedPrimaryPage, discussionsState: savedFeedState.discussionsState } + })) + } + + // Restore trending tab for search page + if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') { + logger.info('PageManager: Restoring trending tab', { + page: savedPrimaryPage, + trendingTab: savedFeedState.trendingTab + }) + window.dispatchEvent(new CustomEvent('restorePageTab', { + detail: { page: 'search', tab: savedFeedState.trendingTab } + })) + currentTabStateRef.current.set('search', savedFeedState.trendingTab) + } + + // 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) + } } } @@ -631,6 +1324,97 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } }, []) + // Listen for tab state changes from components + useEffect(() => { + const handleTabChange = (e: CustomEvent<{ page: TPrimaryPageName, tab: string }>) => { + currentTabStateRef.current.set(e.detail.page, e.detail.tab) + logger.debug('PageManager: Tab state updated', { page: e.detail.page, tab: e.detail.tab }) + } + + window.addEventListener('pageTabChanged', handleTabChange as EventListener) + return () => { + window.removeEventListener('pageTabChanged', handleTabChange as EventListener) + } + }, []) + + // Restore scroll position and 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) { + logger.info('PageManager: Browser back - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) + window.dispatchEvent(new CustomEvent('restorePageTab', { + detail: { page: currentPrimaryPage, tab: savedFeedState.tab } + })) + // Update ref immediately + currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) + } + + // Restore Discussions state + if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { + logger.info('PageManager: Browser back - Restoring Discussions state', { + page: currentPrimaryPage, + discussionsState: savedFeedState.discussionsState + }) + window.dispatchEvent(new CustomEvent('restoreDiscussionsState', { + detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState } + })) + } + + // Restore trending tab for search page + if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { + logger.info('PageManager: Browser back - Restoring trending tab', { + page: currentPrimaryPage, + trendingTab: savedFeedState.trendingTab + }) + window.dispatchEvent(new CustomEvent('restorePageTab', { + detail: { page: 'search', tab: savedFeedState.trendingTab } + })) + currentTabStateRef.current.set('search', savedFeedState.trendingTab) + } + + // 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]) + const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => { const needScrollToTop = page === currentPrimaryPage @@ -668,6 +1452,69 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { 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() + 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', { + 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 }) + } + setSecondaryStack((prevStack) => { logger.component('PageManager', 'Current secondary stack length', { length: prevStack.length }) @@ -714,9 +1561,81 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const popSecondaryPage = () => { if (secondaryStack.length === 1) { - // back to home page + // back to home page - restore to saved event window.history.replaceState(null, '', '/') 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 }) + 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) + } + + // 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)', { + 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) + } } else { window.history.go(-1) } diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 14ff5c5..1016883 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -25,6 +25,7 @@ export default function MainNoteCard({ return (
{ // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 7c6c74f..a71527f 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -92,6 +92,18 @@ const NotificationList = forwardRef((_, ref) => { [loading] ) + // Listen for tab restoration from PageManager + useEffect(() => { + const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { + if (e.detail.page === 'notifications' && e.detail.tab) { + setNotificationType(e.detail.tab as TNotificationType) + setShowCount(SHOW_COUNT) + } + } + window.addEventListener('restorePageTab', handleRestore as EventListener) + return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) + }, []) + const handleNewEvent = useCallback( (event: NostrEvent) => { if (event.pubkey === pubkey) return @@ -317,6 +329,10 @@ const NotificationList = forwardRef((_, ref) => { onTabChange={(type) => { setShowCount(SHOW_COUNT) setNotificationType(type as TNotificationType) + // Dispatch tab change event for PageManager + window.dispatchEvent(new CustomEvent('pageTabChanged', { + detail: { page: 'notifications', tab: type } + })) }} options={!supportTouch ? refresh()} /> : null} /> diff --git a/src/components/Profile/ProfileBookmarksAndHashtags.tsx b/src/components/Profile/ProfileBookmarksAndHashtags.tsx index 38840e8..3b45788 100644 --- a/src/components/Profile/ProfileBookmarksAndHashtags.tsx +++ b/src/components/Profile/ProfileBookmarksAndHashtags.tsx @@ -11,6 +11,29 @@ import NoteCard from '../NoteCard' import { Skeleton } from '../ui/skeleton' type TabValue = 'bookmarks' | 'hashtags' | 'pins' +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +type BookmarksCacheEntry = { + events: Event[] + listEvent: Event | null + lastUpdated: number +} + +type HashtagsCacheEntry = { + events: Event[] + listEvent: Event | null + lastUpdated: number +} + +type PinsCacheEntry = { + events: Event[] + listEvent: Event | null + lastUpdated: number +} + +const bookmarksCache = new Map() +const hashtagsCache = new Map() +const pinsCache = new Map() const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { pubkey: string @@ -66,11 +89,30 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { // Fetch bookmark list event and associated events const fetchBookmarks = useCallback(async (isRetry = false, isRefresh = false) => { - if (!isRetry && !isRefresh) { - setLoadingBookmarks(true) - setRetryCountBookmarks(0) - } else if (isRetry) { - setIsRetryingBookmarks(true) + const cacheKey = `${pubkey}-bookmarks` + + // Check cache first + const cachedEntry = bookmarksCache.get(cacheKey) + const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity + const isCacheFresh = cacheAge < CACHE_DURATION + + // If cache is fresh, show it immediately + if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { + // Add cached events to client cache so they're available in note view + cachedEntry.events.forEach(event => { + client.addEventToCache(event) + }) + setBookmarkEvents(cachedEntry.events) + setBookmarkListEvent(cachedEntry.listEvent) + setLoadingBookmarks(false) + // Still fetch in background to get updates + } else { + if (!isRetry && !isRefresh) { + setLoadingBookmarks(true) + setRetryCountBookmarks(0) + } else if (isRetry) { + setIsRetryingBookmarks(true) + } } try { @@ -150,27 +192,51 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { const events = eventArrays.flat() logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'bookmark events') + // Add all events to client cache so they're available immediately in note view + events.forEach(event => { + client.addEventToCache(event) + }) + + let finalEvents: Event[] if (isRefresh) { // For refresh, append new events and deduplicate - setBookmarkEvents(prevEvents => { - const existingIds = new Set(prevEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - const combinedEvents = [...newEvents, ...prevEvents] - // Re-sort the combined events - return combinedEvents.sort((a, b) => b.created_at - a.created_at) - }) + // Compute final events before setting state + const existingIds = new Set(bookmarkEvents.map(e => e.id)) + const newEvents = events.filter(event => !existingIds.has(event.id)) + finalEvents = [...newEvents, ...bookmarkEvents].sort((a, b) => b.created_at - a.created_at) + setBookmarkEvents(finalEvents) } else { + finalEvents = events setBookmarkEvents(events) } + + // Update cache + bookmarksCache.set(cacheKey, { + events: finalEvents, + listEvent: bookmarkList, + lastUpdated: Date.now() + }) } catch (error) { logger.warn('[ProfileBookmarksAndHashtags] Error fetching bookmark events:', error) setBookmarkEvents([]) } } else { setBookmarkEvents([]) + // Update cache with empty result + bookmarksCache.set(cacheKey, { + events: [], + listEvent: bookmarkList, + lastUpdated: Date.now() + }) } } else { setBookmarkEvents([]) + // Update cache with empty result + bookmarksCache.set(cacheKey, { + events: [], + listEvent: bookmarkList, + lastUpdated: Date.now() + }) } // Reset retry count on successful fetch @@ -204,13 +270,17 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { } }, [pubkey, buildComprehensiveRelayList, retryCountBookmarks, maxRetries]) - // Fetch interest list event and associated events - const fetchHashtags = useCallback(async (isRetry = false, isRefresh = false) => { - if (!isRetry && !isRefresh) { - setLoadingHashtags(true) - setRetryCountHashtags(0) - } else if (isRetry) { - setIsRetryingHashtags(true) + // Internal function to actually fetch hashtags (without cache check) + const fetchHashtagsInternal = useCallback(async (isRetry = false, isRefresh = false, isBackgroundUpdate = false) => { + const cacheKey = `${pubkey}-hashtags` + + if (!isBackgroundUpdate) { + if (!isRetry && !isRefresh) { + setLoadingHashtags(true) + setRetryCountHashtags(0) + } else if (isRetry) { + setIsRetryingHashtags(true) + } } try { @@ -230,8 +300,10 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { interestList = await client.fetchInterestListEvent(pubkey) } - // console.log('[ProfileBookmarksAndHashtags] Interest list event:', interestList) - setInterestListEvent(interestList) + // Only update interest list event if we're not doing a background update + if (!isBackgroundUpdate) { + setInterestListEvent(interestList) + } if (interestList && interestList.tags.length > 0) { // Extract hashtags from interest list @@ -251,27 +323,73 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { }) // console.log('[ProfileBookmarksAndHashtags] Fetched', events.length, 'hashtag events') + // Add all events to client cache so they're available immediately in note view + events.forEach(event => { + client.addEventToCache(event) + }) + + let finalEvents: Event[] if (isRefresh) { // For refresh, append new events and deduplicate - setHashtagEvents(prevEvents => { - const existingIds = new Set(prevEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - const combinedEvents = [...newEvents, ...prevEvents] - // Re-sort the combined events - return combinedEvents.sort((a, b) => b.created_at - a.created_at) - }) + // Compute final events before setting state + const existingIds = new Set(hashtagEvents.map(e => e.id)) + const newEvents = events.filter(event => !existingIds.has(event.id)) + finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) + setHashtagEvents(finalEvents) + } else if (isBackgroundUpdate) { + // For background update, merge with existing cached events + const existingIds = new Set(hashtagEvents.map(e => e.id)) + const newEvents = events.filter(event => !existingIds.has(event.id)) + if (newEvents.length > 0) { + finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) + setHashtagEvents(finalEvents) + } else { + // No new events, keep existing ones + finalEvents = hashtagEvents + } } else { + finalEvents = events setHashtagEvents(events) } + + // Update cache only if we got events or if this is not a background update + if (!isBackgroundUpdate || (finalEvents && finalEvents.length > 0)) { + hashtagsCache.set(cacheKey, { + events: finalEvents, + listEvent: interestList, + lastUpdated: Date.now() + }) + } } catch (error) { logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtag events', { error: (error as Error).message }) - setHashtagEvents([]) + // Only clear events if this is not a background update + if (!isBackgroundUpdate) { + setHashtagEvents([]) + } } } else { - setHashtagEvents([]) + // Only clear events if this is not a background update + if (!isBackgroundUpdate) { + setHashtagEvents([]) + // Update cache with empty result + hashtagsCache.set(cacheKey, { + events: [], + listEvent: interestList, + lastUpdated: Date.now() + }) + } } } else { - setHashtagEvents([]) + // Only clear events if this is not a background update + if (!isBackgroundUpdate) { + setHashtagEvents([]) + // Update cache with empty result + hashtagsCache.set(cacheKey, { + events: [], + listEvent: interestList, + lastUpdated: Date.now() + }) + } } // Reset retry count on successful fetch @@ -282,7 +400,7 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtags', { error: (error as Error).message, retryCount: isRetry ? retryCountHashtags + 1 : 0 }) // If this is not a retry and we haven't exceeded max retries, schedule a retry - if (!isRetry && retryCountHashtags < maxRetries) { + if (!isRetry && retryCountHashtags < maxRetries && !isBackgroundUpdate) { logger.debug('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', { attempt: retryCountHashtags + 1, maxRetries @@ -293,25 +411,84 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { setRetryCountHashtags(prev => prev + 1) fetchHashtags(true) }, delay) - } else { + } else if (!isBackgroundUpdate) { + // Only clear events if this is not a background update setHashtagEvents([]) } } finally { - setLoadingHashtags(false) - setIsRetryingHashtags(false) - if (isRefresh) { - setIsRefreshing(false) + // Only update loading state if this is not a background update + if (!isBackgroundUpdate) { + setLoadingHashtags(false) + setIsRetryingHashtags(false) + if (isRefresh) { + setIsRefreshing(false) + } } } - }, [pubkey, buildComprehensiveRelayList, retryCountHashtags, maxRetries]) + }, [pubkey, buildComprehensiveRelayList, retryCountHashtags, maxRetries, hashtagEvents]) + + // Main fetch function with cache check + const fetchHashtags = useCallback(async (isRetry = false, isRefresh = false) => { + const cacheKey = `${pubkey}-hashtags` + + // Check cache first + const cachedEntry = hashtagsCache.get(cacheKey) + const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity + const isCacheFresh = cacheAge < CACHE_DURATION + + // Track if we're doing a background update (cache is fresh, just checking for new events) + const isBackgroundUpdate = isCacheFresh && cachedEntry && !isRetry && !isRefresh + + // If cache is fresh, show it immediately and defer background fetch + if (isBackgroundUpdate) { + // Add cached events to client cache so they're available in note view + cachedEntry.events.forEach(event => { + client.addEventToCache(event) + }) + setHashtagEvents(cachedEntry.events) + setInterestListEvent(cachedEntry.listEvent) + setLoadingHashtags(false) + + // Defer background fetch to next tick to avoid blocking UI + setTimeout(() => { + // Run background fetch asynchronously without blocking + fetchHashtagsInternal(false, false, true).catch(() => { + // Silently fail background updates + }) + }, 100) // Small delay to let UI render first + return // Exit early, background fetch will run asynchronously + } + + // Not a background update, proceed with normal fetch + return fetchHashtagsInternal(isRetry, isRefresh, false) + }, [pubkey, fetchHashtagsInternal]) // Fetch pin list event and associated events const fetchPins = useCallback(async (isRetry = false, isRefresh = false) => { - if (!isRetry && !isRefresh) { - setLoadingPins(true) - setRetryCountPins(0) - } else if (isRetry) { - setIsRetryingPins(true) + const cacheKey = `${pubkey}-pins` + + // Check cache first + const cachedEntry = pinsCache.get(cacheKey) + const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity + const isCacheFresh = cacheAge < CACHE_DURATION + + // If cache is fresh, show it immediately + if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { + // Add cached events to client cache so they're available in note view + cachedEntry.events.forEach(event => { + client.addEventToCache(event) + }) + setPinEvents(cachedEntry.events) + setPinListEvent(cachedEntry.listEvent) + setLoadingPins(false) + // Still fetch in background to get updates + } else { + if (!isRetry && !isRefresh) { + setLoadingPins(true) + setRetryCountPins(0) + } else if (isRetry) { + setIsRetryingPins(true) + } } try { @@ -395,27 +572,51 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { const events = eventArrays.flat() logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'pin events') + // Add all events to client cache so they're available immediately in note view + events.forEach(event => { + client.addEventToCache(event) + }) + + let finalEvents: Event[] if (isRefresh) { // For refresh, append new events and deduplicate - setPinEvents(prevEvents => { - const existingIds = new Set(prevEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - const combinedEvents = [...newEvents, ...prevEvents] - // Re-sort the combined events - return combinedEvents.sort((a, b) => b.created_at - a.created_at) - }) + // Compute final events before setting state + const existingIds = new Set(pinEvents.map(e => e.id)) + const newEvents = events.filter(event => !existingIds.has(event.id)) + finalEvents = [...newEvents, ...pinEvents].sort((a, b) => b.created_at - a.created_at) + setPinEvents(finalEvents) } else { + finalEvents = events setPinEvents(events) } + + // Update cache + pinsCache.set(cacheKey, { + events: finalEvents, + listEvent: pinList, + lastUpdated: Date.now() + }) } catch (error) { logger.warn('[ProfileBookmarksAndHashtags] Error fetching pin events:', error) setPinEvents([]) } } else { setPinEvents([]) + // Update cache with empty result + pinsCache.set(cacheKey, { + events: [], + listEvent: pinList, + lastUpdated: Date.now() + }) } } else { setPinEvents([]) + // Update cache with empty result + pinsCache.set(cacheKey, { + events: [], + listEvent: pinList, + lastUpdated: Date.now() + }) } // Reset retry count on successful fetch @@ -452,6 +653,11 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { // Expose refresh function to parent component const refresh = useCallback(() => { + // Clear all caches on refresh + bookmarksCache.delete(`${pubkey}-bookmarks`) + hashtagsCache.delete(`${pubkey}-hashtags`) + pinsCache.delete(`${pubkey}-pins`) + setRetryCountBookmarks(0) setRetryCountHashtags(0) setRetryCountPins(0) @@ -459,7 +665,7 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { fetchBookmarks(false, true) // isRetry = false, isRefresh = true fetchHashtags(false, true) // isRetry = false, isRefresh = true fetchPins(false, true) // isRetry = false, isRefresh = true - }, [fetchBookmarks, fetchHashtags, fetchPins]) + }, [pubkey, fetchBookmarks, fetchHashtags, fetchPins]) useImperativeHandle(ref, () => ({ refresh diff --git a/src/components/Profile/ProfileInteractions.tsx b/src/components/Profile/ProfileInteractions.tsx index 8a67b80..4622972 100644 --- a/src/components/Profile/ProfileInteractions.tsx +++ b/src/components/Profile/ProfileInteractions.tsx @@ -11,6 +11,14 @@ import { useZap } from '@/providers/ZapProvider' const INITIAL_SHOW_COUNT = 25 const LOAD_MORE_COUNT = 25 +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +type InteractionsCacheEntry = { + events: Event[] + lastUpdated: number +} + +const interactionsCache = new Map() interface ProfileInteractionsProps { accountPubkey: string @@ -42,8 +50,23 @@ const ProfileInteractions = forwardRef< const [refreshToken, setRefreshToken] = useState(0) const bottomRef = useRef(null) + // Create cache key based on account and profile pubkeys + const cacheKey = useMemo(() => `${accountPubkey}-${profilePubkey}-${zapReplyThreshold}`, [accountPubkey, profilePubkey, zapReplyThreshold]) + const fetchInteractions = useCallback(async () => { - setIsLoading(true) + // Check cache first + const cachedEntry = interactionsCache.get(cacheKey) + const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity + const isCacheFresh = cacheAge < CACHE_DURATION + + // If cache is fresh, show it immediately + if (isCacheFresh && cachedEntry) { + setEvents(cachedEntry.events) + setIsLoading(false) + // Still fetch in background to get updates + } else { + setIsLoading(!cachedEntry) + } try { const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) @@ -147,6 +170,12 @@ const ProfileInteractions = forwardRef< // Sort by created_at descending uniqueEvents.sort((a, b) => b.created_at - a.created_at) + // Update cache + interactionsCache.set(cacheKey, { + events: uniqueEvents, + lastUpdated: Date.now() + }) + setEvents(uniqueEvents) } catch (error) { console.error('Failed to fetch interactions', error) @@ -155,7 +184,7 @@ const ProfileInteractions = forwardRef< setIsLoading(false) setIsRefreshing(false) } - }, [accountPubkey, profilePubkey, zapReplyThreshold]) + }, [accountPubkey, profilePubkey, zapReplyThreshold, cacheKey]) useEffect(() => { if (!accountPubkey || !profilePubkey) return @@ -171,6 +200,8 @@ const ProfileInteractions = forwardRef< () => ({ refresh: () => { setIsRefreshing(true) + // Clear cache on refresh + interactionsCache.delete(cacheKey) setRefreshToken((prev) => prev + 1) }, getEvents: () => events diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index a478238..e99dd6c 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -219,6 +219,17 @@ export default function Profile({ id }: { id?: string }) { forceUpdateCache() }, [profile?.pubkey]) + // Listen for tab restoration from PageManager + useEffect(() => { + const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { + if (e.detail.page === 'profile' && e.detail.tab) { + setActiveTab(e.detail.tab as ProfileTabValue) + } + } + window.addEventListener('restorePageTab', handleRestore as EventListener) + return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) + }, []) + if (!profile && isFetching) { return ( @@ -340,7 +351,13 @@ export default function Profile({ id }: { id?: string }) { setActiveTab(tab as ProfileTabValue)} + onTabChange={(tab) => { + setActiveTab(tab as ProfileTabValue) + // Dispatch tab change event for PageManager + window.dispatchEvent(new CustomEvent('pageTabChanged', { + detail: { page: 'profile', tab: tab } + })) + }} threshold={800} />
diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx index 5dd4108..0e7e36d 100644 --- a/src/components/TrendingNotes/index.tsx +++ b/src/components/TrendingNotes/index.tsx @@ -53,6 +53,27 @@ export default function TrendingNotes() { const isFetchingNostrRef = useRef(false) const hasUserClickedNostrTabRef = useRef(false) + // Listen for tab restoration from PageManager + useEffect(() => { + const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { + if (e.detail.page === 'search' && e.detail.tab && ['nostr', 'relays', 'hashtags'].includes(e.detail.tab)) { + // If restoring to 'nostr' tab, mark it as clicked and clear events to force a fresh load + if (e.detail.tab === 'nostr') { + hasUserClickedNostrTabRef.current = true + // Clear any existing events and error state to force a fresh load (only for API tab) + setNostrEvents([]) + setNostrError(null) + } + // For 'relays' and 'hashtags' tabs, just set the active tab + // The cache should already be loaded, but if it's empty, the initialization useEffect will handle it + // Then set the active tab - this will trigger the useEffect that loads the feed + setActiveTab(e.detail.tab as TrendingTab) + } + } + window.addEventListener('restorePageTab', handleRestore as EventListener) + return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) + }, []) + // Load Nostr.band trending feed only when user explicitly clicks the nostr tab useEffect(() => { const loadTrending = async () => { @@ -588,7 +609,12 @@ export default function TrendingNotes() { Trending: