Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d768a6c325
  1. 58
      src/PageManager.tsx
  2. 10
      src/components/BottomNavigationBar/BottomNavigationBarItem.tsx
  3. 9
      src/components/BottomNavigationBar/FeedButton.tsx
  4. 17
      src/components/BottomNavigationBar/HomeButton.tsx
  5. 13
      src/components/BottomNavigationBar/SpellsButton.tsx
  6. 12
      src/components/LatestFromFollowsSection/index.tsx
  7. 2
      src/components/SearchResult/index.tsx
  8. 10
      src/components/Sidebar/FeedButton.tsx
  9. 22
      src/components/Sidebar/HomeButton.tsx
  10. 162
      src/components/TrendingNotes/index.tsx
  11. 6
      src/pages/primary/ExplorePage/index.tsx
  12. 4
      src/pages/primary/MePage/index.tsx
  13. 10
      src/pages/primary/SearchPage/index.tsx
  14. 11
      src/pages/secondary/SearchPage/index.tsx
  15. 2
      src/providers/InterestListProvider.tsx
  16. 64
      src/providers/NostrProvider/index.tsx
  17. 70
      src/providers/nostr-context.tsx
  18. 5
      vite.config.ts

58
src/PageManager.tsx

@ -87,7 +87,7 @@ type TStackItem = { @@ -87,7 +87,7 @@ type TStackItem = {
}
const PRIMARY_PAGE_REF_MAP = {
home: createRef<TPageRef>(),
explore: createRef<TPageRef>(),
feed: createRef<TPageRef>(),
me: createRef<TPageRef>(),
profile: createRef<TPageRef>(),
@ -101,9 +101,9 @@ const PRIMARY_PAGE_REF_MAP = { @@ -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: (
<Suspense fallback={primaryPageLazyFallback}>
<ExplorePageLazy ref={PRIMARY_PAGE_REF_MAP.home} />
<ExplorePageLazy ref={PRIMARY_PAGE_REF_MAP.explore} />
</Suspense>
),
feed: (
@ -169,8 +169,8 @@ function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageNam @@ -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() { @@ -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 @@ -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( @@ -266,7 +266,8 @@ function buildPrimaryPageUrl(
page: TPrimaryPageName,
props?: { spell?: string } | Record<string, unknown> | 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 { @@ -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 }) { @@ -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<TPrimaryPageName>('home')
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('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<TStackItem[]>([])
@ -892,13 +896,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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

10
src/components/BottomNavigationBar/BottomNavigationBarItem.tsx

@ -5,17 +5,25 @@ import { MouseEventHandler } from 'react' @@ -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 (
<Button
className={cn(
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
active && 'text-primary hover:text-primary'
prominent &&
'h-[3.25rem] min-w-[3.25rem] [&_svg]:h-[1.85rem] [&_svg]:w-[1.85rem] [&_svg]:shrink-0',
prominent &&
'text-green-600 opacity-[0.82] hover:opacity-100 hover:text-green-600 dark:text-green-500 dark:hover:text-green-500',
prominent && active && 'opacity-100 ring-2 ring-green-500/45 ring-offset-2 ring-offset-background dark:ring-green-400/50',
active && !prominent && 'text-primary hover:text-primary'
)}
variant="ghost"
onClick={onClick}

9
src/components/BottomNavigationBar/FeedButton.tsx

@ -1,23 +1,24 @@ @@ -1,23 +1,24 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Newspaper } from 'lucide-react'
import { Compass } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
/** Relay explore / discovery (primary Explore page). */
export default function FeedButton() {
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
return (
<BottomNavigationBarItem
active={current === 'feed' && display && primaryViewType === null}
active={current === 'explore' && display && primaryViewType === null}
onClick={() => {
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('feed')
navigate('explore')
}
}}
>
<Newspaper />
<Compass />
</BottomNavigationBarItem>
)
}

17
src/components/BottomNavigationBar/HomeButton.tsx

@ -1,24 +1,31 @@ @@ -1,24 +1,31 @@
import { cn } from '@/lib/utils'
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Home } from 'lucide-react'
import { Star } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
/** Favorites feed (primary “home” destination in the bar). */
export default function HomeButton() {
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
const active = current === 'feed' && display && primaryViewType === null
return (
<BottomNavigationBarItem
active={current === 'home' && display && primaryViewType === null}
prominent
active={active}
onClick={() => {
// If there's an overlay open, clear it first
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('home')
navigate('feed')
}
}}
>
<Home />
<Star
strokeWidth={active ? 2.4 : 2}
className={cn(active && 'fill-green-500/30 dark:fill-green-400/35')}
/>
</BottomNavigationBarItem>
)
}

13
src/components/BottomNavigationBar/SpellsButton.tsx

@ -1,14 +1,21 @@ @@ -1,14 +1,21 @@
import { usePrimaryPage } from '@/PageManager'
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Wand2 } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function SpellsButton() {
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
return (
<BottomNavigationBarItem
active={current === 'spells' && display}
onClick={() => navigate('spells')}
active={current === 'spells' && display && primaryViewType === null}
onClick={() => {
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('spells')
}
}}
>
<Wand2 />
</BottomNavigationBarItem>

12
src/components/LatestFromFollowsSection/index.tsx

@ -105,7 +105,8 @@ export default function LatestFromFollowsSection() { @@ -105,7 +105,8 @@ export default function LatestFromFollowsSection() {
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map())
const [batchBusy, setBatchBusy] = useState(false)
const [sectionOpen, setSectionOpen] = useState(true)
/** Search page: start collapsed so the bar doesn’t push the search field; data still prefetches in the background. */
const [sectionOpen, setSectionOpen] = useState(false)
const abortedRef = useRef(false)
const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys
@ -260,9 +261,14 @@ export default function LatestFromFollowsSection() { @@ -260,9 +261,14 @@ export default function LatestFromFollowsSection() {
}
return (
<Collapsible open={sectionOpen} onOpenChange={setSectionOpen} className="mb-6 min-w-0">
<Collapsible open={sectionOpen} onOpenChange={setSectionOpen} className="min-w-0">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2.5 text-left hover:bg-muted/25">
<span className="text-base font-semibold">{heading}</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold">{heading}</span>
{batchBusy && postsByPubkey.size === 0 ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</span>
<ChevronDown
className={cn('size-5 shrink-0 text-muted-foreground transition-transform', sectionOpen && 'rotate-180')}
/>

2
src/components/SearchResult/index.tsx

@ -41,7 +41,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -41,7 +41,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
}, [pubkey, relayList, favoriteRelays, blockedRelays])
if (!searchParams) {
return <TrendingNotes />
return <TrendingNotes variant="searchAccordion" />
}
if (searchParams.type === 'profile') {
return <Profile id={searchParams.search} />

10
src/components/Sidebar/FeedButton.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Newspaper } from 'lucide-react'
import { Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
@ -10,11 +10,11 @@ export default function FeedButton() { @@ -10,11 +10,11 @@ export default function FeedButton() {
return (
<SidebarItem
title={t('Feed')}
onClick={() => navigate('feed')}
active={display && current === 'feed' && primaryViewType === null}
title={t('Explore')}
onClick={() => navigate('explore')}
active={display && current === 'explore' && primaryViewType === null}
>
<Newspaper strokeWidth={3} />
<Compass strokeWidth={3} />
</SidebarItem>
)
}

22
src/components/Sidebar/HomeButton.tsx

@ -1,18 +1,30 @@ @@ -1,18 +1,30 @@
import { cn } from '@/lib/utils'
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Home } from 'lucide-react'
import { Star } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
export default function HomeButton() {
const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const active = display && current === 'feed' && primaryViewType === null
return (
<SidebarItem
title="Home"
onClick={() => navigate('home')}
active={display && current === 'home' && primaryViewType === null}
title={t('Favorites Feed')}
onClick={() => navigate('feed')}
active={active}
className={cn(
'[&_svg]:!h-[1.45rem] [&_svg]:!w-[1.45rem] xl:[&_svg]:!h-7 xl:[&_svg]:!w-7',
'text-green-600 opacity-90 hover:opacity-100 dark:text-green-500',
active && 'bg-green-500/15 opacity-100 hover:bg-green-500/15 dark:bg-green-500/20'
)}
>
<Home strokeWidth={3} />
<Star
strokeWidth={active ? 2.75 : 2.35}
className={cn(active && 'fill-green-500/30 dark:fill-green-400/35')}
/>
</SidebarItem>
)
}

162
src/components/TrendingNotes/index.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { queryService } from '@/services/client.service'
@ -13,6 +15,7 @@ import noteStatsService from '@/services/note-stats.service' @@ -13,6 +15,7 @@ import noteStatsService from '@/services/note-stats.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { ChevronDown, Loader2 } from 'lucide-react'
const SHOW_COUNT = 25
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
@ -26,7 +29,9 @@ let isInitializing = false @@ -26,7 +29,9 @@ let isInitializing = false
type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular'
export default function TrendingNotes() {
export type TrendingNotesVariant = 'page' | 'searchAccordion'
export default function TrendingNotes({ variant = 'page' }: { variant?: TrendingNotesVariant }) {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
@ -37,6 +42,7 @@ export default function TrendingNotes() { @@ -37,6 +42,7 @@ export default function TrendingNotes() {
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular')
const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([])
const [cacheLoading, setCacheLoading] = useState(false)
const [accordionOpen, setAccordionOpen] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const trendingRelaySource = useMemo<'favorites' | 'default'>(() => {
@ -294,65 +300,68 @@ export default function TrendingNotes() { @@ -294,65 +300,68 @@ export default function TrendingNotes() {
? t('Trending on Your Favorite Relays')
: t('Trending on the Default Relays')
return (
<div className="min-h-screen">
<div className="sticky top-12 z-30 border-b bg-background">
<div className="px-4 pb-3 pt-3">
<h2 className="text-lg font-bold leading-tight">{headerTitle}</h2>
</div>
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<span className="text-xs text-muted-foreground">{t('Sort')}:</span>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setSortOrder('newest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'newest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('newest')}
</button>
<button
type="button"
onClick={() => setSortOrder('oldest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'oldest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('oldest')}
</button>
<button
type="button"
onClick={() => setSortOrder('most-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'most-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('most popular')}
</button>
<button
type="button"
onClick={() => setSortOrder('least-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'least-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('least popular')}
</button>
</div>
</div>
const sortToolbar = (
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<span className="text-xs text-muted-foreground">{t('Sort')}:</span>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setSortOrder('newest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'newest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('newest')}
</button>
<button
type="button"
onClick={() => setSortOrder('oldest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'oldest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('oldest')}
</button>
<button
type="button"
onClick={() => setSortOrder('most-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'most-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('most popular')}
</button>
<button
type="button"
onClick={() => setSortOrder('least-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'least-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('least popular')}
</button>
</div>
</div>
)
const notesBody = (
<>
{cacheLoading && cacheEvents.length === 0 ? (
<div className="mt-8 text-center text-sm text-muted-foreground">
<div
className={
variant === 'searchAccordion'
? 'px-4 py-6 text-center text-sm text-muted-foreground'
: 'mt-8 text-center text-sm text-muted-foreground'
}
>
{t('Loading trending notes from your relays...')}
</div>
) : null}
@ -376,6 +385,45 @@ export default function TrendingNotes() { @@ -376,6 +385,45 @@ export default function TrendingNotes() {
) : (
<div className="mt-2 text-center text-sm text-muted-foreground">{t('no more notes')}</div>
)}
</>
)
if (variant === 'searchAccordion') {
return (
<Collapsible open={accordionOpen} onOpenChange={setAccordionOpen} className="min-w-0">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2.5 text-left hover:bg-muted/25">
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold leading-tight">{headerTitle}</span>
{cacheLoading && cacheEvents.length === 0 ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</span>
<ChevronDown
className={cn(
'size-5 shrink-0 text-muted-foreground transition-transform',
accordionOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-2 rounded-lg border border-border/60 bg-background">
<div className="border-b border-border/60 bg-muted/10">{sortToolbar}</div>
{notesBody}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className="min-h-screen">
<div className="sticky top-12 z-30 border-b bg-background">
<div className="px-4 pb-3 pt-3">
<h2 className="text-lg font-bold leading-tight">{headerTitle}</h2>
</div>
{sortToolbar}
</div>
{notesBody}
</div>
)
}

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

@ -72,7 +72,7 @@ const ExplorePage = forwardRef((_, ref) => { @@ -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) => { @@ -83,7 +83,7 @@ const ExplorePage = forwardRef((_, ref) => {
return (
<PrimaryPageLayout
ref={ref}
pageName="home"
pageName="explore"
titlebar={<ExplorePageTitlebar />}
subHeader={
<Tabs
@ -97,7 +97,7 @@ const ExplorePage = forwardRef((_, ref) => { @@ -97,7 +97,7 @@ const ExplorePage = forwardRef((_, ref) => {
setTab(next as TExploreTabs)
window.dispatchEvent(
new CustomEvent('pageTabChanged', {
detail: { page: 'home', tab: next }
detail: { page: 'explore', tab: next }
})
)
}}

4
src/pages/primary/MePage/index.tsx

@ -33,7 +33,7 @@ const MePage = forwardRef((_, ref) => { @@ -33,7 +33,7 @@ const MePage = forwardRef((_, ref) => {
return (
<PrimaryPageLayout
ref={ref}
pageName="home"
pageName="me"
titlebar={<MePageTitlebar />}
hideTitlebarBottomBorder
>
@ -47,7 +47,7 @@ const MePage = forwardRef((_, ref) => { @@ -47,7 +47,7 @@ const MePage = forwardRef((_, ref) => {
return (
<PrimaryPageLayout
ref={ref}
pageName="home"
pageName="me"
titlebar={<MePageTitlebar />}
hideTitlebarBottomBorder
>

10
src/pages/primary/SearchPage/index.tsx

@ -69,8 +69,14 @@ const SearchPage = forwardRef((_, ref) => { @@ -69,8 +69,14 @@ const SearchPage = forwardRef((_, ref) => {
</div>
</div>
<div className="h-4"></div>
{!searchParams && <LatestFromFollowsSection />}
<SearchResult searchParams={searchParams} />
{searchParams ? (
<SearchResult searchParams={searchParams} />
) : (
<div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection />
<SearchResult searchParams={null} />
</div>
)}
</div>
</PrimaryPageLayout>
)

11
src/pages/secondary/SearchPage/index.tsx

@ -125,9 +125,14 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -125,9 +125,14 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
</div>
</div>
<div className="h-4"></div>
{!searchParams && <LatestFromFollowsSection />}
<div className="text-xl font-semibold mb-4">Trending Notes</div>
<SearchResult searchParams={searchParams} />
{searchParams ? (
<SearchResult searchParams={searchParams} />
) : (
<div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection />
<SearchResult searchParams={null} />
</div>
)}
</div>
</SecondaryPageLayout>
)

2
src/providers/InterestListProvider.tsx

@ -7,7 +7,7 @@ import client from '@/services/client.service' @@ -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 = {

64
src/providers/NostrProvider/index.tsx

@ -35,7 +35,8 @@ import dayjs from 'dayjs' @@ -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' @@ -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<void>
nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string>
bunkerLogin: (bunker: string) => Promise<string>
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string>
removeAccount: (account: TAccountPointer) => void
/**
* Default publish the event to current relays, user's write relays and additional relays
*/
publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
attemptDelete: (targetEvent: Event) => Promise<void>
signHttpAuth: (url: string, method: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
}
const NostrContext = createContext<TNostrContext | undefined>(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()

70
src/providers/nostr-context.tsx

@ -0,0 +1,70 @@ @@ -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<void>
nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string>
bunkerLogin: (bunker: string) => Promise<string>
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string>
removeAccount: (account: TAccountPointer) => void
publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
attemptDelete: (targetEvent: Event) => Promise<void>
signHttpAuth: (url: string, method: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
}
export const NostrContext = createContext<TNostrContext | undefined>(undefined)
export function useNostr(): TNostrContext {
const context = useContext(NostrContext)
if (!context) {
throw new Error('useNostr must be used within a NostrProvider')
}
return context
}

5
vite.config.ts

@ -26,8 +26,9 @@ const getAppVersion = () => { @@ -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 {

Loading…
Cancel
Save