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

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

@ -47,7 +47,7 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF @@ -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`.
*/
export const NOTIFICATION_SPELL_KINDS = [

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

@ -65,6 +65,7 @@ import { @@ -65,6 +65,7 @@ import {
CalendarDays,
Check,
ChevronDown,
ChevronLeft,
Copy,
FileText,
Gift,
@ -885,6 +886,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -885,6 +886,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[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(
(spell: Event | null) => {
if (spell) {
@ -1124,7 +1131,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1124,7 +1131,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
pageName="spells"
titlebar={
<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">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
@ -1145,6 +1157,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1145,6 +1157,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{selectedFauxSpell ? (
<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>
) : (
<>
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<>
@ -1154,23 +1181,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1154,23 +1181,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
@ -1303,6 +1320,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1303,6 +1320,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
{spellsForSelect.length === 0 && !spellsCatalogSyncing && (
<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 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">

275
src/providers/NotificationProvider.tsx

@ -1,275 +0,0 @@ @@ -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