Browse Source

adjust page manager

imwald
Silberengel 1 month ago
parent
commit
d311be92bf
  1. 5
      src/PageManager.tsx
  2. 2
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  3. 309
      src/pages/primary/SpellsPage/index.tsx
  4. 275
      src/providers/NotificationProvider.tsx

5
src/PageManager.tsx

@ -17,7 +17,6 @@ import MuteListPage from '@/pages/secondary/MuteListPage'
import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage' import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage'
import SecondaryRelayPage from '@/pages/secondary/RelayPage' import SecondaryRelayPage from '@/pages/secondary/RelayPage'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider' import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { NotificationProvider } from '@/providers/NotificationProvider'
// DEPRECATED: useUserPreferences removed - double-panel functionality disabled // DEPRECATED: useUserPreferences removed - double-panel functionality disabled
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { import {
@ -1540,7 +1539,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}} }}
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider <PrimaryNoteViewContext.Provider
value={{ value={{
setPrimaryNoteView, setPrimaryNoteView,
@ -1653,7 +1651,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</Suspense> </Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>
</NotificationProvider>
</CurrentRelaysProvider> </CurrentRelaysProvider>
</SecondaryPageContext.Provider> </SecondaryPageContext.Provider>
</KeyboardShortcutsHelpProvider> </KeyboardShortcutsHelpProvider>
@ -1668,7 +1665,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}} }}
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider>
<PrimaryNoteViewContext.Provider <PrimaryNoteViewContext.Provider
value={{ value={{
setPrimaryNoteView, setPrimaryNoteView,
@ -1805,7 +1801,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</Suspense> </Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>
</NotificationProvider>
</CurrentRelaysProvider> </CurrentRelaysProvider>
</SecondaryPageContext.Provider> </SecondaryPageContext.Provider>
</KeyboardShortcutsHelpProvider> </KeyboardShortcutsHelpProvider>

2
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -47,7 +47,7 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF
} }
/** /**
* Mention/notification-shaped kinds only (aligned with `NotificationProvider`, plus zap receipts). * Mention/notification-shaped kinds only (aligned with global notification-shaped kinds, plus zap receipts).
* Not full {@link PROFILE_FEED_KINDS} that asked relays for huge multi-kind slices per `#p`. * Not full {@link PROFILE_FEED_KINDS} that asked relays for huge multi-kind slices per `#p`.
*/ */
export const NOTIFICATION_SPELL_KINDS = [ export const NOTIFICATION_SPELL_KINDS = [

309
src/pages/primary/SpellsPage/index.tsx

@ -65,6 +65,7 @@ import {
CalendarDays, CalendarDays,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft,
Copy, Copy,
FileText, FileText,
Gift, Gift,
@ -885,6 +886,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[favoriteIds] [favoriteIds]
) )
const spellsTitlebarTitle = useMemo(() => {
if (selectedFauxSpell) return t(fauxSpellLabelKey(selectedFauxSpell))
if (selectedSpell) return spellMenuLabel(selectedSpell)
return t('Spells')
}, [selectedFauxSpell, selectedSpell, spellMenuLabel, t])
const pickSpell = useCallback( const pickSpell = useCallback(
(spell: Event | null) => { (spell: Event | null) => {
if (spell) { if (spell) {
@ -1124,7 +1131,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
pageName="spells" pageName="spells"
titlebar={ titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> <div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="pl-3 text-lg font-semibold">{t('Spells')}</div> <div
className="min-w-0 flex-1 truncate pl-3 text-lg font-semibold"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} /> <RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button <Button
@ -1145,163 +1157,170 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
displayScrollToTopButton displayScrollToTopButton
> >
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4"> <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* Spell picker + actions above the feed */} {selectedFauxSpell ? (
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="flex shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5 -ml-2 h-9 text-muted-foreground hover:text-foreground"
onClick={clearSpellSelection}
>
<ChevronLeft className="size-4 shrink-0" aria-hidden />
<span>{t('Spells')}</span>
</Button>
</div>
) : (
<> <>
{isSmallScreen ? ( {/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<> <>
<Button {isSmallScreen ? (
type="button" <>
variant="outline" <Button
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md" type="button"
title={ variant="outline"
selectedFauxSpell className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
? t(fauxSpellLabelKey(selectedFauxSpell)) title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
: selectedSpell aria-haspopup="dialog"
? spellMenuLabel(selectedSpell) aria-expanded={spellPickerOpen}
: undefined onClick={() => setSpellPickerOpen(true)}
}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Drawer open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DrawerContent className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 p-0 sm:max-h-[75vh]">
<DrawerHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<DrawerTitle className="text-base">{t('Select a spell…')}</DrawerTitle>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
> >
{spellPickerList} <span className="truncate">
</div> {selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
</DrawerContent> </span>
</Drawer> <ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Drawer open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DrawerContent className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 p-0 sm:max-h-[75vh]">
<DrawerHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<DrawerTitle className="text-base">{t('Select a spell…')}</DrawerTitle>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
>
{spellPickerList}
</div>
</DrawerContent>
</Drawer>
</>
) : (
<DropdownMenu open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuTrigger asChild aria-haspopup="menu">
{spellPickerTriggerButton}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
showScrollButtons
className="max-h-[min(75vh,40rem)] w-[var(--radix-dropdown-menu-trigger-width)] max-w-md p-0"
>
<div className="sticky top-0 z-10 border-b bg-popover px-3 py-2 text-left text-sm font-semibold">
{t('Select a spell…')}
</div>
<div className="px-1 py-2" role="listbox" aria-label={t('Select a spell…')}>
{spellPickerList}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</> </>
) : (
<DropdownMenu open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuTrigger asChild aria-haspopup="menu">
{spellPickerTriggerButton}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
showScrollButtons
className="max-h-[min(75vh,40rem)] w-[var(--radix-dropdown-menu-trigger-width)] max-w-md p-0"
>
<div className="sticky top-0 z-10 border-b bg-popover px-3 py-2 text-left text-sm font-semibold">
{t('Select a spell…')}
</div>
<div className="px-1 py-2" role="listbox" aria-label={t('Select a spell…')}>
{spellPickerList}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
className="justify-start gap-2"
variant="outline"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
{selectedSpell && (
<>
<Button <Button
className="justify-start gap-2"
variant="outline" variant="outline"
size="icon" onClick={() => {
className="shrink-0" setSpellToEdit(null)
title={ setSpellToClone(null)
favoriteIds.has(selectedSpell.id) setCreateOpen(true)
? t('Remove from favorites') }}
: t('Add to favorites')
}
onClick={() => toggleFavorite(selectedSpell.id)}
> >
<Star <Wand2 className="size-4" />
className={`size-4 ${favoriteIds.has(selectedSpell.id) ? 'fill-amber-400 text-amber-500' : ''}`} {t('Create a Spell')}
/>
</Button> </Button>
<DropdownMenu> {selectedSpell && (
<DropdownMenuTrigger asChild> <>
<Button variant="outline" size="icon" className="shrink-0" title={t('More options')}> <Button
<MoreVertical className="size-4" /> variant="outline"
size="icon"
className="shrink-0"
title={
favoriteIds.has(selectedSpell.id)
? t('Remove from favorites')
: t('Add to favorites')
}
onClick={() => toggleFavorite(selectedSpell.id)}
>
<Star
className={`size-4 ${favoriteIds.has(selectedSpell.id) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button> </Button>
</DropdownMenuTrigger> <DropdownMenu>
<DropdownMenuContent align="end"> <DropdownMenuTrigger asChild>
{selectedSpellIsOwn ? ( <Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
<DropdownMenuItem <MoreVertical className="size-4" />
className="gap-2" </Button>
onClick={() => { </DropdownMenuTrigger>
setSpellToClone(null) <DropdownMenuContent align="end">
setSpellToEdit(selectedSpell) {selectedSpellIsOwn ? (
setCreateOpen(true) <DropdownMenuItem
}} className="gap-2"
> onClick={() => {
<Pencil className="size-4" /> setSpellToClone(null)
{t('Edit spell')} setSpellToEdit(selectedSpell)
</DropdownMenuItem> setCreateOpen(true)
) : ( }}
<DropdownMenuItem >
className="gap-2" <Pencil className="size-4" />
onClick={() => { {t('Edit spell')}
setSpellToEdit(null) </DropdownMenuItem>
setSpellToClone(selectedSpell) ) : (
setCreateOpen(true) <DropdownMenuItem
}} className="gap-2"
> onClick={() => {
<Copy className="size-4" /> setSpellToEdit(null)
{t('Clone spell')} setSpellToClone(selectedSpell)
</DropdownMenuItem> setCreateOpen(true)
)} }}
<DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}> >
<FileText className="size-4" /> <Copy className="size-4" />
{t('View definition')} {t('Clone spell')}
</DropdownMenuItem> </DropdownMenuItem>
{selectedSpellIsOwn ? ( )}
<> <DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}>
<DropdownMenuSeparator /> <FileText className="size-4" />
<DropdownMenuItem {t('View definition')}
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem> </DropdownMenuItem>
</> {selectedSpellIsOwn ? (
) : null} <>
</DropdownMenuContent> <DropdownMenuSeparator />
</DropdownMenu> <DropdownMenuItem
</> className="gap-2 text-destructive focus:text-destructive"
)} onClick={() => handleDeleteSpell(selectedSpell)}
</div> >
</div> <Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{spellsCatalogSyncing ? ( {spellsCatalogSyncing ? (
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p> <p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null} ) : null}
{spellsForSelect.length === 0 && !spellsCatalogSyncing && ( {spellsForSelect.length === 0 && !spellsCatalogSyncing && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p> <p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
</>
)} )}
{/* Feed — faux spells and kind-777 spells all use NoteList */} {/* Feed — faux spells and kind-777 spells all use NoteList */}

275
src/providers/NotificationProvider.tsx

@ -1,275 +0,0 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { compareEvents } from '@/lib/event'
import logger from '@/lib/logger'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import { kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool'
import { useEffect, useRef, useMemo } from 'react'
import { useNostr } from './NostrProvider'
/**
* Subscribes to live notifications and forwards new events via {@link client.emitNewEvent}.
* (Read/unread UI and cross-device seen at sync were removed.)
*/
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const { pubkey, relayList } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
const notificationBufferRef = useRef<NostrEvent[]>([])
const retryCountRef = useRef(0)
const retryTimeoutIdRef = useRef<NodeJS.Timeout | null>(null)
// Memoize relay URLs to prevent unnecessary re-subscriptions
// This creates stable references based on actual relay URLs, not object references
const userReadRelays = useMemo(() => {
const userRelayList = relayList || { read: [], write: [] }
return userRelayList.read || []
}, [relayList?.read?.join(',')]) // Compare by stringified array, not object reference
const userFavoriteRelays = useMemo(() => {
return favoriteRelays || []
}, [favoriteRelays?.join(',')]) // Compare by stringified array, not array reference
// Memoize the notification relays to prevent re-subscriptions when they haven't changed
const notificationRelays = useMemo(() => {
if (userReadRelays.length > 0) {
return userReadRelays.slice(0, 5)
} else if (userFavoriteRelays.length > 0) {
return userFavoriteRelays.slice(0, 5)
} else {
return FAST_READ_RELAY_URLS.slice(0, 5)
}
}, [userReadRelays, userFavoriteRelays])
useEffect(() => {
if (!pubkey) return
const deferredReset = setTimeout(() => {
notificationBufferRef.current = []
}, 0)
const isMountedRef = { current: true }
const subCloserRef: {
current: SubCloser | null
} = { current: null }
const topicSubCloserRef: {
current: SubCloser | null
} = { current: null }
const MAX_RETRIES = 5
// Reset retry count when effect runs (relays changed)
retryCountRef.current = 0
const subscribe = async () => {
// Clear any pending retries
if (retryTimeoutIdRef.current) {
clearTimeout(retryTimeoutIdRef.current)
retryTimeoutIdRef.current = null
}
if (subCloserRef.current) {
subCloserRef.current.close()
subCloserRef.current = null
}
if (topicSubCloserRef.current) {
topicSubCloserRef.current.close()
topicSubCloserRef.current = null
}
if (!isMountedRef.current) return null
try {
let eosed = false
// Reset retry count on successful subscription attempt
retryCountRef.current = 0
if (notificationRelays.length > 0) {
logger.component('NotificationProvider', 'Using notification relays', {
count: notificationRelays.length,
relays: notificationRelays.slice(0, 3)
})
}
let discussionEosed = false
let initialBufferFlushed = false
const flushBufferedIfReady = () => {
if (
!eosed ||
!discussionEosed ||
!isMountedRef.current ||
initialBufferFlushed
) {
return
}
initialBufferFlushed = true
const buf = notificationBufferRef.current
if (buf.length === 0) return
const sorted = [...buf].sort((a, b) => compareEvents(b, a))
notificationBufferRef.current = sorted.slice(0, 50)
for (const evt of sorted) {
client.emitNewEvent(evt)
}
}
const discussionSubCloser = client.subscribe(
notificationRelays,
[
{
kinds: [11],
limit: 20
}
],
{
oneose: (e) => {
if (e) {
discussionEosed = e
flushBufferedIfReady()
}
},
onevent: (evt) => {
if (evt.pubkey !== pubkey) {
const prev = notificationBufferRef.current
if (!discussionEosed) {
// Before EOSE: just buffer events, limit size
if (prev.length < 100) {
notificationBufferRef.current = [evt, ...prev]
}
return
}
if (prev.length && compareEvents(prev[0], evt) >= 0) {
return
}
// Limit buffer size to prevent memory issues
if (prev.length >= 50) {
notificationBufferRef.current = [evt, ...prev.slice(0, 49)]
} else {
notificationBufferRef.current = [evt, ...prev]
}
client.emitNewEvent(evt)
}
}
}
)
topicSubCloserRef.current = discussionSubCloser
const subCloser = client.subscribe(
notificationRelays,
[
{
kinds: [
kinds.ShortTextNote,
kinds.Repost,
kinds.Reaction,
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE
],
'#p': [pubkey],
limit: 20
}
],
{
oneose: (e) => {
if (e) {
eosed = e
// Don't sort on every EOSE - sorting is expensive and buffer is already maintained in order
// Only sort if buffer is getting large and out of order
if (notificationBufferRef.current.length > 100) {
notificationBufferRef.current = [
...notificationBufferRef.current.sort((a, b) => compareEvents(b, a))
]
}
flushBufferedIfReady()
}
},
onevent: (evt) => {
if (evt.pubkey !== pubkey) {
const prev = notificationBufferRef.current
if (!eosed) {
// Before EOSE: just buffer events, don't emit yet
// Limit buffer size to prevent memory issues
if (prev.length < 100) {
notificationBufferRef.current = [evt, ...prev]
}
return
}
// After EOSE: only emit if it's newer than the most recent event
if (prev.length && compareEvents(prev[0], evt) >= 0) {
return
}
// Limit buffer size to prevent memory issues
if (prev.length >= 50) {
notificationBufferRef.current = [evt, ...prev.slice(0, 49)]
} else {
notificationBufferRef.current = [evt, ...prev]
}
client.emitNewEvent(evt)
}
},
onAllClose: (reasons) => {
if (reasons.every((reason) => reason === 'closed by caller')) {
return
}
if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) {
retryCountRef.current++
const delay = Math.min(15_000 * retryCountRef.current, 60_000) // Exponential backoff, max 60s
logger.debug(`[NotificationProvider] Reconnecting after close (attempt ${retryCountRef.current}/${MAX_RETRIES})...`)
retryTimeoutIdRef.current = setTimeout(() => {
if (isMountedRef.current) {
subscribe()
}
}, delay)
} else if (retryCountRef.current >= MAX_RETRIES) {
logger.error('[NotificationProvider] Max retries reached, stopping reconnection attempts')
}
}
}
)
subCloserRef.current = subCloser
return subCloser
} catch (error) {
logger.error('Subscription error', { error, retryCount: retryCountRef.current })
if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) {
retryCountRef.current++
const delay = Math.min(5_000 * retryCountRef.current, 30_000) // Exponential backoff, max 30s
retryTimeoutIdRef.current = setTimeout(() => {
if (isMountedRef.current) {
subscribe()
}
}, delay)
} else if (retryCountRef.current >= MAX_RETRIES) {
logger.error('[NotificationProvider] Max retries reached, stopping subscription attempts')
}
return null
}
}
subscribe()
return () => {
clearTimeout(deferredReset)
if (retryTimeoutIdRef.current) {
clearTimeout(retryTimeoutIdRef.current)
retryTimeoutIdRef.current = null
}
retryCountRef.current = 0 // Reset retry count on cleanup
isMountedRef.current = false
if (subCloserRef.current) {
subCloserRef.current.close()
subCloserRef.current = null
}
if (topicSubCloserRef.current) {
topicSubCloserRef.current.close()
topicSubCloserRef.current = null
}
}
}, [pubkey, notificationRelays.join(',')]) // Use memoized notificationRelays instead of relayList/favoriteRelays
return <>{children}</>
}
Loading…
Cancel
Save