You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1206 lines
47 KiB

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<TPageRef>(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<Event[]>([])
/** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */
const [favoriteSpellIds, setFavoriteSpellIds] = useState<string[]>([])
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<string | null>(null)
const [followSetListEvents, setFollowSetListEvents] = useState<Event[]>([])
const [followSetCatalogLoading, setFollowSetCatalogLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [spellToEdit, setSpellToEdit] = useState<Event | null>(null)
const [spellToClone, setSpellToClone] = useState<Event | null>(null)
const [definitionSpell, setDefinitionSpell] = useState<Event | null>(null)
const [contacts, setContacts] = useState<string[]>([])
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<TNoteListRef>(null)
const selectedFauxSpellRefreshRef = useRef<string | null>(null)
selectedFauxSpellRefreshRef.current = selectedFauxSpell
const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0)
const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0)
const [profileInteractionsRefreshKey, setProfileInteractionsRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(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<string, unknown>) => {
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<string | null>(null)
/** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */
const fauxSpellUrlSyncFromPickerRef = useRef<string | null>(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<typeof setTimeout> | 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<typeof setTimeout> | null = null
let delayId: ReturnType<typeof setTimeout> | null = null
let syncTimeout: ReturnType<typeof setTimeout> | null = null
let afterFirstBatchTimer: ReturnType<typeof setTimeout> | 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<string, Event>()
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(
() => (
<SpellPickerContent
t={t}
pubkey={pubkey}
selectedSpell={selectedSpell}
selectedFauxSpell={selectedFauxSpell}
favoriteSpellSet={favoriteSpellSet}
starredSpellsForPicker={starredSpellsForPicker}
ownSpells={ownSpells}
followSpells={followSpells}
otherSpells={otherSpells}
followSetListEvents={followSetListEvents}
spellMenuLabel={spellMenuLabel}
spellStarAddTitle={spellStarAddTitle}
spellStarRemoveTitle={spellStarRemoveTitle}
pickSpell={pickSpell}
pickFauxSpell={pickFauxSpell}
clearSpellSelection={clearSpellSelection}
toggleFavoriteSpell={toggleFavoriteSpell}
/>
),
[
t,
pubkey,
selectedSpell,
selectedFauxSpell,
favoriteSpellSet,
starredSpellsForPicker,
ownSpells,
followSpells,
otherSpells,
followSetListEvents,
spellMenuLabel,
spellStarAddTitle,
spellStarRemoveTitle,
pickSpell,
pickFauxSpell,
clearSpellSelection,
toggleFavoriteSpell
]
)
const spellPickerTriggerButton = (
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? selectedFauxSpellDisplayLabel
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
aria-expanded={spellPickerOpen}
>
<span className="truncate">
{selectedFauxSpell
? selectedFauxSpellDisplayLabel
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
)
return (
<PrimaryPageLayout
ref={layoutRef}
pageName="spells"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div
className="app-chrome-title min-w-0 flex-1 truncate pl-3"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
</div>
}
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">
<>
{isSmallScreen ? (
<>
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{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…')}
>
{spellPickerPanel}
</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…')}>
{spellPickerPanel}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
<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
variant="outline"
size="icon"
className="shrink-0"
title={
favoriteSpellSet.has(selectedSpell.id.toLowerCase())
? t('Spell star remove title')
: t('Spell star add title')
}
onClick={() => void toggleFavoriteSpell(selectedSpell)}
>
<Star
className={`size-4 ${favoriteSpellSet.has(selectedSpell.id.toLowerCase()) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{selectedSpellCanEditOrDelete ? (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToClone(null)
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Pencil className="size-4" />
{t('Edit spell')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(selectedSpell)
setCreateOpen(true)
}}
>
<Copy className="size-4" />
{t('Clone spell')}
</DropdownMenuItem>
)}
<DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" />
{t('View definition')}
</DropdownMenuItem>
{selectedSpellCanEditOrDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{spellsCatalogSyncing ? (
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null}
{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">
{selectedFauxSpell === 'notifications' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please log in to view notifications.')}
</div>
) : isFollowFeedFauxSpellId(selectedFauxSpell ?? '') && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view following feed')}
</div>
) : selectedFauxSpell === 'bookmarks' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')}
</div>
) : selectedFauxSpell === 'heatMap' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view thread heat map')}
</div>
) : selectedFauxSpell === 'heatMap' && pubkey ? (
<div className="min-h-0 min-w-0 flex-1">
<RelayThreadHeatMap followPubkeys={contacts} refreshKey={heatMapRefreshKey} />
</div>
) : selectedFauxSpell === 'topicMap' ? (
<div className="min-h-0 min-w-0 flex-1">
<TopicKeywordHeatMap refreshKey={topicMapRefreshKey} />
</div>
) : isProfileInteractionsSpellId(selectedFauxSpell) ? (
<div className="min-h-0 min-w-0 flex-1">
<ProfileInteractionsMap
pubkey={decodeProfileInteractionsSpellId(selectedFauxSpell)!}
refreshKey={profileInteractionsRefreshKey}
/>
</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? (
<>
{selectedFauxSpell === 'notifications' ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 px-1 pb-2 sm:justify-between">
{notificationsFeedPubkey ? (
<StoredAccountSwitchSelect className="min-w-0 flex-1 sm:max-w-[min(100%,20rem)]" />
) : null}
</div>
) : null}
<div className="min-h-0 min-w-0 flex-1">
<NoteList
ref={spellFeedListRef}
subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells"
preserveTimelineOnSubRequestsChange={spellFauxMergeTimeline}
mergeTimelineWhenSubRequestFiltersMatch={spellFauxMergeTimeline}
showKinds={
selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds
}
spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
timelineLoadingSafetyTimeoutMs={
selectedFauxSpell === 'notifications'
? NOTIFICATION_SPELL_LOADING_SAFETY_MS
: undefined
}
clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false}
showKind1OPs={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1OPs
: true
}
showKind1Replies={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1Replies
: true
}
showKind1111={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1111
: true
}
hideReplies={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? hideRepliesFollowing
: false
}
extraShouldHideEvent={
selectedFauxSpell === 'notifications' && notificationsFeedPubkey
? notificationsMentionExtraHide
: undefined
}
showPaymentAttestationAction={selectedFauxSpell === 'notifications'}
incomingPaymentRecipientPubkey={
selectedFauxSpell === 'notifications' ? notificationsFeedPubkey : null
}
/>
</div>
</>
) : selectedSpell ? (
subRequests.length > 0 ? (
<NoteList
ref={spellFeedListRef}
subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells"
showKinds={showKinds}
spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
useFilterAsIs
/>
) : !pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
) ? (
<div className="py-8 text-center text-muted-foreground">
{t('Log in to run this spell (it uses $me or $contacts).')}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
{t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.'
)}
</div>
)
) : (
<div className="py-8 text-center text-muted-foreground">
{t('Select a spell to view its feed.')}
</div>
)}
</div>
</div>
<CreateSpellDialog
open={createOpen}
onOpenChange={(open) => {
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)
}
}}
/>
<Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')}
</DialogTitle>
</DialogHeader>
{definitionSpell && (
<div className="space-y-4 text-sm">
{definitionSpell.content?.trim() && (
<div>
<div className="mb-1 font-medium text-muted-foreground">{t('Description')}</div>
<p className="whitespace-pre-wrap break-words">{definitionSpell.content.trim()}</p>
</div>
)}
<div>
<div className="mb-2 font-medium text-muted-foreground">{t('Tags')}</div>
<dl className="space-y-1.5 font-mono text-xs">
{definitionSpell.tags.map((tag, i) => (
<div key={i} className="flex flex-wrap gap-x-2 gap-y-0.5">
<dt className="shrink-0 text-muted-foreground">{tag[0]}:</dt>
<dd className="min-w-0 break-all">
{tag.length > 1 ? tag.slice(1).join(', ') : '—'}
</dd>
</div>
))}
</dl>
</div>
<div className="overflow-wrap-anywhere break-words text-xs text-muted-foreground">
<span className="font-medium">id:</span> <span className="break-all">{definitionSpell.id}</span>
</div>
</div>
)}
</DialogContent>
</Dialog>
</PrimaryPageLayout>
)
})
export default SpellsPage