diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 330b793d..221cd319 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -34,13 +34,15 @@ import { cloneElement, createContext, createRef, + isValidElement, lazy, - ReactNode, + type ReactNode, RefObject, Suspense, useCallback, useContext, useEffect, + useMemo, useRef, useState } from 'react' @@ -53,6 +55,7 @@ import MePage from './pages/primary/MePage' import ProfilePage from './pages/primary/ProfilePage' import RelayPage from './pages/primary/RelayPage' import SearchPage from './pages/primary/SearchPage' +import RssPage from './pages/primary/RssPage' import { useScreenSize } from './providers/ScreenSizeProvider' /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */ @@ -66,6 +69,8 @@ import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHel type TPrimaryPageContext = { navigate: (page: TPrimaryPageName, props?: object) => void current: TPrimaryPageName | null + /** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */ + currentPageProps: object | undefined display: boolean } @@ -85,23 +90,25 @@ type TStackItem = { const PRIMARY_PAGE_REF_MAP = { home: createRef(), - explore: createRef(), + feed: createRef(), me: createRef(), profile: createRef(), relay: createRef(), search: createRef(), + rss: createRef(), spells: createRef() } // Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency // This is only evaluated when called, not at module load time const getPrimaryPageMap = () => ({ - home: , - explore: , + home: , + feed: , me: , profile: , relay: , search: , + rss: , spells: ( ({ ) }) +/** Spells is wrapped in ``; navigated props must go to the lazy page, not the boundary. */ +function applyPrimaryPageProps(element: ReactNode, props: object): ReactNode { + if (!isValidElement(element)) return element + if (element.type === Suspense) { + const inner = element.props.children + if (isValidElement(inner)) { + return cloneElement(element, undefined, cloneElement(inner, props)) + } + } + return cloneElement(element, props) +} + // Type for primary page names - use the return type of getPrimaryPageMap export type TPrimaryPageName = keyof ReturnType type TPrimaryPageStateEntry = { name: TPrimaryPageName; element: ReactNode; props?: any } -/** /discussions and contextual /discussions/notes/* map to spells + faux discussions. */ function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageName; props?: object } | null { if (pageContext === 'discussions') { return { name: 'spells', props: { spell: 'discussions' } } } + if (pageContext === 'explore') { + return { name: 'home' } + } const map = getPrimaryPageMap() if (pageContext in map) { return { name: pageContext as TPrimaryPageName } @@ -202,9 +223,9 @@ export function useNoteDrawer() { // Helper function to build contextual note URL function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string { // Pages that should preserve context in the URL - const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'explore', 'spells'] + const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'home'] - if (currentPage && contextualPages.includes(currentPage) && currentPage !== 'home') { + if (currentPage && contextualPages.includes(currentPage)) { return `/${currentPage}/notes/${noteId}` } @@ -215,9 +236,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string { const encodedRelayUrl = encodeURIComponent(relayUrl) - // Only preserve context for explore page (where you discover relays) - if (currentPage === 'explore') { - return `/explore/relays/${encodedRelayUrl}` + if (currentPage === 'home') { + return `/home/relays/${encodedRelayUrl}` } return `/relays/${encodedRelayUrl}` @@ -226,7 +246,9 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): // Helper function to extract noteId and context from URL function parseNoteUrl(url: string): { noteId: string; context?: string } { // Match patterns like /discussions/notes/{noteId} or /notes/{noteId} - const contextualMatch = url.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) + const contextualMatch = url.match( + /\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/ + ) if (contextualMatch) { return { noteId: contextualMatch[2], context: contextualMatch[1] } } @@ -302,8 +324,9 @@ export function useSmartRelayNavigation() { const navigateToRelay = (url: string) => { // Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url}) - const relayUrlMatch = url.match(/\/(discussions|search|profile|explore|spells)\/relays\/(.+)$/) || - url.match(/\/relays\/(.+)$/) + const relayUrlMatch = + url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/) || + url.match(/\/relays\/(.+)$/) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) // Build contextual URL based on current page @@ -596,7 +619,7 @@ function MainContentArea({ {(() => { try { logger.debug(`Rendering ${name} component`) - return props ? cloneElement(element as React.ReactElement, props) : element + return props ? applyPrimaryPageProps(element, props) : element } catch (error) { logger.error(`Error rendering ${name} component:`, error) return
Error rendering {name}: {error instanceof Error ? error.message : String(error)}
@@ -635,11 +658,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const navigationCounterRef = useRef(0) const savedFeedStateRef = useRef>(new Map()) const currentTabStateRef = useRef>(new Map()) // Track current tab state for each page - + + const currentPageProps = useMemo((): object | undefined => { + const entry = primaryPages.find((p) => p.name === currentPrimaryPage) + return entry?.props as object | undefined + }, [primaryPages, currentPrimaryPage]) + 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 @@ -648,39 +675,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Get current tab state from ref (updated by components via events) const currentTab = currentTabStateRef.current.get(currentPrimaryPage) - // Discussions list state when Spells page may host embedded Discussions - let discussionsState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | undefined = undefined - if (currentPrimaryPage === 'spells') { - // Request discussions state from embedded Discussions (faux-spell) when mounted - 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 'relays' | 'hashtags' | 'calendar' | undefined - // Save state (tab, discussions, trending) if any exists - if (currentTab || discussionsState || trendingTab) { + // Save state (tab, trending) if any exists + if (currentTab || trendingTab) { logger.info('PageManager: Saving page state', { page: currentPrimaryPage, tab: currentTab, - discussionsState, trendingTab }) savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab, - discussionsState, trendingTab }) } @@ -713,17 +719,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab) } - // Restore Discussions state - if (savedFeedState?.discussionsState && savedPrimaryPage === 'spells') { - 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 (map legacy 'nostr' to 'relays') if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') { const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab @@ -812,7 +807,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const pathname = window.location.pathname // Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id} - const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) + const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const noteUrlMatch = contextualNoteMatch || standardNoteMatch @@ -881,22 +876,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Check if this is a primary page URL - don't push primary pages to secondary stack const pathnameOnly = pathname.split('?')[0].split('#')[0] - const firstSeg = pathnameOnly.slice(1).split('/')[0] + const segments = pathnameOnly.split('/').filter(Boolean) + const firstSeg = segments[0] ?? '' + const primaryMap = getPrimaryPageMap() const isPrimaryPageUrl = - pathnameOnly === '/' || - pathnameOnly === '/home' || - firstSeg === 'discussions' || - (pathnameOnly.startsWith('/') && - firstSeg in getPrimaryPageMap() && - !pathnameOnly.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) + segments.length === 0 || + (segments.length === 1 && + (firstSeg === 'discussions' || + firstSeg === 'home' || + firstSeg === 'explore' || + firstSeg in primaryMap)) if (isPrimaryPageUrl) { // This is a primary page - just navigate to it, don't push to secondary stack const pageName = - pathnameOnly === '/' || pathnameOnly === '/home' ? 'home' : firstSeg - if (pageName === 'discussions') { + segments.length === 0 || (segments.length === 1 && firstSeg === 'home') ? 'home' : firstSeg + if (pageName === 'explore') { + navigatePrimaryPage('home') + requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } }) + ) + }) + } else if (pageName === 'discussions') { navigatePrimaryPage('spells', { spell: 'discussions' }) - } else if (pageName in getPrimaryPageMap()) { + } else if (pageName in primaryMap) { navigatePrimaryPage(pageName as TPrimaryPageName) } return @@ -940,7 +944,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } else { // Check if pathname matches a primary page name // First, check if it's a contextual note URL (e.g., /discussions/notes/...) - const contextualNoteMatch = pathname.match(/^\/(discussions|search|profile|explore|spells)\/notes\//) + const contextualNoteMatch = pathname.match( + /^\/(discussions|search|profile|home|feed|spells|explore)\/notes\// + ) if (contextualNoteMatch) { const pageContext = contextualNoteMatch[1] const resolved = noteContextToPrimaryEntry(pageContext) @@ -957,6 +963,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { navigatePrimaryPage('spells', { spell: 'discussions' }) return } + if (pageName === 'explore') { + navigatePrimaryPage('home') + requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } }) + ) + }) + return + } if (pageName && pageName in getPrimaryPageMap()) { // For relay page, check if there's a URL prop if (pageName === 'relay') { @@ -990,7 +1005,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const urlToCheck = state?.url || window.location.pathname // Check if it's a note URL (we'll update drawer after stack is synced) - const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || + const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) || urlToCheck.match(/\/notes\/(.+)$/) const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null @@ -1036,14 +1051,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (!topItem) { // Stack is empty - check if this is a primary page URL or a secondary route const pathname = state.url.split('?')[0].split('#')[0] - const popFirstSeg = pathname.slice(1).split('/')[0] + const popSegments = pathname.split('/').filter(Boolean) + const popFirstSeg = popSegments[0] ?? '' + const popPrimaryMap = getPrimaryPageMap() const isPrimaryPage = - pathname === '/' || - pathname === '/home' || - popFirstSeg === 'discussions' || - (pathname.startsWith('/') && - popFirstSeg in getPrimaryPageMap() && - !pathname.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) + popSegments.length === 0 || + (popSegments.length === 1 && + (popFirstSeg === 'discussions' || + popFirstSeg === 'home' || + popFirstSeg === 'explore' || + popFirstSeg in popPrimaryMap)) // If it's a primary page URL, return empty stack (right panel will close) if (isPrimaryPage) { @@ -1053,7 +1070,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setTimeout(() => { setDrawerNoteId(null) // Ensure URL matches the primary page - const pageUrl = pathname === '/' || pathname === '/home' ? '/' : pathname + const pageUrl = + popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home') + ? '/' + : `/${popFirstSeg}` window.history.replaceState(null, '', pageUrl) }, 350) } @@ -1061,7 +1081,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id}) - const noteUrlMatch = state.url.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || + const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) || state.url.match(/\/notes\/(.+)$/) if (noteUrlMatch) { const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -1112,7 +1132,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Extract noteId from top item's URL or from state.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url if (topItemUrl) { - const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || + const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) || topItemUrl.match(/\/notes\/(.+)$/) if (topNoteUrlMatch) { const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -1182,17 +1202,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } - // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { - 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') { const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab @@ -1221,13 +1230,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Update primary pages and current page setPrimaryPages((prev) => { const exists = prev.find((p) => p.name === page) - if (exists && props) { + if (exists) { exists.props = props return [...prev] - } else if (!exists) { - return [...prev, { name: page, element: getPrimaryPageMap()[page], props }] } - return prev + return [...prev, { name: page, element: getPrimaryPageMap()[page], props }] }) setCurrentPrimaryPage(page) @@ -1260,7 +1267,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { logger.component('PageManager', 'Current secondary stack length', { length: prevStack.length }) // For relay pages, clear the stack and start fresh to avoid confusion - if (url.startsWith('/relays/')) { + if ( + url.startsWith('/relays/') || + url.startsWith('/home/relays/') || + url.startsWith('/explore/relays/') + ) { logger.component('PageManager', 'Clearing stack for relay navigation') const { newStack, newItem } = pushNewPageToStack([], url, maxStackSize, 0) logger.component('PageManager', 'New stack created', { @@ -1316,17 +1327,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } - // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { - 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') { const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab @@ -1385,17 +1385,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } - // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { - logger.info('PageManager: Mobile/Single-pane - Restoring Discussions state', { - page: currentPrimaryPage, - discussionsState: savedFeedState.discussionsState - }) - window.dispatchEvent(new CustomEvent('restoreDiscussionsState', { - detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState } - })) - } - // Restore trending tab for search page if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab @@ -1427,17 +1416,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } - // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { - 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') { const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab @@ -1481,6 +1459,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { value={{ navigate: navigatePrimaryPage, current: currentPrimaryPage, + currentPageProps, display: secondaryStack.length === 0 }} > @@ -1562,7 +1541,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none' }} > - {props ? cloneElement(element as React.ReactElement, props) : element} + {props ? applyPrimaryPageProps(element, props) : element} ))} @@ -1602,6 +1581,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { value={{ navigate: navigatePrimaryPage, current: currentPrimaryPage, + currentPageProps, display: true }} > diff --git a/src/components/BottomNavigationBar/DiscussionsButton.tsx b/src/components/BottomNavigationBar/DiscussionsButton.tsx index 27302468..f2fb2fbc 100644 --- a/src/components/BottomNavigationBar/DiscussionsButton.tsx +++ b/src/components/BottomNavigationBar/DiscussionsButton.tsx @@ -3,11 +3,12 @@ import { MessageCircle } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function DiscussionsButton() { - const { navigate, current, display } = usePrimaryPage() + const { navigate, current, currentPageProps, display } = usePrimaryPage() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell return ( navigate('spells', { spell: 'discussions' })} > diff --git a/src/components/BottomNavigationBar/ExploreButton.tsx b/src/components/BottomNavigationBar/ExploreButton.tsx deleted file mode 100644 index 9f8cd6c5..00000000 --- a/src/components/BottomNavigationBar/ExploreButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { usePrimaryPage } from '@/PageManager' -import { Compass } from 'lucide-react' -import BottomNavigationBarItem from './BottomNavigationBarItem' - -export default function ExploreButton() { - const { navigate, current, display } = usePrimaryPage() - - return ( - navigate('explore')} - > - - - ) -} diff --git a/src/components/BottomNavigationBar/FeedButton.tsx b/src/components/BottomNavigationBar/FeedButton.tsx new file mode 100644 index 00000000..4d0be860 --- /dev/null +++ b/src/components/BottomNavigationBar/FeedButton.tsx @@ -0,0 +1,23 @@ +import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { Newspaper } from 'lucide-react' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +export default function FeedButton() { + const { navigate, current, display } = usePrimaryPage() + const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView() + + return ( + { + if (primaryViewType !== null) { + setPrimaryNoteView(null) + } else { + navigate('feed') + } + }} + > + + + ) +} diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index 73950328..30007b77 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -4,12 +4,13 @@ import { Bell } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function NotificationsButton() { - const { navigate } = usePrimaryPage() + const { navigate, current, currentPageProps, display } = usePrimaryPage() const { checkLogin } = useNostr() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell return ( checkLogin(() => navigate('spells', { spell: 'notifications' }))} > diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 0965aa81..78109894 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils' +import FeedButton from './FeedButton' import HomeButton from './HomeButton' import DiscussionsButton from './DiscussionsButton' import NotificationsButton from './NotificationsButton' @@ -19,6 +20,7 @@ export default function BottomNavigationBar() { > + diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx index 6034741a..a6812214 100644 --- a/src/components/FeedSwitcher/index.tsx +++ b/src/components/FeedSwitcher/index.tsx @@ -19,7 +19,7 @@ export default function FeedSwitcher({ close }: { close?: () => void }) { // Filter out blocked relays for display const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) - // Feed rows that exist here follow FAUX_SPELL_ORDER where applicable: favorite-relays → following → bookmarks. + // Feed rows: aggregate favorites → following → bookmarks (see FAUX_SPELL_ORDER for spell picker order). return (
{visibleRelays.length > 0 && ( diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index a0ad65b9..51962420 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -5,16 +5,9 @@ import { useKindFilter } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import storage from '@/services/local-storage.service' import { TFeedSubRequest, TNoteListMode } from '@/types' -import { forwardRef, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' +import { forwardRef, useLayoutEffect, useMemo, useRef, useState } from 'react' import KindFilter from '../KindFilter' import { RefreshButton } from '../RefreshButton' -import RssFeedList from '../RssFeedList' -import { useNostr } from '@/providers/NostrProvider' -import rssFeedService from '@/services/rss-feed.service' -import { DEFAULT_RSS_FEEDS } from '@/constants' -import { Rss, Search } from 'lucide-react' -import { Button } from '@/components/ui/button' const NormalFeed = forwardRef void -}>(function NormalFeed({ - subRequests, - areAlgoRelays = false, - isMainFeed = false, - showRelayCloseReason = false, - setSubHeader -}, ref) { +}>(function NormalFeed( + { + subRequests, + areAlgoRelays = false, + isMainFeed = false, + showRelayCloseReason = false, + setSubHeader + }, + ref +) { logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed }) - const { t } = useTranslation() const { hideUntrustedNotes } = useUserTrust() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [listMode, setListMode] = useState(() => { - // Get stored mode preference const storedMode = storage.getNoteListMode() - // For main feed, only allow 'posts' or 'postsAndReplies' as valid values - // Default to 'posts' if no valid preference is stored if (isMainFeed) { if (storedMode === 'posts' || storedMode === 'postsAndReplies') { return storedMode } return 'posts' } - // For non-main feeds, use stored mode or default to 'posts' return storedMode || 'posts' }) const internalNoteListRef = useRef(null) const noteListRef = ref || internalNoteListRef - const [showRssFeed, setShowRssFeed] = useState(() => storage.getShowRssFeed()) - const [activeTab, setActiveTab] = useState(listMode) - const [rssRefreshKey, setRssRefreshKey] = useState(0) - const { pubkey, rssFeedListEvent } = useNostr() - // Sync activeTab with listMode when listMode changes (but not when switching to RSS) - useEffect(() => { - if (activeTab !== 'rss' && activeTab !== listMode) { - setActiveTab(listMode) - } - }, [listMode, activeTab]) - - // Check showRssFeed setting on mount and listen for changes - useEffect(() => { - const checkShowRssFeed = () => { - const currentShowRssFeed = storage.getShowRssFeed() - setShowRssFeed(currentShowRssFeed) - } - - // Check on mount - checkShowRssFeed() - - // Listen for storage changes (polling approach - check every second) - const intervalId = setInterval(checkShowRssFeed, 1000) - - // Also listen for custom event if RSS setting changes - const handleRssSettingChange = () => { - checkShowRssFeed() - } - window.addEventListener('rssFeedSettingChanged', handleRssSettingChange) - - return () => { - clearInterval(intervalId) - window.removeEventListener('rssFeedSettingChanged', handleRssSettingChange) - } - }, []) - - // Handle RSS tab visibility when showRssFeed changes - useEffect(() => { - // If RSS tab is hidden while it's active, switch to posts - if (!showRssFeed && activeTab === 'rss') { - setActiveTab('posts') - setListMode('posts') - } - }, [showRssFeed, activeTab]) - - // Listen for custom event to switch to RSS tab - useEffect(() => { - const handleSwitchToRss = () => { - if (showRssFeed) { - setActiveTab('rss') - // Dispatch event to notify sidebar that RSS tab is active - window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } })) - if (noteListRef && typeof noteListRef !== 'function') { - noteListRef.current?.scrollToTop('smooth') - } - } - } - - window.addEventListener('switchToRssFeed', handleSwitchToRss) - return () => { - window.removeEventListener('switchToRssFeed', handleSwitchToRss) - } - }, [showRssFeed, noteListRef]) - - // Listen for custom event to switch to Notes tab - useEffect(() => { - const handleSwitchToNotes = () => { - // Switch to posts (Notes) tab - setListMode('posts') - setActiveTab('posts') - // Dispatch event to notify sidebar that RSS tab is not active - window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } })) - if (isMainFeed) { - storage.setNoteListMode('posts') - } - if (noteListRef && typeof noteListRef !== 'function') { - noteListRef.current?.scrollToTop('smooth') - } - } - - window.addEventListener('switchToNotesTab', handleSwitchToNotes) - return () => { - window.removeEventListener('switchToNotesTab', handleSwitchToNotes) - } - }, [isMainFeed, noteListRef]) - - // Dispatch initial RSS tab state on mount and when activeTab changes - useEffect(() => { - window.dispatchEvent(new CustomEvent('rssTabStateChanged', { - detail: { active: activeTab === 'rss' } - })) - }, [activeTab]) + const tabs = useMemo( + (): TabDefinition[] => [ + { value: 'posts', label: 'Notes' }, + { value: 'postsAndReplies', label: 'Replies' } + ], + [] + ) const handleListModeChange = (mode: TNoteListMode | string) => { - if (mode === 'rss') { - setActiveTab('rss') - // Dispatch event to notify sidebar that RSS tab is active - window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } })) - return - } const noteListMode = mode as TNoteListMode setListMode(noteListMode) - setActiveTab(noteListMode) - // Dispatch event to notify sidebar that RSS tab is not active - window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } })) if (isMainFeed) { storage.setNoteListMode(noteListMode) + window.dispatchEvent(new CustomEvent('noteListModeChanged')) } if (noteListRef && typeof noteListRef !== 'function') { noteListRef.current?.scrollToTop('smooth') @@ -172,117 +70,50 @@ const NormalFeed = forwardRef { - const baseTabs: TabDefinition[] = [ - { value: 'posts', label: 'Notes' }, - { value: 'postsAndReplies', label: 'Replies' } - ] - - if (showRssFeed) { - baseTabs.push({ value: 'rss', label: 'RSS', icon: }) - } - - return baseTabs - }, [showRssFeed]) - - // Determine current tab value - const currentTabValue = activeTab - const tabsElement = ( { - handleListModeChange(tab) - }} + onTabChange={(tab) => handleListModeChange(tab)} options={ <> - {activeTab === 'rss' && showRssFeed && ( - - )} - { - if (activeTab === 'rss') { - let feedUrls: string[] = [] - if (pubkey && rssFeedListEvent) { - try { - const urls = rssFeedListEvent.tags - .filter(tag => tag[0] === 'u' && tag[1]) - .map(tag => tag[1] as string) - .filter((url): url is string => { - if (typeof url !== 'string') return false - const trimmed = url.trim() - return trimmed.length > 0 - }) - feedUrls = urls - } catch (e) { - feedUrls = [] - } - } else { - feedUrls = DEFAULT_RSS_FEEDS - } - logger.info('[NormalFeed] Manual refresh: triggering RSS background refresh', { feedCount: feedUrls.length }) - rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => { - logger.error('[NormalFeed] Manual refresh: background refresh failed', { error: err }) - }) - if (pubkey) { - window.dispatchEvent(new CustomEvent('rssFeedListUpdated', { - detail: { pubkey, feedUrls, eventId: 'manual-refresh' } - })) - } - setRssRefreshKey(prev => prev + 1) - } else { + { if (noteListRef && typeof noteListRef !== 'function') { noteListRef.current?.refresh() } - } - }} /> - {activeTab !== 'rss' && ( - - )} + }} + /> + } /> ) - // When used on Home, render tabs in layout subHeader so they don't overlap content useLayoutEffect(() => { if (!isMainFeed || !setSubHeader) return setSubHeader(tabsElement) return () => setSubHeader(null) - }, [isMainFeed, setSubHeader, currentTabValue, activeTab, showRssFeed, temporaryShowKinds]) + }, [isMainFeed, setSubHeader, listMode, temporaryShowKinds]) const renderTabsInFeed = !(isMainFeed && setSubHeader) return ( <> {renderTabsInFeed && tabsElement} -
- {activeTab === 'rss' ? ( - - ) : ( - - )} +
+
) diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 0d91daa1..3bb4a5da 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -2,7 +2,9 @@ import { Separator } from '@/components/ui/separator' import { toNote } from '@/lib/link' import { useSmartNoteNavigation } from '@/PageManager' import client from '@/services/client.service' +import { Pin } from 'lucide-react' import { Event } from 'nostr-tools' +import { useTranslation } from 'react-i18next' import Collapsible from '../Collapsible' import Note from '../Note' import NoteStats from '../NoteStats' @@ -13,14 +15,18 @@ export default function MainNoteCard({ className, reposter, embedded, - originalNoteId + originalNoteId, + pinned = false }: { event: Event className?: string reposter?: string embedded?: boolean originalNoteId?: string + /** Profile (or other) pinned highlight */ + pinned?: boolean }) { + const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() return ( @@ -48,6 +54,15 @@ export default function MainNoteCard({ }} >
+ {pinned && !embedded && ( +
+ +
+ )} + return } diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index c63835da..5c783bdc 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -10,11 +10,13 @@ import RepostNoteCard from './RepostNoteCard' const NoteCard = memo(function NoteCard({ event, className, - filterMutedNotes = true + filterMutedNotes = true, + pinned = false }: { event: Event className?: string filterMutedNotes?: boolean + pinned?: boolean }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -31,17 +33,23 @@ const NoteCard = memo(function NoteCard({ if (event.kind === kinds.Repost) { return ( - + ) } - return + return }, (prevProps, nextProps) => { // Custom comparison function for memo return ( prevProps.event.id === nextProps.event.id && prevProps.event.created_at === nextProps.event.created_at && prevProps.className === nextProps.className && - prevProps.filterMutedNotes === nextProps.filterMutedNotes + prevProps.filterMutedNotes === nextProps.filterMutedNotes && + prevProps.pinned === nextProps.pinned ) }) diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index b325f423..5de8a3e6 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -1,22 +1,45 @@ import NoteCard from '@/components/NoteCard' import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import RetroRefreshButton from '@/components/ui/RetroRefreshButton' +import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' +import { isReplyNoteEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' +import { useKindFilter } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' -import { Event } from 'nostr-tools' +import storage from '@/services/local-storage.service' +import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Skeleton } from '@/components/ui/skeleton' const INITIAL_SHOW_COUNT = 25 const LOAD_MORE_COUNT = 25 +function useHideRepliesLikeMainFeed() { + const [hideReplies, setHideReplies] = useState(() => { + const m = storage.getNoteListMode() + return m !== 'postsAndReplies' + }) + + useEffect(() => { + const sync = () => { + const m = storage.getNoteListMode() + setHideReplies(m !== 'postsAndReplies') + } + window.addEventListener('noteListModeChanged', sync) + return () => window.removeEventListener('noteListModeChanged', sync) + }, []) + + return hideReplies +} + const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const { t } = useTranslation() const { zapReplyThreshold } = useZap() + const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() + const hideReplies = useHideRepliesLikeMainFeed() const [searchQuery, setSearchQuery] = useState('') const [isRefreshing, setIsRefreshing] = useState(false) const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) @@ -49,9 +72,24 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) + const passesMainFeedTimelineRules = useCallback( + (event: Event) => { + if (!showKinds.includes(event.kind)) return false + if (event.kind === kinds.ShortTextNote) { + const isReply = isReplyNoteEvent(event) + if (hideReplies && isReply) return false + if (isReply && !showKind1Replies) return false + if (!isReply && !showKind1OPs) return false + } + if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false + return true + }, + [showKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] + ) + const restTimeline = useMemo( - () => timelineEvents.filter((e) => !pinIds.has(e.id)), - [timelineEvents, pinIds] + () => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules), + [timelineEvents, pinIds, passesMainFeedTimelineRules] ) const applySearch = useCallback( @@ -71,6 +109,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) + const pinnedDisplayIds = useMemo(() => new Set(filteredPins.map((e) => e.id)), [filteredPins]) + useEffect(() => { setShowCount(INITIAL_SHOW_COUNT) }, [searchQuery, pubkey]) @@ -174,10 +214,15 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && (
- {t('Posts')} + {t('Feed')}
)} - +
))}
diff --git a/src/components/Sidebar/DiscussionsButton.tsx b/src/components/Sidebar/DiscussionsButton.tsx index b6510b91..8f75d237 100644 --- a/src/components/Sidebar/DiscussionsButton.tsx +++ b/src/components/Sidebar/DiscussionsButton.tsx @@ -5,14 +5,17 @@ import SidebarItem from './SidebarItem' export default function DiscussionsButton() { const { t } = useTranslation() - const { navigate, current, display } = usePrimaryPage() + const { navigate, current, currentPageProps, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell return ( navigate('spells', { spell: 'discussions' })} - active={display && current === 'spells' && primaryViewType === null} + active={ + display && current === 'spells' && primaryViewType === null && spell === 'discussions' + } > diff --git a/src/components/Sidebar/ExploreButton.tsx b/src/components/Sidebar/FeedButton.tsx similarity index 57% rename from src/components/Sidebar/ExploreButton.tsx rename to src/components/Sidebar/FeedButton.tsx index 67bb8e60..58165af4 100644 --- a/src/components/Sidebar/ExploreButton.tsx +++ b/src/components/Sidebar/FeedButton.tsx @@ -1,20 +1,20 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' -import { Compass } from 'lucide-react' +import { Newspaper } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' -export default function RelaysButton() { +export default function FeedButton() { const { t } = useTranslation() const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() return ( navigate('explore')} - active={display && current === 'explore' && primaryViewType === null} + title={t('Feed')} + onClick={() => navigate('feed')} + active={display && current === 'feed' && primaryViewType === null} > - + ) } diff --git a/src/components/Sidebar/HomeButton.tsx b/src/components/Sidebar/HomeButton.tsx index 67194d42..fa9ac10a 100644 --- a/src/components/Sidebar/HomeButton.tsx +++ b/src/components/Sidebar/HomeButton.tsx @@ -1,55 +1,16 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { Home } from 'lucide-react' import SidebarItem from './SidebarItem' -import storage from '@/services/local-storage.service' -import { useState, useEffect } from 'react' export default function HomeButton() { const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() - const showRssFeed = storage.getShowRssFeed() - const [rssTabActive, setRssTabActive] = useState(false) - - // Listen for RSS tab state changes - useEffect(() => { - const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => { - setRssTabActive(event.detail.active) - } - - window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) - - // Check initial state - setRssTabActive(false) // Default to false, will be updated by event - - return () => { - window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) - } - }, []) - - // Home is active when on home page, but NOT when RSS tab is active (RSS button handles that) - const isActive = display && current === 'home' && primaryViewType === null && !(showRssFeed && rssTabActive) - - const handleClick = () => { - // Navigate to home if not already there - if (current !== 'home' || primaryViewType !== null) { - navigate('home') - // Wait a bit for navigation to complete, then switch to Notes tab - setTimeout(() => { - window.dispatchEvent(new CustomEvent('switchToNotesTab')) - }, 100) - } else { - // Already on home, just switch to Notes tab (if RSS is active) - if (showRssFeed && rssTabActive) { - window.dispatchEvent(new CustomEvent('switchToNotesTab')) - } - } - } return ( - navigate('home')} + active={display && current === 'home' && primaryViewType === null} > diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index f33f9958..254e9480 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -1,17 +1,24 @@ -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { Bell } from 'lucide-react' import SidebarItem from './SidebarItem' export default function NotificationButton() { - const { navigate } = usePrimaryPage() + const { navigate, current, currentPageProps, display } = usePrimaryPage() + const { primaryViewType } = usePrimaryNoteView() const { checkLogin } = useNostr() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell return ( checkLogin(() => navigate('spells', { spell: 'notifications' }))} - active={false} + active={ + display && + current === 'spells' && + primaryViewType === null && + spell === 'notifications' + } > diff --git a/src/components/Sidebar/RssButton.tsx b/src/components/Sidebar/RssButton.tsx index efcb5b91..4ba95ebc 100644 --- a/src/components/Sidebar/RssButton.tsx +++ b/src/components/Sidebar/RssButton.tsx @@ -2,51 +2,19 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { Rss } from 'lucide-react' import SidebarItem from './SidebarItem' import storage from '@/services/local-storage.service' -import { useState, useEffect } from 'react' export default function RssButton() { const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() const showRssFeed = storage.getShowRssFeed() - const [rssTabActive, setRssTabActive] = useState(false) - // Listen for RSS tab state changes - useEffect(() => { - const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => { - setRssTabActive(event.detail.active) - } + if (!showRssFeed) return null - window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) - - // Check initial state - setRssTabActive(false) // Default to false, will be updated by event - - return () => { - window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) - } - }, []) - - // RSS is active when on home page, RSS tab is actually active, and RSS feed is enabled - const isActive = display && current === 'home' && primaryViewType === null && showRssFeed && rssTabActive - - const handleClick = () => { - // Navigate to home if not already there - if (current !== 'home' || primaryViewType !== null) { - navigate('home') - // Wait a bit for navigation to complete, then switch to RSS - setTimeout(() => { - window.dispatchEvent(new CustomEvent('switchToRssFeed')) - }, 100) - } else { - // Already on home, just switch to RSS tab - window.dispatchEvent(new CustomEvent('switchToRssFeed')) - } - } + const isActive = display && current === 'rss' && primaryViewType === null return ( - + navigate('rss')} active={isActive}> ) } - diff --git a/src/components/Sidebar/SidebarItem.tsx b/src/components/Sidebar/SidebarItem.tsx index aff2d7aa..4e035746 100644 --- a/src/components/Sidebar/SidebarItem.tsx +++ b/src/components/Sidebar/SidebarItem.tsx @@ -12,7 +12,7 @@ const SidebarItem = forwardRef< return ( ) }) diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 008ad91e..822caabe 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -4,7 +4,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import AccountButton from './AccountButton' import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarButton' import DiscussionsButton from './DiscussionsButton' -import RelaysButton from './ExploreButton' +import FeedButton from './FeedButton' import HomeButton from './HomeButton' import NotificationButton from './NotificationButton' import PostButton from './PostButton' @@ -13,15 +13,13 @@ import SearchButton from './SearchButton' import SettingsButton from './SettingsButton' import SpellsButton from './SpellsButton' import PaneModeToggle from './PaneModeToggle' -import storage from '@/services/local-storage.service' export default function PrimaryPageSidebar() { const { isSmallScreen } = useScreenSize() - const showRssFeed = storage.getShowRssFeed() if (isSmallScreen) return null return ( -
+
@@ -33,12 +31,12 @@ export default function PrimaryPageSidebar() {
- + - {showRssFeed && } +
diff --git a/src/components/Titlebar/ExploreButton.tsx b/src/components/Titlebar/ExploreButton.tsx deleted file mode 100644 index 24e700ed..00000000 --- a/src/components/Titlebar/ExploreButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { usePrimaryPage } from '@/PageManager' -import { Button } from '@/components/ui/button' -import { Compass } from 'lucide-react' - -export default function ExploreButton() { - const { navigate, current, display } = usePrimaryPage() - - return ( - - ) -} diff --git a/src/constants.ts b/src/constants.ts index d0b79aa7..1024be6c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -279,7 +279,6 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( /** Order for faux-spells in the feed / spell picker. */ export const FAUX_SPELL_ORDER = [ - 'favorite-relays', 'notifications', 'discussions', 'following', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 793034e8..be6bfd0a 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -453,6 +453,14 @@ export default { 'Refresh results': 'Ergebnisse aktualisieren', Poll: 'Umfrage', Media: 'Medien', + Interests: 'Interessen', + Calendar: 'Kalender', + 'No subscribed interests yet.': + 'Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.', + 'No bookmarked notes with id tags yet.': + 'Noch keine Lesezeichen mit Ereignis-IDs. Nur klassische (e-Tag-) Lesezeichen erscheinen in diesem Feed.', + 'No follows or relays to load yet.': 'Noch keine Follows oder Relays zum Laden.', + 'Nothing to load for this feed.': 'Für diesen Feed gibt es nichts zu laden.', 'Republish to ...': 'Erneut veröffentlichen zu ...', 'Successfully republish to your write relays': 'Erfolgreich erneut zu deinen Schreib-Relays veröffentlicht', @@ -710,6 +718,8 @@ export default { 'Comma-separated topics': 'Themen, komma-getrennt', Mode: 'Modus', Feed: 'Feed', + 'Favorites Feed': 'Favoriten-Feed', + 'Pinned note': 'Angehefteter Beitrag', Fetch: 'Abrufen', 'Fetch once, then stop.': 'Einmal abrufen, dann stoppen.', 'Live feed; keeps updating.': 'Live-Feed; wird fortgesetzt aktualisiert.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 281050a9..2f6a031c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -5,6 +5,9 @@ export default { 'New Note': 'New Note', Post: 'Post', Home: 'Home', + Feed: 'Feed', + 'Favorites Feed': 'Favorites Feed', + 'Pinned note': 'Pinned note', 'Relay settings': 'Relays and Storage Settings', Settings: 'Settings', SidebarRelays: 'Relays', @@ -65,6 +68,7 @@ export default { "username's used relays": "{{username}}'s used relays", "username's muted": "{{username}}'s muted", Login: 'Login', + 'Please log in to view notifications.': 'Please log in to view notifications.', 'Follows you': 'Follows you', 'Relay Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings', @@ -509,6 +513,14 @@ export default { 'Refresh results': 'Refresh results', Poll: 'Poll', Media: 'Media', + Interests: 'Interests', + Calendar: 'Calendar', + 'No subscribed interests yet.': + 'No subscribed interests yet. Add topics in settings to see them here.', + 'No bookmarked notes with id tags yet.': + 'No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.', + 'No follows or relays to load yet.': 'No follows or relays to load yet.', + 'Nothing to load for this feed.': 'Nothing to load for this feed.', 'Republish to ...': 'Republish to ...', 'All available relays': 'All available relays', 'All active relays (monitoring list)': 'All active relays (monitoring list)', @@ -663,6 +675,7 @@ export default { 'nested events': 'nested events', 'Loading RSS feeds...': 'Loading RSS feeds...', 'No RSS feed items available': 'No RSS feed items available', + 'Show or hide the RSS page and sidebar entry': 'Show or hide the RSS page and sidebar entry', 'Refreshing feeds...': 'Refreshing feeds...', 'All feeds': 'All feeds', 'All time': 'All time', diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 8b695030..50f948a9 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -1,22 +1,30 @@ import Explore from '@/components/Explore' import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' import Tabs from '@/components/Tabs' +import VersionUpdateBanner from '@/components/VersionUpdateBanner' import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { Compass, Plus } from 'lucide-react' -import { forwardRef, useState, useEffect } from 'react' +import { forwardRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -type TExploreTabs = 'following' | 'explore' +type TExploreTabs = 'explore' | 'following' + +function normalizeHomeTab(restored: string): TExploreTabs { + if (restored === 'following') return 'following' + // Removed "favorites" tab — treat saved state as Explore + return 'explore' +} const ExplorePage = forwardRef((_, ref) => { + const { t } = useTranslation() const [tab, setTab] = useState('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) + const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { + if (e.detail.page === 'home' && e.detail.tab) { + setTab(normalizeHomeTab(e.detail.tab)) } } window.addEventListener('restorePageTab', handleRestore as EventListener) @@ -26,27 +34,33 @@ const ExplorePage = forwardRef((_, ref) => { return ( } + pageName="home" + titlebar={} subHeader={ { - setTab(tab as TExploreTabs) - window.dispatchEvent(new CustomEvent('pageTabChanged', { - detail: { page: 'explore', tab: tab } - })) + onTabChange={(next) => { + setTab(next as TExploreTabs) + window.dispatchEvent( + new CustomEvent('pageTabChanged', { + detail: { page: 'home', tab: next } + }) + ) }} /> } displayScrollToTopButton >
- {tab === 'following' ? : } +
+ +
+ {tab === 'explore' && } + {tab === 'following' && }
) @@ -54,9 +68,7 @@ const ExplorePage = forwardRef((_, ref) => { ExplorePage.displayName = 'ExplorePage' export default ExplorePage -function ExplorePageTitlebar() { - const { t } = useTranslation() - +function ExplorePageTitlebar({ t }: { t: (key: string) => string }) { return (
diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 6d073a34..72f32cc5 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -8,9 +8,12 @@ import { kinds } from 'nostr-tools' import React, { useEffect, useMemo, useState, useRef } from 'react' export default function RelaysFeed({ - setSubHeader + setSubHeader, + kindsOverride }: { setSubHeader?: (node: React.ReactNode) => void + /** When set, subscription kinds (fixed list); otherwise uses KindFilterProvider. */ + kindsOverride?: number[] }) { logger.debug('RelaysFeed component rendering') const { feedInfo, relayUrls } = useFeed() @@ -75,30 +78,38 @@ export default function RelaysFeed({ relayInfoFetchedRef.current = false }, [relayUrls]) - // Early returns for invalid feed types - if (feedInfo.feedType !== 'relay' && feedInfo.feedType !== 'relays' && feedInfo.feedType !== 'all-favorites') { - return null - } + const defaultKinds = + kindsOverride && kindsOverride.length > 0 + ? kindsOverride + : showKinds.length > 0 + ? showKinds + : [kinds.ShortTextNote] - // CRITICAL: Don't render feed if relayUrls is empty - this would cause subscription to fail - if (relayUrls.length === 0) { - logger.debug('RelaysFeed: relayUrls is empty, not rendering feed') - return null - } + const canRenderFeed = + (feedInfo.feedType === 'relay' || + feedInfo.feedType === 'relays' || + feedInfo.feedType === 'all-favorites') && + relayUrls.length > 0 - // CRITICAL: Provide proper filter with default kinds - NoteList requires kinds in filter - // Use showKinds from KindFilterProvider if available, otherwise default to kind 1 - const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] - - // Memoize subRequests with proper filter - this ensures NoteList gets valid filter + // Hooks must run every render — never place useMemo after conditional returns. const subRequests = useMemo(() => { - return [{ - urls: relayUrls, - filter: { - kinds: defaultKinds + if (!canRenderFeed) return [] + return [ + { + urls: relayUrls, + filter: { + kinds: defaultKinds + } } - }] - }, [relayUrls, defaultKinds]) + ] + }, [canRenderFeed, relayUrls, defaultKinds, kindsOverride]) + + if (!canRenderFeed) { + if (relayUrls.length === 0) { + logger.debug('RelaysFeed: relayUrls is empty, not rendering feed') + } + return null + } logger.component('RelaysFeed', 'Rendering NormalFeed', { subRequests: subRequests.length, diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index db35ce79..f0b393b0 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -21,7 +21,6 @@ import React, { } from 'react' import { useTranslation } from 'react-i18next' import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' -import ExploreButton from '@/components/Titlebar/ExploreButton' import AccountButton from '@/components/Titlebar/AccountButton' import FollowingFeed from './FollowingFeed' import RelaysFeed from './RelaysFeed' @@ -111,7 +110,7 @@ const NoteListPage = forwardRef((_, ref) => { return ( > }) { + const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { setPrimaryNoteView } = usePrimaryNoteView() return (
-
- +
+
{t('Favorites Feed')}
{isSmallScreen && (
diff --git a/src/pages/primary/RssPage/index.tsx b/src/pages/primary/RssPage/index.tsx new file mode 100644 index 00000000..c34e7e08 --- /dev/null +++ b/src/pages/primary/RssPage/index.tsx @@ -0,0 +1,78 @@ +import RssFeedList from '@/components/RssFeedList' +import { RefreshButton } from '@/components/RefreshButton' +import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' +import { Button } from '@/components/ui/button' +import { DEFAULT_RSS_FEEDS } from '@/constants' +import logger from '@/lib/logger' +import { useNostr } from '@/providers/NostrProvider' +import rssFeedService from '@/services/rss-feed.service' +import { Rss, Search } from 'lucide-react' +import { forwardRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const RssPage = forwardRef((_, ref) => { + const { t } = useTranslation() + const { pubkey, rssFeedListEvent } = useNostr() + const [rssRefreshKey, setRssRefreshKey] = useState(0) + + const handleRefresh = () => { + let feedUrls: string[] = [] + if (pubkey && rssFeedListEvent) { + try { + feedUrls = rssFeedListEvent.tags + .filter((tag) => tag[0] === 'u' && tag[1]) + .map((tag) => tag[1] as string) + .filter((url): url is string => typeof url === 'string' && url.trim().length > 0) + } catch { + feedUrls = [] + } + } else { + feedUrls = DEFAULT_RSS_FEEDS + } + rssFeedService.backgroundRefreshFeeds(feedUrls).catch((err) => { + logger.error('[RssPage] Background refresh failed', { error: err }) + }) + if (pubkey) { + window.dispatchEvent( + new CustomEvent('rssFeedListUpdated', { + detail: { pubkey, feedUrls, eventId: 'manual-refresh' } + }) + ) + } + setRssRefreshKey((k) => k + 1) + } + + return ( + +
+ +
{t('RSS Feed')}
+
+
+ + +
+
+ } + displayScrollToTopButton + > +
+ +
+ + ) +}) + +RssPage.displayName = 'RssPage' +export default RssPage diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts new file mode 100644 index 00000000..e761d50e --- /dev/null +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -0,0 +1,167 @@ +/** + * Built-in “faux spells” use the same NoteList path as kind-777 REQ spells. + */ +import { + DEFAULT_FAVORITE_RELAYS, + ExtendedKind, + FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, + PROFILE_FEED_KINDS +} from '@/constants' +import { normalizeTopic } from '@/lib/discussion-topics' +import { normalizeUrl } from '@/lib/url' +import type { TFeedSubRequest, TRelayList, TNotificationType } from '@/types' +import { kinds, type Event, type Filter } from 'nostr-tools' + +const NOTIFICATION_LIMIT = 500 +const DISCUSSION_LIMIT = 500 +const MAX_BOOKMARK_IDS = 250 + +export const MEDIA_SPELL_KINDS = [ + ExtendedKind.PICTURE, + ExtendedKind.VIDEO, + ExtendedKind.SHORT_VIDEO, + ExtendedKind.VOICE +] as const + +/** Relays for “global” faux feeds (media, calendar): visible favorites or defaults. */ +export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] { + const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) + const visible = favoriteRelays.filter((r) => { + const k = normalizeUrl(r) || r + return k && !blocked.has(k) + }) + const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS + return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) +} + +export function notificationRelayUrls( + relayList: TRelayList | null | undefined, + favoriteRelays: string[] +): string[] { + const read = relayList?.read ?? [] + if (read.length > 0) return dedupe(read.slice(0, 5)) + if (favoriteRelays.length > 0) return dedupe(favoriteRelays.slice(0, 5)) + return dedupe(FAST_READ_RELAY_URLS.slice(0, 5)) +} + +function dedupe(urls: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const u of urls) { + const k = normalizeUrl(u) || u + if (!k || seen.has(k)) continue + seen.add(k) + out.push(k) + } + return out +} + +export function notificationFilterKinds(notificationType: TNotificationType): number[] { + switch (notificationType) { + case 'mentions': + return [ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE, + ExtendedKind.DISCUSSION + ] + case 'reactions': + return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE] + case 'zaps': + return [kinds.Zap] + default: + return [ + kinds.ShortTextNote, + kinds.Repost, + kinds.Reaction, + kinds.Zap, + ExtendedKind.COMMENT, + ExtendedKind.POLL_RESPONSE, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE, + ExtendedKind.DISCUSSION + ] + } +} + +export function buildNotificationFilter(pubkey: string, notificationType: TNotificationType): Filter { + return { + kinds: notificationFilterKinds(notificationType), + limit: NOTIFICATION_LIMIT, + '#p': [pubkey] + } +} + +/** Relay set for discussion threads (kind 11), aligned with DiscussionsPage’s merged list (sync). */ +export function discussionRelayUrls( + relayList: TRelayList | null | undefined, + favoriteRelays: string[], + blockedRelays: string[] +): string[] { + const read = relayList?.read ?? [] + const write = relayList?.write ?? [] + const merged = [...read, ...write, ...favoriteRelays, ...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS] + const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) + const seen = new Set() + const out: string[] = [] + for (const u of merged) { + const k = normalizeUrl(u) || u + if (!k || seen.has(k) || blocked.has(k)) continue + seen.add(k) + out.push(k) + } + return out +} + +export function buildDiscussionFilter(): Filter { + return { + kinds: [ExtendedKind.DISCUSSION], + limit: DISCUSSION_LIMIT + } +} + +export function buildMediaSpellFilter(): Filter { + return { kinds: [...MEDIA_SPELL_KINDS], limit: 500 } +} + +export function buildCalendarSpellFilter(): Filter { + return { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], + limit: 200 + } +} + +/** One subrequest per topic (OR). Uses same kind set as the main profile/favorites feed. */ +export function buildInterestsSubRequests( + relayUrls: string[], + rawTopics: string[], + kindsList: number[] = PROFILE_FEED_KINDS +): TFeedSubRequest[] { + if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] + const topics = Array.from( + new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0)) + ) + if (!topics.length) return [] + return topics.map((topic) => ({ + urls: relayUrls, + filter: { + kinds: kindsList, + '#t': [topic], + limit: 400 + } + })) +} + +/** Bookmark list e-tags only (hex ids); addressable (a-tag) bookmarks need separate fetches. */ +export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls: string[]): TFeedSubRequest[] { + if (!bookmarkListEvent?.tags?.length || !urls.length) return [] + const ids = bookmarkListEvent.tags + .filter((t) => t[0] === 'e' && t[1] && /^[a-f0-9]{64}$/i.test(t[1])) + .map((t) => t[1] as string) + if (!ids.length) return [] + return [{ urls, filter: { ids: ids.slice(0, MAX_BOOKMARK_IDS), limit: MAX_BOOKMARK_IDS } }] +} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index f434443a..8f477b87 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1,10 +1,7 @@ import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import NoteList from '@/components/NoteList' -import NotificationList from '@/components/NotificationList' -import { RefreshButton } from '@/components/RefreshButton' import Tabs from '@/components/Tabs' import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, @@ -28,13 +25,17 @@ import { import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' +import { usePrimaryPage } from '@/PageManager' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' import { cn } from '@/lib/utils' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useKindFilter } from '@/providers/KindFilterProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import { ExtendedKind } from '@/constants' +import storage from '@/services/local-storage.service' +import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants' import { formatPubkey } from '@/lib/pubkey' import { buildSpellCatalogAuthors, @@ -44,23 +45,26 @@ import { isSpellEvent, SPELL_CATALOG_SYNC_LIMIT, SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS, - spellEventToFilter, - spellHasExplicitRelays, - spellIsCount + spellEventToFilter } from '@/services/spell.service' -import { TFeedSubRequest } from '@/types' +import { TFeedSubRequest, type TNotificationType } from '@/types' import { Bell, + Bookmark, + CalendarDays, Check, ChevronDown, Copy, FileText, + Hash, + Image as ImageIcon, MessageSquare, MoreVertical, Pencil, Plus, Star, Trash2, + Users, Wand2 } from 'lucide-react' import type { Event } from 'nostr-tools' @@ -68,10 +72,20 @@ import { verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' +import { + buildBookmarksSubRequests, + buildCalendarSpellFilter, + buildDiscussionFilter, + buildInterestsSubRequests, + buildMediaSpellFilter, + buildNotificationFilter, + discussionRelayUrls, + fauxFavoriteRelayUrls, + MEDIA_SPELL_KINDS, + notificationFilterKinds, + notificationRelayUrls +} from './fauxSpellFeeds' import type { TPageRef } from '@/types' -import type { TNotificationType } from '@/types' -import { isTouchDevice } from '@/lib/utils' -import DiscussionsPage from '@/pages/primary/DiscussionsPage' /** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */ function spellPickerPrimaryAndSecondary( @@ -166,30 +180,75 @@ function SpellSheetOptionRow({ ) } -const FAUX_SPELL_NAMES = ['notifications', 'discussions'] as const -type FauxSpellName = (typeof FAUX_SPELL_NAMES)[number] +type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] function isFauxSpellName(s: string): s is FauxSpellName { - return FAUX_SPELL_NAMES.includes(s as FauxSpellName) + return (FAUX_SPELL_ORDER as readonly string[]).includes(s) +} + +function useNoteListHideReplies() { + const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts') + + useEffect(() => { + const sync = () => setHideReplies(storage.getNoteListMode() === 'posts') + window.addEventListener('noteListModeChanged', sync) + return () => window.removeEventListener('noteListModeChanged', sync) + }, []) + + return hideReplies +} + +function fauxSpellLabelKey(name: FauxSpellName): string { + switch (name) { + case 'notifications': + return 'Notifications' + case 'discussions': + return 'Discussions' + case 'following': + return 'Following' + case 'media': + return 'Media' + case 'interests': + return 'Interests' + case 'bookmarks': + return 'Bookmarks' + case 'calendar': + return 'Calendar' + default: + return 'Spells' + } +} + +const FAUX_SPELL_ICON: Record = { + notifications: Bell, + discussions: MessageSquare, + following: Users, + media: ImageIcon, + interests: Hash, + bookmarks: Bookmark, + calendar: CalendarDays } -const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp }: { spell?: string }, ref) { +const SpellsPage = forwardRef(function SpellsPage( + { spell: spellProp }: { spell?: string }, + ref +) { const { t } = useTranslation() - const { pubkey, relayList, attemptDelete } = useNostr() + const { navigate: navigatePrimary } = usePrimaryPage() + const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { + showKinds: kindFilterShowKinds, + showKind1OPs, + showKind1Replies, + showKind1111 + } = useKindFilter() + const hideRepliesFollowing = useNoteListHideReplies() const [spells, setSpells] = useState([]) const [favoriteIds, setFavoriteIds] = useState>(new Set()) const [selectedSpell, setSelectedSpell] = useState(null) const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) const [notificationType, setNotificationType] = useState('all') - const notificationListRef = useRef<{ refresh: () => void }>(null) - const supportTouch = useMemo(() => isTouchDevice(), []) - - useEffect(() => { - if (spellProp && isFauxSpellName(spellProp)) { - setSelectedFauxSpell(spellProp) - setSelectedSpell(null) - } - }, [spellProp]) const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) const [spellToClone, setSpellToClone] = useState(null) @@ -199,22 +258,40 @@ const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp } const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const spellCatalogCloserRef = useRef<(() => void) | null>(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) - /** COUNT spells: per-relay breakdown + distinct total */ - const [spellCount, setSpellCount] = useState<{ - loading: boolean - rows: { url: string; count: number | null; error?: string }[] - totalDistinct: number | null - error: 'none' | 'login' | 'invalid' | 'failed' - mayHitLimit: boolean - usedExplicitRelays: boolean - }>({ - loading: false, - rows: [], - totalDistinct: null, - error: 'none', - mayHitLimit: false, - usedExplicitRelays: false - }) + + useEffect(() => { + if (spellProp && isFauxSpellName(spellProp)) { + setSelectedFauxSpell(spellProp) + setSelectedSpell(null) + } + }, [spellProp]) + + const [followingSubRequests, setFollowingSubRequests] = useState([]) + const [followingFeedLoading, setFollowingFeedLoading] = useState(false) + + useEffect(() => { + if (selectedFauxSpell !== 'following' || !pubkey) { + setFollowingSubRequests([]) + setFollowingFeedLoading(false) + return + } + let cancelled = false + setFollowingFeedLoading(true) + void (async () => { + try { + const followings = await client.fetchFollowings(pubkey) + const req = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) + if (!cancelled) setFollowingSubRequests(req) + } catch { + if (!cancelled) setFollowingSubRequests([]) + } finally { + if (!cancelled) setFollowingFeedLoading(false) + } + })() + return () => { + cancelled = true + } + }, [selectedFauxSpell, pubkey]) const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ @@ -327,164 +404,74 @@ const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp } client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) }, [pubkey]) - // Memoize subRequests to prevent NoteList from re-subscribing when array reference changes - // This ensures the array reference only changes when the actual content changes - const subRequests = useMemo(() => { - if (!selectedSpell) { - return [] - } - if (spellIsCount(selectedSpell)) { - return [] + const syncFauxSubRequests = useMemo(() => { + if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] + + if (selectedFauxSpell === 'notifications') { + if (!pubkey) return [] + const urls = notificationRelayUrls(relayList, favoriteRelays) + if (!urls.length) return [] + return [{ urls, filter: buildNotificationFilter(pubkey, notificationType) }] } - const relayListWrite = relayList?.write ?? [] - const ctx = { - pubkey, - contacts + if (selectedFauxSpell === 'discussions') { + const urls = discussionRelayUrls(relayList, favoriteRelays, blockedRelays) + if (!urls.length) return [] + return [{ urls, filter: buildDiscussionFilter() }] } - const filter = spellEventToFilter(selectedSpell, ctx) - if (!filter) { - return [] + if (selectedFauxSpell === 'media') { + const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) + if (!urls.length) return [] + return [{ urls, filter: buildMediaSpellFilter() }] } - const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) - if (!relays.length) { - return [] + if (selectedFauxSpell === 'calendar') { + const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) + if (!urls.length) return [] + return [{ urls, filter: buildCalendarSpellFilter() }] } - return [{ urls: relays, filter }] - }, [selectedSpell, pubkey, contacts, relayList?.write]) - - useEffect(() => { - if (!selectedSpell) { - setSpellCount({ - loading: false, - rows: [], - totalDistinct: null, - error: 'none', - mayHitLimit: false, - usedExplicitRelays: false - }) - return + if (selectedFauxSpell === 'interests') { + if (!pubkey || !interestListEvent) return [] + const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) + const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) + return buildInterestsSubRequests(urls, topics, PROFILE_FEED_KINDS) } - if (spellIsCount(selectedSpell)) { - return + if (selectedFauxSpell === 'bookmarks') { + if (!pubkey) return [] + const urls = notificationRelayUrls(relayList, favoriteRelays) + return buildBookmarksSubRequests(bookmarkListEvent, urls) } - setSpellCount({ - loading: false, - rows: [], - totalDistinct: null, - error: 'none', - mayHitLimit: false, - usedExplicitRelays: false - }) - }, [selectedSpell]) + return [] + }, [ + selectedFauxSpell, + pubkey, + notificationType, + relayList, + favoriteRelays, + blockedRelays, + interestListEvent, + bookmarkListEvent + ]) - useEffect(() => { - if (!selectedSpell || !spellIsCount(selectedSpell)) { - return - } - let cancelled = false + const fauxSubRequests = useMemo(() => { + if (selectedFauxSpell === 'following') return followingSubRequests + return syncFauxSubRequests + }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) + + const spellSubRequests = useMemo(() => { + if (!selectedSpell) return [] const relayListWrite = relayList?.write ?? [] const ctx = { pubkey, contacts } - const usedExplicitRelays = spellHasExplicitRelays(selectedSpell) - - const needsLogin = - !pubkey && - selectedSpell.tags.some( - (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) - ) - if (needsLogin) { - setSpellCount({ - loading: false, - rows: [], - totalDistinct: null, - error: 'login', - mayHitLimit: false, - usedExplicitRelays - }) - return - } - const filter = spellEventToFilter(selectedSpell, ctx) - if (!filter) { - setSpellCount({ - loading: false, - rows: [], - totalDistinct: null, - error: 'invalid', - mayHitLimit: false, - usedExplicitRelays - }) - return - } - const relays = getRelaysForSpell(selectedSpell, { relayListWrite }, { mergeDefaultReadRelays: false }) - if (!relays.length) { - setSpellCount({ - loading: false, - rows: [], - totalDistinct: null, - error: 'failed', - mayHitLimit: false, - usedExplicitRelays - }) - return - } - - setSpellCount({ - loading: true, - rows: [], - totalDistinct: null, - error: 'none', - mayHitLimit: false, - usedExplicitRelays - }) - ;(async () => { - const rows: { url: string; count: number | null; error?: string }[] = [] - const allIds = new Set() - try { - for (const url of relays) { - if (cancelled) return - const { events, connectionError } = await client.fetchEventsFromSingleRelay(url, filter, { - globalTimeout: 28_000 - }) - if (cancelled) return - if (connectionError) { - rows.push({ url, count: null, error: connectionError }) - } else { - const c = new Set(events.map((e) => e.id)).size - rows.push({ url, count: c }) - events.forEach((e) => allIds.add(e.id)) - } - } - if (cancelled) return - const lim = filter.limit - const totalDistinct = allIds.size - const mayHitLimit = typeof lim === 'number' && lim > 0 && totalDistinct >= lim - setSpellCount({ - loading: false, - rows, - totalDistinct, - error: 'none', - mayHitLimit, - usedExplicitRelays - }) - } catch { - if (!cancelled) { - setSpellCount({ - loading: false, - rows, - totalDistinct: null, - error: 'failed', - mayHitLimit: false, - usedExplicitRelays - }) - } - } - })() - - return () => { - cancelled = true - } + if (!filter) return [] + const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) + if (!relays.length) return [] + return [{ urls: relays, filter }] }, [selectedSpell, pubkey, contacts, relayList?.write]) + const subRequests = useMemo(() => { + if (selectedFauxSpell) return fauxSubRequests + return spellSubRequests + }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) + const toggleFavorite = useCallback(async (spellId: string) => { const ids = await indexedDb.getSpellFavoriteIds() const set = new Set(ids) @@ -561,39 +548,89 @@ const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp } }, [selectedSpell?.id]) const showKinds = useMemo(() => { + if (selectedFauxSpell === 'notifications') { + return notificationFilterKinds(notificationType) + } + if (selectedFauxSpell === 'discussions') { + return [ExtendedKind.DISCUSSION] + } + if (selectedFauxSpell === 'following') { + return kindFilterShowKinds + } + if (selectedFauxSpell === 'media') { + return [...MEDIA_SPELL_KINDS] + } + if (selectedFauxSpell === 'calendar') { + return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] + } + if (selectedFauxSpell === 'interests') { + return PROFILE_FEED_KINDS + } + if (selectedFauxSpell === 'bookmarks') { + return PROFILE_FEED_KINDS + } if (!selectedSpell) return [1] const kinds = selectedSpell.tags .filter((tag) => tag[0] === 'k') .map((tag) => parseInt(tag[1], 10)) .filter((n) => !Number.isNaN(n)) return kinds.length ? kinds : [1] - }, [selectedSpell?.id, showKindsTagKey]) + }, [ + selectedFauxSpell, + notificationType, + selectedSpell?.id, + showKindsTagKey, + kindFilterShowKinds + ]) const spellMenuLabel = useCallback( (spell: Event) => (favoriteIds.has(spell.id) ? `★ ${getSpellName(spell)}` : getSpellName(spell)), [favoriteIds] ) - const pickSpell = useCallback((spell: Event | null) => { - setSelectedSpell(spell) - setSelectedFauxSpell(null) - setSpellPickerOpen(false) - }, []) + const pickSpell = useCallback( + (spell: Event | null) => { + setSelectedSpell(spell) + setSelectedFauxSpell(null) + setSpellPickerOpen(false) + navigatePrimary('spells') + }, + [navigatePrimary] + ) const clearSpellSelection = useCallback(() => { setSelectedSpell(null) setSelectedFauxSpell(null) setSpellPickerOpen(false) - }, []) + navigatePrimary('spells') + }, [navigatePrimary]) - const pickFauxSpell = useCallback((name: FauxSpellName | null) => { - setSelectedFauxSpell(name) - setSelectedSpell(null) - setSpellPickerOpen(false) - }, []) + const pickFauxSpell = useCallback( + (name: FauxSpellName | null) => { + setSelectedFauxSpell(name) + setSelectedSpell(null) + setSpellPickerOpen(false) + if (name) navigatePrimary('spells', { spell: name }) + else navigatePrimary('spells') + }, + [navigatePrimary] + ) const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) + const fauxNoteListUseFilterAsIs = useMemo(() => { + if (!selectedFauxSpell) return true + return selectedFauxSpell !== 'following' && selectedFauxSpell !== 'bookmarks' + }, [selectedFauxSpell]) + + const fauxFeedEmptyMessage = useMemo(() => { + if (!selectedFauxSpell || fauxSubRequests.length > 0) return null + if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') + if (selectedFauxSpell === 'bookmarks') return t('No bookmarked notes with id tags yet.') + if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') + return t('Nothing to load for this feed.') + }, [selectedFauxSpell, fauxSubRequests.length, t]) + return ( (function SpellsPage({ spell: spellProp } variant="outline" className="min-w-0 flex-1 justify-between font-normal sm:max-w-md" title={ - selectedFauxSpell === 'notifications' - ? t('Notifications') - : selectedFauxSpell === 'discussions' - ? t('Discussions') - : selectedSpell - ? spellMenuLabel(selectedSpell) - : undefined + selectedFauxSpell + ? t(fauxSpellLabelKey(selectedFauxSpell)) + : selectedSpell + ? spellMenuLabel(selectedSpell) + : undefined } aria-haspopup="dialog" aria-expanded={spellPickerOpen} onClick={() => setSpellPickerOpen(true)} > - {selectedFauxSpell === 'notifications' - ? t('Notifications') - : selectedFauxSpell === 'discussions' - ? t('Discussions') - : selectedSpell - ? spellMenuLabel(selectedSpell) - : t('Select a spell…')} + {selectedFauxSpell + ? t(fauxSpellLabelKey(selectedFauxSpell)) + : selectedSpell + ? spellMenuLabel(selectedSpell) + : t('Select a spell…')} @@ -664,44 +697,41 @@ const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp } role="listbox" aria-label={t('Select a spell…')} > - {pubkey && ( - - )} - + {FAUX_SPELL_ORDER.map((name) => { + if ( + (name === 'notifications' || + name === 'following' || + name === 'bookmarks' || + name === 'interests') && + !pubkey + ) { + return null + } + const Icon = FAUX_SPELL_ICON[name] + const selected = selectedFauxSpell === name + return ( + + ) + })} -
- ) : null} - {spellCount.mayHitLimit ? ( -

- {t('COUNT spell may be capped by limit')} -

- ) : null} - - )} -
- ) : subRequests.length > 0 ? ( - + subRequests.length > 0 ? ( + ) : !pubkey && selectedSpell.tags.some( (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) diff --git a/src/pages/secondary/HomePage/index.tsx b/src/pages/secondary/HomePage/index.tsx index 9fbd431a..bf57e65a 100644 --- a/src/pages/secondary/HomePage/index.tsx +++ b/src/pages/secondary/HomePage/index.tsx @@ -72,7 +72,17 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => { ))}
- diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index 6981c436..647851d5 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -487,7 +487,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index />
- {t('Show or hide the RSS feed tab in the main feed')} + {t('Show or hide the RSS page and sidebar entry')}
diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index ccb2e897..8fbdf92c 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -234,13 +234,12 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { // Update relay URLs when favoriteRelays change and we're in all-favorites mode useEffect(() => { - if (feedInfo.feedType === 'all-favorites') { - // Filter out blocked relays - const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) - logger.debug('Updating relay URLs for all-favorites:', visibleRelays) - setRelayUrls(visibleRelays) - } - }, [pubkey, isInitialized, favoriteRelays, blockedRelays, relaySets]) + if (feedInfo.feedType !== 'all-favorites') return + const visibleRelays = favoriteRelays.filter((relay) => !blockedRelays.includes(relay)) + const finalRelays = visibleRelays.length > 0 ? visibleRelays : DEFAULT_FAVORITE_RELAYS + logger.debug('Updating relay URLs for all-favorites:', finalRelays) + setRelayUrls(finalRelays) + }, [feedInfo.feedType, favoriteRelays, blockedRelays]) return ( }, { path: '/profile/notes/:id', element: }, { path: '/explore/notes/:id', element: }, + { path: '/home/notes/:id', element: }, + { path: '/feed/notes/:id', element: }, { path: '/spells/notes/:id', element: }, { path: '/users', element: }, { path: '/users/:id', element: }, @@ -36,7 +38,8 @@ const ROUTES = [ { path: '/users/:id/relays', element: }, { path: '/relays/:url', element: }, { path: '/relays/:url/reviews', element: }, - // Contextual relay route (only for explore page where you discover relays) + // Contextual relay routes (home = explore; legacy /explore) + { path: '/home/relays/:url', element: }, { path: '/explore/relays/:url', element: }, { path: '/search', element: }, { path: '/settings', element: },