import NoteList, { type TNoteListRef } from '@/components/NoteList' import StoredAccountSwitchSelect from '@/components/StoredAccountSwitchSelect' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { usePrimaryPage } from '@/contexts/primary-page-context' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useBookmarks } from '@/providers/bookmarks-context' import { useNostr } from '@/providers/NostrProvider' import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { dedupeFollowSetEventsByD } from '@/lib/follow-set-spell' import client, { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' import { filterEventsExcludingTombstones } from '@/lib/event' import { normalizeHexPubkey } from '@/lib/pubkey' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' import { buildSpellCatalogAuthors, getRelaysForSpellCatalogSync, getSpellName, isSpellEvent, SPELL_CATALOG_SYNC_LIMIT, SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS, SPELL_CATALOG_SYNC_TIMEOUT_MS } from '@/services/spell.service' import { ChevronDown, ChevronLeft, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react' import type { Event } from 'nostr-tools' import { verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import ProfileInteractionsMap from './ProfileInteractionsMap' import RelayThreadHeatMap from './RelayThreadHeatMap' import TopicKeywordHeatMap from './TopicKeywordHeatMap' import type { TPageRef } from '@/types' import { decodeFollowSetSpellId, decodeProfileInteractionsSpellId, fauxSpellLabelKey, getFollowSetDTag, isBuiltinFauxSpell, isFollowFeedFauxSpellId, isFollowSetSpellId, isFauxSpellPageParam, isProfileInteractionsSpellId, labelFollowSetEvent } from './fauxSpellConfig' import { SpellPickerContent } from './SpellPickerContent' import { useSpellsPageFeed } from './useSpellsPageFeed' const SpellsPage = forwardRef(function SpellsPage( { spell: spellProp }: { spell?: string }, ref ) { const { t } = useTranslation() const { navigate: navigatePrimary } = usePrimaryPage() const { pubkey, account, relayList, attemptDelete, bookmarkListEvent, interestListEvent, followListEvent } = useNostr() const { addBookmark, removeBookmark } = useBookmarks() const notificationThreadWatch = useNotificationThreadWatchOptional() const eventsIFollowListEvent = notificationThreadWatch?.eventsIFollowListEvent ?? null const eventsIMutedListEvent = notificationThreadWatch?.eventsIMutedListEvent ?? null const { isSmallScreen } = useScreenSize() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const { showKinds: kindFilterShowKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() const [spells, setSpells] = useState([]) /** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */ const [favoriteSpellIds, setFavoriteSpellIds] = useState([]) const [selectedSpell, setSelectedSpell] = useState(null) const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) const [followSetListEvents, setFollowSetListEvents] = useState([]) const [followSetCatalogLoading, setFollowSetCatalogLoading] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) const [spellToClone, setSpellToClone] = useState(null) const [definitionSpell, setDefinitionSpell] = useState(null) const [contacts, setContacts] = useState([]) const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) /** True while fetching kind 777 authored by the user from write relays into IndexedDB */ const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const spellCatalogCloserRef = useRef<(() => void) | null>(null) /** Bumps spell catalog relay re-sync when the user taps refresh in the titlebar. */ const [spellCatalogManualRefreshKey, setSpellCatalogManualRefreshKey] = useState(0) /** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */ const spellCatalogLastManualKeyRef = useRef(0) const spellFeedListRef = useRef(null) const selectedFauxSpellRefreshRef = useRef(null) selectedFauxSpellRefreshRef.current = selectedFauxSpell const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0) const [profileInteractionsRefreshKey, setProfileInteractionsRefreshKey] = useState(0) const layoutRef = useRef(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) /** Monotonic token + wall time for spell-feed latency instrumentation (picker → first rows). */ const spellFeedInstrTokenRef = useRef(0) const spellFeedInstrT0Ref = useRef(0) const spellFeedInstrLabelRef = useRef('') const [spellFeedInstrumentToken, setSpellFeedInstrumentToken] = useState(0) const [followSetManualRefreshKey, setFollowSetManualRefreshKey] = useState(0) /** Notifications `#p` + mention filter track the active session; changing the dropdown calls {@link switchAccount}. */ const notificationsFeedPubkey = useMemo(() => { const cur = pubkey?.trim() return cur ? normalizeHexPubkey(cur) : null }, [pubkey]) const logSpellFeedPickerSelection = useCallback((label: string, extra?: Record) => { spellFeedInstrT0Ref.current = performance.now() spellFeedInstrLabelRef.current = label spellFeedInstrTokenRef.current += 1 const instrumentToken = spellFeedInstrTokenRef.current setSpellFeedInstrumentToken(instrumentToken) logger.info('[SpellsPage] Spell feed — picker selection', { label, instrumentToken, ...extra }) }, []) const urlFauxSpellInstrumentedRef = useRef(null) /** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */ const fauxSpellUrlSyncFromPickerRef = useRef(null) useEffect(() => { if (spellProp === 'favorites') { navigatePrimary('spells', { spell: 'heatMap' }) return } if (spellProp && isFauxSpellPageParam(spellProp)) { if (fauxSpellUrlSyncFromPickerRef.current === spellProp) { fauxSpellUrlSyncFromPickerRef.current = null urlFauxSpellInstrumentedRef.current = spellProp setSelectedFauxSpell(spellProp) setSelectedSpell(null) return } if (urlFauxSpellInstrumentedRef.current === spellProp) return urlFauxSpellInstrumentedRef.current = spellProp logSpellFeedPickerSelection(`faux:${spellProp} (from URL)`, { fauxSpell: spellProp, fromUrl: true }) setSelectedFauxSpell(spellProp) setSelectedSpell(null) } else { urlFauxSpellInstrumentedRef.current = null // URL / props no longer name a faux spell (e.g. bottom bar “Spells” → `/spells`) — leave the feed. setSelectedFauxSpell(null) } }, [spellProp, logSpellFeedPickerSelection, navigatePrimary]) const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ indexedDb.getSpellEvents(), indexedDb.getSpellFavoriteIds() ]) setSpells(events) setFavoriteSpellIds(ids) }, []) const refreshSpellsFeedAndCatalog = useCallback(() => { void loadSpells() if (pubkey) { setSpellCatalogManualRefreshKey((k) => k + 1) setFollowSetManualRefreshKey((k) => k + 1) } if (selectedFauxSpellRefreshRef.current === 'heatMap') { setHeatMapRefreshKey((k) => k + 1) } if (selectedFauxSpellRefreshRef.current === 'topicMap') { setTopicMapRefreshKey((k) => k + 1) } if (isProfileInteractionsSpellId(selectedFauxSpellRefreshRef.current)) { setProfileInteractionsRefreshKey((k) => k + 1) } spellFeedListRef.current?.refresh() }, [loadSpells, pubkey]) useImperativeHandle( ref, () => ({ scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), refresh: refreshSpellsFeedAndCatalog }), [refreshSpellsFeedAndCatalog] ) const { relayMailboxStableKey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, subRequests, spellFeedSubscriptionKey, spellBrowseRelayUrlsKey, showKinds, fauxNoteListUseFilterAsIs, spellFauxMergeTimeline, notificationsMentionExtraHide, hideRepliesFollowing, fauxSubRequests, NOTIFICATION_SPELL_LOADING_SAFETY_MS, NOTIFICATION_SPELL_KINDS } = useSpellsPageFeed({ selectedFauxSpell, selectedSpell, pubkey, relayList, favoriteRelays, blockedRelays, notificationsFeedPubkey, interestListEvent, bookmarkListEvent, followListEvent, contacts, contactsSyncKey, followSetListEvents, followSetCatalogLoading, kindFilterShowKinds, notificationEventsIFollowListEvent: eventsIFollowListEvent, notificationEventsIMutedListEvent: eventsIMutedListEvent }) useEffect(() => { if (!pubkey) { setFollowSetListEvents([]) setFollowSetCatalogLoading(false) return } let cancelled = false setFollowSetCatalogLoading(true) void (async () => { try { const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) if (!feedUrls.length) { if (!cancelled) setFollowSetListEvents([]) return } const [events, tombstones] = await Promise.all([ queryService.fetchEvents( feedUrls, { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } ), indexedDb.getAllTombstones() ]) if (!cancelled) { setFollowSetListEvents(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) } } catch { if (!cancelled) setFollowSetListEvents([]) } finally { if (!cancelled) setFollowSetCatalogLoading(false) } })() return () => { cancelled = true } }, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey, favoriteRelays, blockedRelays, relayList]) useEffect(() => { const onTombstones = () => setFollowSetManualRefreshKey((k) => k + 1) window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) }, []) /** * Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer * this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL. */ useEffect(() => { let cancelled = false const run = () => { if (!cancelled) void loadSpells() } let idleId: number | undefined let timeoutId: ReturnType | undefined if (spellProp?.trim()) { if (typeof requestIdleCallback !== 'undefined') { idleId = requestIdleCallback(run, { timeout: 400 }) } else { timeoutId = setTimeout(run, 0) } } else { run() } return () => { cancelled = true if (idleId !== undefined) cancelIdleCallback(idleId) if (timeoutId !== undefined) clearTimeout(timeoutId) } }, [loadSpells, spellProp]) /** * Pull kind 777 from relays only when IndexedDB has no spells yet, or when the user requests refresh. * Otherwise the picker uses {@link loadSpells} from cache only (no extra REQ on each visit / relay churn). */ useEffect(() => { if (!pubkey) { setSpellsCatalogSyncing(false) return } let cancelled = false spellCatalogCloserRef.current = null let loadSpellsDebounce: ReturnType | null = null let delayId: ReturnType | null = null let syncTimeout: ReturnType | null = null let afterFirstBatchTimer: ReturnType | null = null const clearAfterFirstBatchTimer = () => { if (afterFirstBatchTimer != null) { clearTimeout(afterFirstBatchTimer) afterFirstBatchTimer = null } } const scheduleLoadSpells = () => { if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) loadSpellsDebounce = setTimeout(() => { loadSpellsDebounce = null if (!cancelled) void loadSpells() }, 120) } void (async () => { const manualBump = spellCatalogManualRefreshKey !== spellCatalogLastManualKeyRef.current if (manualBump) { spellCatalogLastManualKeyRef.current = spellCatalogManualRefreshKey } /** Avoid relay catalog SUB on every Spells visit; sync when the picker opens or user refreshes. */ if (!manualBump && !spellPickerOpen) { return } const idbSpellsP = indexedDb.getSpellEvents() if (!manualBump) { const cachedSpells = await idbSpellsP if (cancelled) return if (cachedSpells.length > 0) { return } } const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [], useGlobalRelayBootstrap }) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const authorAllowlist = new Set(catalogAuthors) const filter = { kinds: [ExtendedKind.SPELL], authors: catalogAuthors, limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT } syncTimeout = setTimeout(() => { if (cancelled) return logger.warn('[SpellsPage] Spell catalog sync timed out') spellCatalogCloserRef.current?.() spellCatalogCloserRef.current = null setSpellsCatalogSyncing(false) }, SPELL_CATALOG_SYNC_TIMEOUT_MS) let catalogSyncDone = false /** Catalog sync runs in parallel with the open feed; avoid an artificial delay. */ const catalogDelayMs = 0 if (cancelled) return delayId = setTimeout(() => { if (cancelled) return void (async () => { try { setSpellsCatalogSyncing(true) const { closer } = await client.subscribeTimeline( [{ urls, filter }], { onEvents: async (events, eosed) => { if (cancelled) return let wrote = false for (const ev of events) { if (cancelled) return if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue try { await indexedDb.putSpellEvent(ev) wrote = true } catch (e) { logger.warn('[SpellsPage] Failed to cache spell from relay', e) } } if (wrote) scheduleLoadSpells() if (wrote && afterFirstBatchTimer == null) { afterFirstBatchTimer = setTimeout(() => { afterFirstBatchTimer = null if (cancelled || catalogSyncDone) return catalogSyncDone = true if (syncTimeout != null) clearTimeout(syncTimeout) if (loadSpellsDebounce != null) { clearTimeout(loadSpellsDebounce) loadSpellsDebounce = null } void (async () => { if (!cancelled) await loadSpells() if (!cancelled) setSpellsCatalogSyncing(false) })() closer() spellCatalogCloserRef.current = null }, FIRST_RELAY_RESULT_GRACE_MS) } if (eosed) { clearAfterFirstBatchTimer() if (cancelled || catalogSyncDone) return catalogSyncDone = true if (syncTimeout != null) clearTimeout(syncTimeout) if (loadSpellsDebounce != null) { clearTimeout(loadSpellsDebounce) loadSpellsDebounce = null } if (!cancelled) await loadSpells() if (!cancelled) setSpellsCatalogSyncing(false) closer() spellCatalogCloserRef.current = null } }, onNew: () => {} // Not needed }, { firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS } ) if (cancelled) { closer() return } spellCatalogCloserRef.current = closer } catch (e) { if (syncTimeout != null) clearTimeout(syncTimeout) logger.warn('[SpellsPage] Spell catalog subscribe failed', e) if (!cancelled) setSpellsCatalogSyncing(false) } })() }, catalogDelayMs) })() return () => { cancelled = true clearAfterFirstBatchTimer() if (delayId != null) clearTimeout(delayId) if (syncTimeout != null) clearTimeout(syncTimeout) if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) spellCatalogCloserRef.current?.() spellCatalogCloserRef.current = null setSpellsCatalogSyncing(false) } }, [ pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, loadSpells, contactsSyncKey, spellCatalogManualRefreshKey, spellPickerOpen, useGlobalRelayBootstrap ]) useEffect(() => { if (!pubkey) { setContacts([]) return } client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) }, [pubkey]) const { addRelayUrls, removeRelayUrls } = useCurrentRelays() useEffect(() => { if (!spellBrowseRelayUrlsKey) return const urls = spellBrowseRelayUrlsKey.split('|') addRelayUrls(urls) return () => removeRelayUrls(urls) }, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls]) const favoriteSpellSet = useMemo( () => new Set(favoriteSpellIds.map((id) => id.toLowerCase())), [favoriteSpellIds] ) const toggleFavoriteSpell = useCallback( async (spell: Event) => { const sid = spell.id const sidLower = sid.toLowerCase() const ids = await indexedDb.getSpellFavoriteIds() const exists = ids.some((id) => id.toLowerCase() === sidLower) if (!exists) { if (pubkey) { try { await addBookmark(spell) } catch (e) { logger.error('[SpellsPage] addBookmark for starred spell failed', e) showPublishingError(e instanceof Error ? e : new Error(String(e))) return } } const next = [sid, ...ids.filter((id) => id.toLowerCase() !== sidLower)] await indexedDb.setSpellFavoriteIds(next) setFavoriteSpellIds(next) return } if (pubkey) { try { await removeBookmark(spell) } catch (e) { logger.error('[SpellsPage] removeBookmark for starred spell failed', e) showPublishingError(e instanceof Error ? e : new Error(String(e))) return } } const next = ids.filter((id) => id.toLowerCase() !== sidLower) await indexedDb.setSpellFavoriteIds(next) setFavoriteSpellIds(next) }, [pubkey, addBookmark, removeBookmark] ) const handleDeleteSpell = useCallback( async (spell: Event) => { try { await attemptDelete(spell) } catch (e) { logger.error('Spell deletion publish failed', { error: e, spellId: spell.id }) showPublishingError(e instanceof Error ? e : new Error(String(e))) return } try { await indexedDb.deleteSpellEvent(spell.id) const ids = await indexedDb.getSpellFavoriteIds() const wasStarred = ids.some((id) => id.toLowerCase() === spell.id.toLowerCase()) if (pubkey && wasStarred) { try { await removeBookmark(spell) } catch (e) { logger.warn('[SpellsPage] removeBookmark after spell delete failed', e) } } await indexedDb.setSpellFavoriteIds(ids.filter((id) => id.toLowerCase() !== spell.id.toLowerCase())) if (selectedSpell?.id === spell.id) setSelectedSpell(null) await loadSpells() } catch (e) { logger.error('Spell local cleanup after delete failed', { error: e, spellId: spell.id }) showPublishingError( e instanceof Error ? e : new Error(t('Failed to remove spell from local storage')) ) } }, [attemptDelete, loadSpells, pubkey, removeBookmark, selectedSpell?.id, t] ) const starredSpellsForPicker = useMemo(() => { const byId = new Map() for (const s of spells) { byId.set(s.id.toLowerCase(), s) } return favoriteSpellIds .map((id) => byId.get(id.toLowerCase())) .filter((s): s is Event => s != null) }, [spells, favoriteSpellIds]) const { ownSpells, followSpells, otherSpells, spellsForSelect } = useMemo(() => { const byName = (a: Event, b: Event) => getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) const followSet = new Set(contacts) const own: Event[] = [] const follow: Event[] = [] const other: Event[] = [] for (const s of spells) { if (favoriteSpellSet.has(s.id.toLowerCase())) continue if (pubkey && s.pubkey === pubkey) own.push(s) else if (followSet.has(s.pubkey)) follow.push(s) else other.push(s) } own.sort(byName) follow.sort(byName) other.sort(byName) return { ownSpells: own, followSpells: follow, otherSpells: other, spellsForSelect: [...own, ...follow, ...other] } }, [spells, pubkey, contacts, favoriteSpellSet]) const spellMenuLabel = useCallback( (spell: Event) => favoriteSpellSet.has(spell.id.toLowerCase()) ? `★ ${getSpellName(spell)}` : getSpellName(spell), [favoriteSpellSet] ) const selectedFauxSpellDisplayLabel = useMemo(() => { if (!selectedFauxSpell) return '' if (isProfileInteractionsSpellId(selectedFauxSpell)) { return t('Interactions map') } if (isFollowSetSpellId(selectedFauxSpell)) { const d = decodeFollowSetSpellId(selectedFauxSpell) if (!d) return t('Follow set') const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === d) return ev ? labelFollowSetEvent(ev) : d } if (isBuiltinFauxSpell(selectedFauxSpell)) { return t(fauxSpellLabelKey(selectedFauxSpell)) } return selectedFauxSpell }, [selectedFauxSpell, followSetListEvents, t]) const spellsTitlebarTitle = useMemo(() => { if (selectedFauxSpell) return selectedFauxSpellDisplayLabel if (selectedSpell) return spellMenuLabel(selectedSpell) return t('Spells') }, [selectedFauxSpell, selectedSpell, selectedFauxSpellDisplayLabel, spellMenuLabel, t]) const pickSpell = useCallback( (spell: Event | null) => { setSpellPickerOpen(false) if (spell && selectedSpell?.id === spell.id && !selectedFauxSpell) { return } if (spell) { logSpellFeedPickerSelection(`kind777:${getSpellName(spell)}`, { spellId: spell.id, spellAuthorPubkey: spell.pubkey, kind777: true }) } setSelectedSpell(spell) setSelectedFauxSpell(null) navigatePrimary('spells') }, [logSpellFeedPickerSelection, navigatePrimary, selectedSpell?.id, selectedFauxSpell] ) const clearSpellSelection = useCallback(() => { logSpellFeedPickerSelection('(cleared)', { cleared: true }) setSelectedSpell(null) setSelectedFauxSpell(null) setSpellPickerOpen(false) navigatePrimary('spells') }, [logSpellFeedPickerSelection, navigatePrimary]) const pickFauxSpell = useCallback( (name: string | null) => { setSpellPickerOpen(false) if (name) { if (!isFauxSpellPageParam(name)) return // Re-selecting the same built-in feed from the picker should not clear + resubscribe (toggle used to call // pickFauxSpell(null) and wipe the timeline when the row was already selected). if (selectedFauxSpell === name && selectedSpell === null) { return } logSpellFeedPickerSelection(`faux:${name}`, { fauxSpell: name }) fauxSpellUrlSyncFromPickerRef.current = name setSelectedFauxSpell(name) setSelectedSpell(null) navigatePrimary('spells', { spell: name }) } else { logSpellFeedPickerSelection('(cleared faux)', { clearedFaux: true }) fauxSpellUrlSyncFromPickerRef.current = null setSelectedFauxSpell(null) setSelectedSpell(null) navigatePrimary('spells') } }, [logSpellFeedPickerSelection, navigatePrimary, selectedFauxSpell, selectedSpell] ) const canSignSpellActions = account != null && account.signerType !== 'npub' const selectedSpellIsAuthor = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) const selectedSpellCanEditOrDelete = selectedSpellIsAuthor && canSignSpellActions const handleSpellFeedFirstPaint = useCallback( (detail: { eventCount: number; firstEventId: string }) => { const elapsedMsSincePickerMs = Math.round(performance.now() - spellFeedInstrT0Ref.current) logger.info('[SpellsPage] Spell feed — first events rendered (list has rows)', { ...detail, eventCountMeaning: 'filtered visible rows (slice), not full relay buffer', elapsedMsSincePickerMs, selectionLabel: spellFeedInstrLabelRef.current, instrumentToken: spellFeedInstrTokenRef.current }) }, [] ) const fauxFeedEmptyMessage = useMemo(() => { if (!selectedFauxSpell || fauxSubRequests.length > 0) return null if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'bookmarks') return t('No NIP-51 bookmarks or web bookmarks yet.') if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) const spellStarAddTitle = t('Spell star add title') const spellStarRemoveTitle = t('Spell star remove title') const spellPickerPanel = useMemo( () => ( ), [ t, pubkey, selectedSpell, selectedFauxSpell, favoriteSpellSet, starredSpellsForPicker, ownSpells, followSpells, otherSpells, followSetListEvents, spellMenuLabel, spellStarAddTitle, spellStarRemoveTitle, pickSpell, pickFauxSpell, clearSpellSelection, toggleFavoriteSpell ] ) const spellPickerTriggerButton = ( ) return (
{spellsTitlebarTitle}
} displayScrollToTopButton >
{selectedFauxSpell ? (
) : ( <> {/* Spell picker + actions above the feed */}
<> {isSmallScreen ? ( <> {t('Select a spell…')}
{spellPickerPanel}
) : ( {spellPickerTriggerButton}
{t('Select a spell…')}
{spellPickerPanel}
)}
{selectedSpell && ( <> {selectedSpellCanEditOrDelete ? ( { setSpellToClone(null) setSpellToEdit(selectedSpell) setCreateOpen(true) }} > {t('Edit spell')} ) : ( { setSpellToEdit(null) setSpellToClone(selectedSpell) setCreateOpen(true) }} > {t('Clone spell')} )} setDefinitionSpell(selectedSpell)}> {t('View definition')} {selectedSpellCanEditOrDelete ? ( <> handleDeleteSpell(selectedSpell)} > {t('Delete')} ) : null} )}
{spellsCatalogSyncing ? (

{t('Loading spells from your relays…')}

) : null} {spellsForSelect.length === 0 && !spellsCatalogSyncing && (

{t('No spells yet. Create one with the button above.')}

)} )} {/* Feed — faux spells and kind-777 spells all use NoteList */}
{selectedFauxSpell === 'notifications' && !pubkey ? (
{t('Please log in to view notifications.')}
) : isFollowFeedFauxSpellId(selectedFauxSpell ?? '') && !pubkey ? (
{t('Please login to view following feed')}
) : selectedFauxSpell === 'bookmarks' && !pubkey ? (
{t('Please login to view bookmarks')}
) : selectedFauxSpell === 'heatMap' && !pubkey ? (
{t('Please login to view thread heat map')}
) : selectedFauxSpell === 'heatMap' && pubkey ? (
) : selectedFauxSpell === 'topicMap' ? (
) : isProfileInteractionsSpellId(selectedFauxSpell) ? (
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( <> {selectedFauxSpell === 'notifications' ? (
{notificationsFeedPubkey ? ( ) : null}
) : null}
) : selectedSpell ? ( subRequests.length > 0 ? ( ) : !pubkey && selectedSpell.tags.some( (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) ) ? (
{t('Log in to run this spell (it uses $me or $contacts).')}
) : (
{t( 'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.' )}
) ) : (
{t('Select a spell to view its feed.')}
)}
{ setCreateOpen(open) if (!open) { setSpellToEdit(null) setSpellToClone(null) } }} spellToEdit={spellToEdit} spellToClone={spellToClone} onSaved={(ev) => { void loadSpells() if (ev && spellToEdit && selectedSpell?.id === spellToEdit.id) { setSelectedSpell(ev) } if (ev && spellToClone && selectedSpell?.id === spellToClone.id) { setSelectedSpell(ev) } }} /> !open && setDefinitionSpell(null)}> {definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')} {definitionSpell && (
{definitionSpell.content?.trim() && (
{t('Description')}

{definitionSpell.content.trim()}

)}
{t('Tags')}
{definitionSpell.tags.map((tag, i) => (
{tag[0]}:
{tag.length > 1 ? tag.slice(1).join(', ') : '—'}
))}
id: {definitionSpell.id}
)}
) }) export default SpellsPage