diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b4c187ec..8f8e03a3 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -87,7 +87,7 @@ type TStackItem = { } const PRIMARY_PAGE_REF_MAP = { - home: createRef(), + explore: createRef(), feed: createRef(), me: createRef(), profile: createRef(), @@ -101,9 +101,9 @@ const PRIMARY_PAGE_REF_MAP = { // Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency // This is only evaluated when called, not at module load time const getPrimaryPageMap = () => ({ - home: ( + explore: ( - + ), feed: ( @@ -169,8 +169,8 @@ function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageNam if (pageContext === 'discussions') { return { name: 'spells', props: { spell: 'discussions' } } } - if (pageContext === 'explore') { - return { name: 'home' } + if (pageContext === 'explore' || pageContext === 'home') { + return { name: 'explore' } } const map = getPrimaryPageMap() if (pageContext in map) { @@ -241,7 +241,7 @@ 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', 'feed', 'spells', 'rss', 'home'] + const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'explore'] if (currentPage && contextualPages.includes(currentPage)) { return `/${currentPage}/notes/${noteId}` @@ -254,8 +254,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string { const encodedRelayUrl = encodeURIComponent(relayUrl) - if (currentPage === 'home') { - return `/home/relays/${encodedRelayUrl}` + if (currentPage === 'explore') { + return `/explore/relays/${encodedRelayUrl}` } return `/relays/${encodedRelayUrl}` @@ -266,7 +266,8 @@ function buildPrimaryPageUrl( page: TPrimaryPageName, props?: { spell?: string } | Record | null ): string { - if (page === 'home') return '/' + if (page === 'feed') return '/' + if (page === 'explore') return '/explore' if (page === 'spells') { const spell = props && typeof (props as { spell?: unknown }).spell === 'string' @@ -287,9 +288,12 @@ function spellPropsFromSearch(search: string): { spell: string } | undefined { function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): string { const popSegments = pathname.split('/').filter(Boolean) const popFirstSeg = popSegments[0] ?? '' - if (popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home')) { + if (popSegments.length === 0) { return '/' } + if (popSegments.length === 1 && popFirstSeg === 'home') { + return '/explore' + } if (popSegments.length === 1 && popFirstSeg === 'spells') { try { const sp = new URL(fullUrlForQuery, window.location.origin).searchParams.get('spell')?.trim() @@ -674,13 +678,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() // DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled - const [currentPrimaryPage, setCurrentPrimaryPage] = useState('home') + const [currentPrimaryPage, setCurrentPrimaryPage] = useState('feed') const [primaryPages, setPrimaryPages] = useState< { name: TPrimaryPageName; element: ReactNode; props?: any }[] >([ { - name: 'home', - element: getPrimaryPageMap().home + name: 'feed', + element: getPrimaryPageMap().feed } ]) const [secondaryStack, setSecondaryStack] = useState([]) @@ -892,13 +896,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (isPrimaryPageUrl) { // This is a primary page - just navigate to it, don't push to secondary stack - const pageName = - segments.length === 0 || (segments.length === 1 && firstSeg === 'home') ? 'home' : firstSeg + const pageName: TPrimaryPageName | 'discussions' | null = + segments.length === 0 + ? 'feed' + : firstSeg === 'home' + ? 'explore' + : firstSeg === 'discussions' + ? 'discussions' + : firstSeg in primaryMap + ? (firstSeg as TPrimaryPageName) + : null if (pageName === 'explore') { - navigatePrimaryPage('home') + navigatePrimaryPage('explore') requestAnimationFrame(() => { window.dispatchEvent( - new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } }) + new CustomEvent('restorePageTab', { detail: { page: 'explore', tab: 'explore' } }) ) }) } else if (pageName === 'discussions') { @@ -906,7 +918,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } else if (pageName === 'spells') { const spellProps = spellPropsFromSearch(window.location.search) navigatePrimaryPage('spells', spellProps) - } else if (pageName in primaryMap) { + } else if (pageName && pageName in primaryMap) { navigatePrimaryPage(pageName as TPrimaryPageName) } return @@ -945,8 +957,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const pathname: string = window.location.pathname // Handle dedicated paths for primary pages - if (pathname === '/' || pathname === '/home') { - navigatePrimaryPage('home') + if (pathname === '/') { + navigatePrimaryPage('feed') + } else if (pathname === '/home') { + navigatePrimaryPage('explore') } else { // Check if pathname matches a primary page name // First, check if it's a contextual note URL (e.g., /discussions/notes/...) @@ -970,10 +984,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return } if (pageName === 'explore') { - navigatePrimaryPage('home') + navigatePrimaryPage('explore') requestAnimationFrame(() => { window.dispatchEvent( - new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } }) + new CustomEvent('restorePageTab', { detail: { page: 'explore', tab: 'explore' } }) ) }) return diff --git a/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx b/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx index 16c79927..a8f260bc 100644 --- a/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx +++ b/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx @@ -5,17 +5,25 @@ import { MouseEventHandler } from 'react' export default function BottomNavigationBarItem({ children, active = false, + prominent = false, onClick }: { children: React.ReactNode active?: boolean + /** Slightly larger icon (e.g. favorites feed). */ + prominent?: boolean onClick: MouseEventHandler }) { return ( - - - - - + const sortToolbar = ( +
+ {t('Sort')}: +
+ + + +
+
+ ) + const notesBody = ( + <> {cacheLoading && cacheEvents.length === 0 ? ( -
+
{t('Loading trending notes from your relays...')}
) : null} @@ -376,6 +385,45 @@ export default function TrendingNotes() { ) : (
{t('no more notes')}
)} + + ) + + if (variant === 'searchAccordion') { + return ( + + + + {headerTitle} + {cacheLoading && cacheEvents.length === 0 ? ( + + ) : null} + + + + +
+
{sortToolbar}
+ {notesBody} +
+
+
+ ) + } + + return ( +
+
+
+

{headerTitle}

+
+ {sortToolbar} +
+ {notesBody}
) } diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 526f9d34..e1a31d2a 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -72,7 +72,7 @@ const ExplorePage = forwardRef((_, ref) => { // Listen for tab restoration from PageManager useEffect(() => { const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { - if (e.detail.page === 'home' && e.detail.tab) { + if (e.detail.page === 'explore' && e.detail.tab) { setTab(normalizeHomeTab(e.detail.tab)) } } @@ -83,7 +83,7 @@ const ExplorePage = forwardRef((_, ref) => { return ( } subHeader={ { setTab(next as TExploreTabs) window.dispatchEvent( new CustomEvent('pageTabChanged', { - detail: { page: 'home', tab: next } + detail: { page: 'explore', tab: next } }) ) }} diff --git a/src/pages/primary/MePage/index.tsx b/src/pages/primary/MePage/index.tsx index 9c91db3b..82b2af35 100644 --- a/src/pages/primary/MePage/index.tsx +++ b/src/pages/primary/MePage/index.tsx @@ -33,7 +33,7 @@ const MePage = forwardRef((_, ref) => { return ( } hideTitlebarBottomBorder > @@ -47,7 +47,7 @@ const MePage = forwardRef((_, ref) => { return ( } hideTitlebarBottomBorder > diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index d9fc3e55..3dc4fea2 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -69,8 +69,14 @@ const SearchPage = forwardRef((_, ref) => {
- {!searchParams && } - + {searchParams ? ( + + ) : ( +
+ + +
+ )} ) diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index f994ab4c..c06c8915 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -125,9 +125,14 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
- {!searchParams && } -
Trending Notes
- + {searchParams ? ( + + ) : ( +
+ + +
+ )} ) diff --git a/src/providers/InterestListProvider.tsx b/src/providers/InterestListProvider.tsx index cdc032c2..081f3505 100644 --- a/src/providers/InterestListProvider.tsx +++ b/src/providers/InterestListProvider.tsx @@ -7,7 +7,7 @@ import client from '@/services/client.service' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { useNostr } from './NostrProvider' +import { useNostr } from '@/providers/nostr-context' import { useFavoriteRelays } from './FavoriteRelaysProvider' type TInterestListContext = { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 4b2508f2..6a010089 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -35,7 +35,8 @@ import dayjs from 'dayjs' import { Event, kinds, VerifiedEvent, validateEvent } from 'nostr-tools' import * as nip19 from 'nostr-tools/nip19' import * as nip49 from 'nostr-tools/nip49' -import { createContext, useContext, useEffect, useState } from 'react' +import { NostrContext } from '@/providers/nostr-context' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { BunkerSigner } from './bunker.signer' @@ -44,65 +45,8 @@ import { NostrConnectionSigner } from './nostrConnection.signer' import { NpubSigner } from './npub.signer' import { NsecSigner } from './nsec.signer' -type TNostrContext = { - isInitialized: boolean - pubkey: string | null - profile: TProfile | null - profileEvent: Event | null - relayList: TRelayList | null - cacheRelayListEvent: Event | null - followListEvent: Event | null - muteListEvent: Event | null - bookmarkListEvent: Event | null - interestListEvent: Event | null - favoriteRelaysEvent: Event | null - blockedRelaysEvent: Event | null - userEmojiListEvent: Event | null - rssFeedListEvent: Event | null - account: TAccountPointer | null - accounts: TAccountPointer[] - nsec: string | null - ncryptsec: string | null - switchAccount: (account: TAccountPointer | null) => Promise - nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise - ncryptsecLogin: (ncryptsec: string) => Promise - nip07Login: () => Promise - bunkerLogin: (bunker: string) => Promise - nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise - npubLogin(npub: string): Promise - removeAccount: (account: TAccountPointer) => void - /** - * Default publish the event to current relays, user's write relays and additional relays - */ - publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise - attemptDelete: (targetEvent: Event) => Promise - signHttpAuth: (url: string, method: string) => Promise - signEvent: (draftEvent: TDraftEvent) => Promise - nip04Encrypt: (pubkey: string, plainText: string) => Promise - nip04Decrypt: (pubkey: string, cipherText: string) => Promise - startLogin: () => void - checkLogin: (cb?: () => T) => Promise - updateRelayListEvent: (relayListEvent: Event) => Promise - updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise - updateProfileEvent: (profileEvent: Event) => Promise - updateFollowListEvent: (followListEvent: Event) => Promise - updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise - updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise - updateInterestListEvent: (interestListEvent: Event) => Promise - updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise - updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise - updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise -} - -const NostrContext = createContext(undefined) - -export const useNostr = () => { - const context = useContext(NostrContext) - if (!context) { - throw new Error('useNostr must be used within a NostrProvider') - } - return context -} +export { useNostr } from '@/providers/nostr-context' +export type { TNostrContext } from '@/providers/nostr-context' export function NostrProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx new file mode 100644 index 00000000..7401b016 --- /dev/null +++ b/src/providers/nostr-context.tsx @@ -0,0 +1,70 @@ +/** + * Standalone React context for Nostr so HMR on `NostrProvider/index.tsx` does not recreate + * `createContext()` (which breaks `useNostr` in providers like InterestListProvider after Fast Refresh). + */ +import type { + TAccountPointer, + TDraftEvent, + TProfile, + TPublishOptions, + TRelayList +} from '@/types' +import { Event, VerifiedEvent } from 'nostr-tools' +import { createContext, useContext } from 'react' + +export type TNostrContext = { + isInitialized: boolean + pubkey: string | null + profile: TProfile | null + profileEvent: Event | null + relayList: TRelayList | null + cacheRelayListEvent: Event | null + followListEvent: Event | null + muteListEvent: Event | null + bookmarkListEvent: Event | null + interestListEvent: Event | null + favoriteRelaysEvent: Event | null + blockedRelaysEvent: Event | null + userEmojiListEvent: Event | null + rssFeedListEvent: Event | null + account: TAccountPointer | null + accounts: TAccountPointer[] + nsec: string | null + ncryptsec: string | null + switchAccount: (account: TAccountPointer | null) => Promise + nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise + ncryptsecLogin: (ncryptsec: string) => Promise + nip07Login: () => Promise + bunkerLogin: (bunker: string) => Promise + nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise + npubLogin(npub: string): Promise + removeAccount: (account: TAccountPointer) => void + publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise + attemptDelete: (targetEvent: Event) => Promise + signHttpAuth: (url: string, method: string) => Promise + signEvent: (draftEvent: TDraftEvent) => Promise + nip04Encrypt: (pubkey: string, plainText: string) => Promise + nip04Decrypt: (pubkey: string, cipherText: string) => Promise + startLogin: () => void + checkLogin: (cb?: () => T) => Promise + updateRelayListEvent: (relayListEvent: Event) => Promise + updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise + updateProfileEvent: (profileEvent: Event) => Promise + updateFollowListEvent: (followListEvent: Event) => Promise + updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise + updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise + updateInterestListEvent: (interestListEvent: Event) => Promise + updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise + updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise + updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise +} + +export const NostrContext = createContext(undefined) + +export function useNostr(): TNostrContext { + const context = useContext(NostrContext) + if (!context) { + throw new Error('useNostr must be used within a NostrProvider') + } + return context +} diff --git a/vite.config.ts b/vite.config.ts index ac6328f9..de40835d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,8 +26,9 @@ const getAppVersion = () => { } /** - * React Fast Refresh can remount provider children without NostrProvider (e.g. after editing pages), - * causing `useNostr must be used within a NostrProvider`. Full page reload keeps the tree consistent. + * React Fast Refresh can remount provider children without matching context after editing providers + * or pages. Full page reload keeps the tree consistent. `nostr-context.tsx` fixes duplicate Nostr + * `createContext` identity across HMR for most cases. */ function fullReloadOnProvidersAndPages(): Plugin { return {