@ -15,12 +15,7 @@ import {
@@ -15,12 +15,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
Sheet ,
SheetContent ,
SheetHeader ,
SheetTitle
} from '@/components/ui/sheet'
import { Drawer , DrawerContent , DrawerHeader , DrawerTitle } from '@/components/ui/drawer'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
@ -28,9 +23,11 @@ import { usePrimaryPage } from '@/PageManager'
@@ -28,9 +23,11 @@ import { usePrimaryPage } from '@/PageManager'
import logger from '@/lib/logger'
import { showPublishingError } from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
@ -38,6 +35,7 @@ import storage from '@/services/local-storage.service'
@@ -38,6 +35,7 @@ import storage from '@/services/local-storage.service'
import { ExtendedKind , FAUX_SPELL_ORDER , PROFILE_FEED_KINDS } from '@/constants'
import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import {
buildSpellCatalogAuthors ,
getRelaysForSpell ,
@ -242,6 +240,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -242,6 +240,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const { navigate : navigatePrimary } = usePrimaryPage ( )
const { pubkey , relayList , attemptDelete , bookmarkListEvent , interestListEvent } = useNostr ( )
const { hideUntrustedNotifications } = useUserTrust ( )
const { isSmallScreen } = useScreenSize ( )
const { favoriteRelays , blockedRelays } = useFavoriteRelays ( )
const {
showKinds : kindFilterShowKinds ,
@ -320,6 +319,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -320,6 +319,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[ relayList ]
)
/** Content key only — `relayList` often gets a new object ref from NostrProvider; recomputing spell filters would re-run `resolveRelativeTime` (Date.now) and churn NoteList subscriptions. */
const relayListWriteKey = useMemo (
( ) = >
JSON . stringify (
[ . . . ( relayList ? . write ? ? [ ] ) ]
. map ( ( u ) = > normalizeUrl ( u ) || u )
. filter ( Boolean )
. sort ( )
) ,
[ relayList ]
)
useEffect ( ( ) = > {
loadSpells ( )
} , [ loadSpells ] )
@ -414,7 +425,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -414,7 +425,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if ( selectedFauxSpell === 'notifications' ) {
if ( ! pubkey ) return [ ]
const urls = fauxFavoriteRelayUrls ( favoriteRelays , blockedRelays )
const urls = notificationRelayUrls ( relayList , favoriteRelays , blockedRelays )
if ( ! urls . length ) return [ ]
return [ { urls , filter : buildMentionsSpellFilter ( pubkey ) } ]
}
@ -441,17 +452,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -441,17 +452,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}
if ( selectedFauxSpell === 'bookmarks' ) {
if ( ! pubkey ) return [ ]
const urls = notificationRelayUrls ( relayList , favoriteRelays )
const urls = notificationRelayUrls ( relayList , favoriteRelays , blockedRelays )
return buildBookmarksSubRequests ( bookmarkListEvent , urls )
}
if ( selectedFauxSpell === 'followPacks' ) {
return buildFollowPacksSubRequests ( )
}
return [ ]
// spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick
} , [
selectedFauxSpell ,
pubkey ,
relayList ,
spellCatalogRelayKey ,
favoriteRelays ,
blockedRelays ,
interestListEvent ,
@ -472,13 +484,32 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -472,13 +484,32 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const relays = getRelaysForSpell ( selectedSpell , { relayListWrite } )
if ( ! relays . length ) return [ ]
return [ { urls : relays , filter } ]
} , [ selectedSpell , pubkey , contacts , relayList ? . write ] )
// relayListWriteKey + contactsSyncKey: avoid recomputing when relayList/contacts are new refs with same contents (spell filters use Date.now via resolveRelativeTime)
} , [ selectedSpell , pubkey , contactsSyncKey , relayListWriteKey ] )
const subRequests = useMemo < TFeedSubRequest [ ] > ( ( ) = > {
if ( selectedFauxSpell ) return fauxSubRequests
return spellSubRequests
} , [ selectedFauxSpell , fauxSubRequests , spellSubRequests ] )
const spellBrowseRelayUrls = useMemo ( ( ) = > {
const set = new Set < string > ( )
for ( const req of subRequests ) {
for ( const u of req . urls ) {
const n = normalizeUrl ( u ) || u
if ( n ) set . add ( n )
}
}
return [ . . . set ]
} , [ subRequests ] )
const { addRelayUrls , removeRelayUrls } = useCurrentRelays ( )
useEffect ( ( ) = > {
if ( ! spellBrowseRelayUrls . length ) return
addRelayUrls ( spellBrowseRelayUrls )
return ( ) = > removeRelayUrls ( spellBrowseRelayUrls )
} , [ spellBrowseRelayUrls , addRelayUrls , removeRelayUrls ] )
const toggleFavorite = useCallback ( async ( spellId : string ) = > {
const ids = await indexedDb . getSpellFavoriteIds ( )
const set = new Set ( ids )
@ -645,72 +676,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -645,72 +676,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return t ( 'Nothing to load for this feed.' )
} , [ selectedFauxSpell , fauxSubRequests . length , t ] )
return (
< PrimaryPageLayout
ref = { ref }
pageName = "spells"
titlebar = {
< div className = "flex w-full items-center justify-between gap-2" >
< div className = "font-semibold" > { t ( 'Spells' ) } < / div >
< Button
variant = "ghost"
size = "titlebar-icon"
onClick = { ( ) = > {
setSpellToEdit ( null )
setSpellToClone ( null )
setCreateOpen ( true )
} }
title = { t ( 'Create a Spell' ) }
>
< Plus className = "size-5" / >
< / Button >
< / div >
}
displayScrollToTopButton
>
< div className = "flex min-h-0 flex-1 flex-col gap-4 p-4" >
{ /* Spell picker + actions above the feed */ }
< div className = "flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3" >
const spellPickerList = (
< >
< Button
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
}
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 >
< Sheet open = { spellPickerOpen } onOpenChange = { setSpellPickerOpen } >
< SheetContent
side = "bottom"
className = "flex max-h-[min(92dvh,40rem)] flex-col gap-0 rounded-t-2xl p-0 sm:max-h-[75vh]"
>
< SheetHeader className = "shrink-0 space-y-0 border-b px-4 py-3 text-left" >
< SheetTitle className = "text-base" > { t ( 'Select a spell…' ) } < / SheetTitle >
< / SheetHeader >
< div
className = "min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role = "listbox"
aria - label = { t ( 'Select a spell…' ) }
>
{ FAUX_SPELL_ORDER . map ( ( name ) = > {
if (
( name === 'notifications' ||
@ -837,9 +804,122 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
@@ -837,9 +804,122 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
) ) }
< / >
) : null }
< / >
)
const spellPickerTriggerButton = (
< Button
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
}
aria - expanded = { spellPickerOpen }
>
< 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 >
)
return (
< PrimaryPageLayout
ref = { ref }
pageName = "spells"
titlebar = {
< div className = "flex w-full items-center justify-between gap-2" >
< div className = "font-semibold" > { t ( 'Spells' ) } < / div >
< Button
variant = "ghost"
size = "titlebar-icon"
onClick = { ( ) = > {
setSpellToEdit ( null )
setSpellToClone ( null )
setCreateOpen ( true )
} }
title = { t ( 'Create a Spell' ) }
>
< Plus className = "size-5" / >
< / Button >
< / div >
< / SheetContent >
< / Sheet >
}
displayScrollToTopButton
>
< div className = "flex min-h-0 flex-1 flex-col gap-4 p-4" >
{ /* Spell picker + actions above the feed */ }
< div className = "flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3" >
< >
{ isSmallScreen ? (
< >
< Button
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
}
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 }
< / 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 >
) }
< / >
< div className = "flex shrink-0 flex-wrap items-center gap-2" >