Browse Source

fixing back-navigation on feeds and tabs

imwald
Silberengel 4 months ago
parent
commit
00c54ca426
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 923
      src/PageManager.tsx
  4. 1
      src/components/NoteCard/MainNoteCard.tsx
  5. 16
      src/components/NotificationList/index.tsx
  6. 308
      src/components/Profile/ProfileBookmarksAndHashtags.tsx
  7. 35
      src/components/Profile/ProfileInteractions.tsx
  8. 19
      src/components/Profile/index.tsx
  9. 38
      src/components/TrendingNotes/index.tsx
  10. 73
      src/hooks/useFetchEvent.tsx
  11. 18
      src/hooks/useProfileTimeline.tsx
  12. 23
      src/pages/primary/DiscussionsPage/index.tsx
  13. 21
      src/pages/primary/ExplorePage/index.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

923
src/PageManager.tsx

@ -30,6 +30,7 @@ import { @@ -30,6 +30,7 @@ import {
createRef,
ReactNode,
RefObject,
useCallback,
useContext,
useEffect,
useRef,
@ -134,9 +135,10 @@ export function useSmartNoteNavigation() { @@ -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(<NotePage id={noteId} index={0} hideTitlebar={true} />, 'note')
@ -444,11 +446,630 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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<TPrimaryPageName | null>(null)
const navigationCounterRef = useRef(0)
const savedEventIdsRef = useRef<Map<TPrimaryPageName, string>>(new Map())
const savedFeedStateRef = useRef<Map<TPrimaryPageName, {
eventIds: string[],
scrollPosition: number,
tab?: string,
discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' },
trendingTab?: 'nostr' | 'relays' | 'hashtags'
}>>(new Map())
const restoringScrollRef = useRef<Set<string>>(new Set()) // Track which eventIds are currently being restored
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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)
}

1
src/components/NoteCard/MainNoteCard.tsx

@ -25,6 +25,7 @@ export default function MainNoteCard({ @@ -25,6 +25,7 @@ export default function MainNoteCard({
return (
<div
className={className}
data-event-id={event.id}
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement

16
src/components/NotificationList/index.tsx

@ -92,6 +92,18 @@ const NotificationList = forwardRef((_, ref) => { @@ -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) => { @@ -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 ? <RefreshButton onClick={() => refresh()} /> : null}
/>

308
src/components/Profile/ProfileBookmarksAndHashtags.tsx

@ -11,6 +11,29 @@ import NoteCard from '../NoteCard' @@ -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<string, BookmarksCacheEntry>()
const hashtagsCache = new Map<string, HashtagsCacheEntry>()
const pinsCache = new Map<string, PinsCacheEntry>()
const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, {
pubkey: string
@ -66,11 +89,30 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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 }, { @@ -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

35
src/components/Profile/ProfileInteractions.tsx

@ -11,6 +11,14 @@ import { useZap } from '@/providers/ZapProvider' @@ -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<string, InteractionsCacheEntry>()
interface ProfileInteractionsProps {
accountPubkey: string
@ -42,8 +50,23 @@ const ProfileInteractions = forwardRef< @@ -42,8 +50,23 @@ const ProfileInteractions = forwardRef<
const [refreshToken, setRefreshToken] = useState(0)
const bottomRef = useRef<HTMLDivElement>(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< @@ -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< @@ -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< @@ -171,6 +200,8 @@ const ProfileInteractions = forwardRef<
() => ({
refresh: () => {
setIsRefreshing(true)
// Clear cache on refresh
interactionsCache.delete(cacheKey)
setRefreshToken((prev) => prev + 1)
},
getEvents: () => events

19
src/components/Profile/index.tsx

@ -219,6 +219,17 @@ export default function Profile({ id }: { id?: string }) { @@ -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 }) { @@ -340,7 +351,13 @@ export default function Profile({ id }: { id?: string }) {
<Tabs
value={activeTab}
tabs={tabs}
onTabChange={(tab) => 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}
/>
<div className="flex items-center gap-2 pr-2 px-1">

38
src/components/TrendingNotes/index.tsx

@ -53,6 +53,27 @@ export default function TrendingNotes() { @@ -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() { @@ -588,7 +609,12 @@ export default function TrendingNotes() {
<span className="text-sm font-medium text-muted-foreground">Trending:</span>
<div className="flex gap-1">
<button
onClick={() => setActiveTab('relays')}
onClick={() => {
setActiveTab('relays')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'relays' }
}))
}}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'relays'
? 'bg-primary text-primary-foreground'
@ -598,7 +624,12 @@ export default function TrendingNotes() { @@ -598,7 +624,12 @@ export default function TrendingNotes() {
on your relays
</button>
<button
onClick={() => setActiveTab('hashtags')}
onClick={() => {
setActiveTab('hashtags')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'hashtags' }
}))
}}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'hashtags'
? 'bg-primary text-primary-foreground'
@ -611,6 +642,9 @@ export default function TrendingNotes() { @@ -611,6 +642,9 @@ export default function TrendingNotes() {
onClick={() => {
hasUserClickedNostrTabRef.current = true
setActiveTab('nostr')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'nostr' }
}))
}}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'nostr'

73
src/hooks/useFetchEvent.tsx

@ -2,29 +2,63 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -2,29 +2,63 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
export function useFetchEvent(eventId?: string) {
const { isEventDeleted } = useDeletedEvent()
const [isFetching, setIsFetching] = useState(true)
const { addReplies } = useReply()
const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(undefined)
const cachedEventResolvedRef = useRef(false)
const [isFetching, setIsFetching] = useState(true)
useEffect(() => {
if (!eventId) {
setIsFetching(false)
setError(new Error('No id provided'))
return
}
cachedEventResolvedRef.current = false
setIsFetching(true)
// Check if event is in cache by trying to access the cache map
const cacheMap = (client as any).eventCacheMap
const cachedPromise = cacheMap?.get(eventId)
// If we have a cached promise, try to resolve it immediately
if (cachedPromise) {
// Try to resolve quickly - if it resolves in < 50ms, it was likely already resolved (cached)
const startTime = Date.now()
cachedPromise
.then((cachedEvent: Event | undefined) => {
const resolveTime = Date.now() - startTime
// If resolves quickly (< 50ms), it was likely already resolved (cached)
if (resolveTime < 50 && cachedEvent && !isEventDeleted(cachedEvent)) {
cachedEventResolvedRef.current = true
setEvent(cachedEvent)
addReplies([cachedEvent])
setIsFetching(false) // Show cached event immediately
}
})
.catch(() => {
// Cache promise rejected, will fetch below
})
}
// Always fetch to ensure we have the latest, but don't show loading if we got cached data
const fetchEvent = async () => {
setIsFetching(true)
if (!eventId) {
setIsFetching(false)
setError(new Error('No id provided'))
return
// Only show loading if we don't have cached data yet
if (!cachedEventResolvedRef.current && !event) {
setIsFetching(true)
}
try {
const event = await client.fetchEvent(eventId)
if (event && !isEventDeleted(event)) {
setEvent(event)
addReplies([event])
// fetchEvent will use cache if available (via DataLoader), or fetch if not
const fetchedEvent = await client.fetchEvent(eventId)
if (fetchedEvent && !isEventDeleted(fetchedEvent)) {
setEvent(fetchedEvent)
addReplies([fetchedEvent])
}
} catch (error) {
setError(error as Error)
@ -33,11 +67,18 @@ export function useFetchEvent(eventId?: string) { @@ -33,11 +67,18 @@ export function useFetchEvent(eventId?: string) {
}
}
fetchEvent().catch((err) => {
setError(err as Error)
setIsFetching(false)
})
}, [eventId])
// Small delay to let cached promise resolve first if it exists
const timeoutId = setTimeout(() => {
fetchEvent().catch((err) => {
setError(err as Error)
setIsFetching(false)
})
}, cachedPromise ? 10 : 0) // Small delay if we're checking cache
return () => {
clearTimeout(timeoutId)
}
}, [eventId, isEventDeleted, addReplies])
useEffect(() => {
if (event && isEventDeleted(event)) {

18
src/hooks/useProfileTimeline.tsx

@ -10,6 +10,7 @@ type ProfileTimelineCacheEntry = { @@ -10,6 +10,7 @@ type ProfileTimelineCacheEntry = {
}
const timelineCache = new Map<string, ProfileTimelineCacheEntry>()
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long
const relayGroupCache = new Map<string, string[][]>()
type UseProfileTimelineOptions = {
@ -114,7 +115,22 @@ export function useProfileTimeline({ @@ -114,7 +115,22 @@ export function useProfileTimeline({
let cancelled = false
const subscribe = async () => {
setIsLoading(!timelineCache.has(cacheKey))
// Check if we have fresh cached data
const cachedEntry = timelineCache.get(cacheKey)
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity
const isCacheFresh = cacheAge < CACHE_DURATION
// If cache is fresh, show it immediately and skip subscribing
if (isCacheFresh && cachedEntry) {
setEvents(cachedEntry.events)
setIsLoading(false)
// Still subscribe in background to get updates, but don't show loading
// This ensures we get new events without disrupting the UI
} else {
// Cache is stale or missing - show loading and fetch
setIsLoading(!cachedEntry)
}
try {
const relayGroups = await getRelayGroups(pubkey)
if (cancelled) {

23
src/pages/primary/DiscussionsPage/index.tsx

@ -757,6 +757,29 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -757,6 +757,29 @@ const DiscussionsPage = forwardRef((_, ref) => {
}
}, [allEventMap, timeSpan, selectedTopic]) // Run when allEventMap, timeSpan, or selectedTopic changes
// Listen for state requests and restoration from PageManager
useEffect(() => {
const handleStateRequest = () => {
window.dispatchEvent(new CustomEvent('discussionsStateResponse', {
detail: { selectedTopic, timeSpan }
}))
}
const handleRestore = (e: CustomEvent<{ page: string, discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } }>) => {
if (e.detail.page === 'discussions' && e.detail.discussionsState) {
setSelectedTopic(e.detail.discussionsState.selectedTopic)
setTimeSpan(e.detail.discussionsState.timeSpan)
}
}
window.addEventListener('requestDiscussionsState', handleStateRequest as EventListener)
window.addEventListener('restoreDiscussionsState', handleRestore as EventListener)
return () => {
window.removeEventListener('requestDiscussionsState', handleStateRequest as EventListener)
window.removeEventListener('restoreDiscussionsState', handleRestore as EventListener)
}
}, [selectedTopic, timeSpan])
// Get available topics sorted by most recent activity (including dynamic topics)
// Topic counts are calculated based on the current time span filter
const availableTopics = useMemo(() => {

21
src/pages/primary/ExplorePage/index.tsx

@ -4,7 +4,7 @@ import Tabs from '@/components/Tabs' @@ -4,7 +4,7 @@ import Tabs from '@/components/Tabs'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Compass, Plus } from 'lucide-react'
import { forwardRef, useState } from 'react'
import { forwardRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
type TExploreTabs = 'following' | 'explore'
@ -12,6 +12,17 @@ type TExploreTabs = 'following' | 'explore' @@ -12,6 +12,17 @@ type TExploreTabs = 'following' | 'explore'
const ExplorePage = forwardRef((_, ref) => {
const [tab, setTab] = useState<TExploreTabs>('explore')
// Listen for tab restoration from PageManager
useEffect(() => {
const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => {
if (e.detail.page === 'explore' && e.detail.tab) {
setTab(e.detail.tab as TExploreTabs)
}
}
window.addEventListener('restorePageTab', handleRestore as EventListener)
return () => window.removeEventListener('restorePageTab', handleRestore as EventListener)
}, [])
return (
<PrimaryPageLayout
ref={ref}
@ -25,7 +36,13 @@ const ExplorePage = forwardRef((_, ref) => { @@ -25,7 +36,13 @@ const ExplorePage = forwardRef((_, ref) => {
{ value: 'explore', label: 'Explore' },
{ value: 'following', label: "Following's Favorites" }
]}
onTabChange={(tab) => setTab(tab as TExploreTabs)}
onTabChange={(tab) => {
setTab(tab as TExploreTabs)
// Dispatch tab change event for PageManager
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'explore', tab: tab }
}))
}}
/>
{tab === 'following' ? <FollowingFavoriteRelayList /> : <Explore />}
</PrimaryPageLayout>

Loading…
Cancel
Save