Browse Source

remove mobile RSS feed button

implement spells
imwald
Silberengel 1 month ago
parent
commit
016a044b05
  1. 7
      src/PageManager.tsx
  2. 16
      src/components/BottomNavigationBar/SpellsButton.tsx
  3. 2
      src/components/BottomNavigationBar/index.tsx
  4. 21
      src/components/NoteList/index.tsx
  5. 15
      src/components/Sidebar/SpellsButton.tsx
  6. 2
      src/components/Sidebar/index.tsx
  7. 4
      src/constants.ts
  8. 49
      src/lib/draft-event.ts
  9. 1
      src/lib/link.ts
  10. 34
      src/pages/primary/NoteListPage/index.tsx
  11. 259
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  12. 211
      src/pages/primary/SpellsPage/index.tsx
  13. 25
      src/services/client.service.ts
  14. 82
      src/services/indexed-db.service.ts
  15. 177
      src/services/spell.service.ts

7
src/PageManager.tsx

@ -49,6 +49,7 @@ import ProfilePage from './pages/primary/ProfilePage' @@ -49,6 +49,7 @@ import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import DiscussionsPage from './pages/primary/DiscussionsPage'
import SpellsPage from './pages/primary/SpellsPage'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
@ -83,7 +84,8 @@ const PRIMARY_PAGE_REF_MAP = { @@ -83,7 +84,8 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
discussions: createRef<TPageRef>()
discussions: createRef<TPageRef>(),
spells: createRef<TPageRef>()
}
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
@ -96,7 +98,8 @@ const getPrimaryPageMap = () => ({ @@ -96,7 +98,8 @@ const getPrimaryPageMap = () => ({
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />,
spells: <SpellsPage ref={PRIMARY_PAGE_REF_MAP.spells} />
})
// Type for primary page names - use the return type of getPrimaryPageMap

16
src/components/BottomNavigationBar/SpellsButton.tsx

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
import { usePrimaryPage } from '@/PageManager'
import { Wand2 } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function SpellsButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem
active={current === 'spells' && display}
onClick={() => navigate('spells')}
>
<Wand2 />
</BottomNavigationBarItem>
)
}

2
src/components/BottomNavigationBar/index.tsx

@ -3,6 +3,7 @@ import HomeButton from './HomeButton' @@ -3,6 +3,7 @@ import HomeButton from './HomeButton'
import NotificationsButton from './NotificationsButton'
import DiscussionsButton from './DiscussionsButton'
import SearchButton from './SearchButton'
import SpellsButton from './SpellsButton'
import WriteButton from './WriteButton'
export default function BottomNavigationBar() {
@ -19,6 +20,7 @@ export default function BottomNavigationBar() { @@ -19,6 +20,7 @@ export default function BottomNavigationBar() {
<WriteButton />
<DiscussionsButton />
<HomeButton />
<SpellsButton />
<SearchButton />
<NotificationsButton />
</div>

21
src/components/NoteList/index.tsx

@ -52,7 +52,8 @@ const NoteList = forwardRef( @@ -52,7 +52,8 @@ const NoteList = forwardRef(
hideUntrustedNotes = false,
areAlgoRelays = false,
showRelayCloseReason = false,
pinnedEventIds = []
pinnedEventIds = [],
useFilterAsIs = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@ -65,6 +66,8 @@ const NoteList = forwardRef( @@ -65,6 +66,8 @@ const NoteList = forwardRef(
areAlgoRelays?: boolean
showRelayCloseReason?: boolean
pinnedEventIds?: string[]
/** When true, use filter from subRequests as-is (kinds, limit) instead of showKinds. For spell feeds. */
useFilterAsIs?: boolean
},
ref
) => {
@ -219,11 +222,13 @@ const NoteList = forwardRef( @@ -219,11 +222,13 @@ const NoteList = forwardRef(
const { closer, timelineKey } = await client.subscribeTimeline(
subRequests.map(({ urls, filter }) => ({
urls,
filter: {
...filter,
kinds: showKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
filter: useFilterAsIs
? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) }
: {
...filter,
kinds: showKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
})),
{
onEvents: (events, eosed) => {
@ -239,7 +244,7 @@ const NoteList = forwardRef( @@ -239,7 +244,7 @@ const NoteList = forwardRef(
}
},
onNew: (event) => {
if (!showKinds.includes(event.kind)) return
if (!useFilterAsIs && !showKinds.includes(event.kind)) return
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return
@ -303,7 +308,7 @@ const NoteList = forwardRef( @@ -303,7 +308,7 @@ const NoteList = forwardRef(
return () => {
promise.then((closer) => closer())
}
}, [subRequestsKey, refreshCount, showKinds, showKind1OPs, showKind1Replies, showKind1111])
}, [subRequestsKey, refreshCount, showKinds, showKind1OPs, showKind1Replies, showKind1111, useFilterAsIs])
useEffect(() => {
const options = {

15
src/components/Sidebar/SpellsButton.tsx

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { usePrimaryPage } from '@/PageManager'
import { Wand2 } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function SpellsButton() {
const { navigate, current, display } = usePrimaryPage()
const isActive = display && current === 'spells'
return (
<SidebarItem title="Spells" onClick={() => navigate('spells')} active={isActive}>
<Wand2 strokeWidth={3} />
</SidebarItem>
)
}

2
src/components/Sidebar/index.tsx

@ -9,6 +9,7 @@ import NotificationsButton from './NotificationButton' @@ -9,6 +9,7 @@ import NotificationsButton from './NotificationButton'
import PostButton from './PostButton'
import ProfileButton from './ProfileButton'
import RssButton from './RssButton'
import SpellsButton from './SpellsButton'
import SearchButton from './SearchButton'
import SettingsButton from './SettingsButton'
import PaneModeToggle from './PaneModeToggle'
@ -38,6 +39,7 @@ export default function PrimaryPageSidebar() { @@ -38,6 +39,7 @@ export default function PrimaryPageSidebar() {
<SearchButton />
<ProfileButton />
{showRssFeed && <RssButton />}
<SpellsButton />
<SettingsButton />
<PostButton />
</div>

4
src/constants.ts

@ -213,7 +213,9 @@ export const ExtendedKind = { @@ -213,7 +213,9 @@ export const ExtendedKind = {
/** NIP-52 Time-based calendar event */
CALENDAR_EVENT_TIME: 31923,
/** NIP-52 Calendar event RSVP */
CALENDAR_EVENT_RSVP: 31925
CALENDAR_EVENT_RSVP: 31925,
/** NIP-A7 Spells: portable relay query filters (kind 777) */
SPELL: 777
}
/** NIP-52 calendar event kinds (addressable by d-tag); use in isReplaceableEvent. */

49
src/lib/draft-event.ts

@ -589,6 +589,55 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf @@ -589,6 +589,55 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf
}
}
/** NIP-A7 spell (kind 777) draft params from Create Spell form. */
export type TSpellDraftParams = {
cmd: 'REQ' | 'COUNT'
content: string
name?: string
alt?: string
kinds: string[] // e.g. ['1', '6']
authors: string[]
ids: string[]
tagFilters: { letter: string; values: string[] }[] // e.g. { letter: 't', values: ['bitcoin'] }
limit: string
since: string
until: string
search: string
relays: string[]
topics: string[] // t tags for spell categorization
closeOnEose: boolean
}
export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
const tags: string[][] = [['cmd', params.cmd]]
if (params.name?.trim()) tags.push(['name', params.name.trim()])
if (params.alt?.trim()) tags.push(['alt', params.alt.trim()])
params.kinds.filter((k) => k.trim()).forEach((k) => tags.push(['k', k.trim()]))
if (params.authors.length) tags.push(['authors', ...params.authors])
if (params.ids.length) tags.push(['ids', ...params.ids])
params.tagFilters.forEach(({ letter, values }) => {
if (letter?.trim() && values.some((v) => v?.trim())) {
tags.push(['tag', letter.trim(), ...values.map((v) => v.trim()).filter(Boolean)])
}
})
if (params.limit.trim()) {
const n = parseInt(params.limit, 10)
if (!Number.isNaN(n)) tags.push(['limit', String(n)])
}
if (params.since.trim()) tags.push(['since', params.since.trim()])
if (params.until.trim()) tags.push(['until', params.until.trim()])
if (params.search.trim()) tags.push(['search', params.search.trim()])
if (params.relays.length) tags.push(['relays', ...params.relays])
params.topics.filter((t) => t?.trim()).forEach((t) => tags.push(['t', t.trim()]))
if (params.closeOnEose) tags.push(['close-on-eose'])
return {
kind: ExtendedKind.SPELL,
content: params.content?.trim() ?? '',
tags,
created_at: dayjs().unix()
}
}
export function createRssFeedListDraftEvent(feedUrls: string[]): TDraftEvent {
// Validate and sanitize feed URLs
const validUrls = feedUrls

1
src/lib/link.ts

@ -77,6 +77,7 @@ export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` @@ -77,6 +77,7 @@ export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
export const toMuteList = () => '/mutes'
export const toFollowPacks = () => '/follow-packs'
export const toSpells = () => '/spells'
export const toChachiChat = (relay: string, d: string) => {
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`

34
src/pages/primary/NoteListPage/index.tsx

@ -8,7 +8,7 @@ import { useFeed } from '@/providers/FeedProvider' @@ -8,7 +8,7 @@ import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TPageRef } from '@/types'
import { Info, Rss } from 'lucide-react'
import { Info } from 'lucide-react'
import React, {
Dispatch,
forwardRef,
@ -26,8 +26,7 @@ import AccountButton from '@/components/Titlebar/AccountButton' @@ -26,8 +26,7 @@ import AccountButton from '@/components/Titlebar/AccountButton'
import FollowingFeed from './FollowingFeed'
import RelaysFeed from './RelaysFeed'
import logger from '@/lib/logger'
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import storage from '@/services/local-storage.service'
import { usePrimaryNoteView } from '@/PageManager'
const NoteListPage = forwardRef((_, ref) => {
logger.debug('NoteListPage component rendering')
@ -147,25 +146,6 @@ function NoteListPageTitlebar({ @@ -147,25 +146,6 @@ function NoteListPageTitlebar({
}) {
const { isSmallScreen } = useScreenSize()
const { setPrimaryNoteView } = usePrimaryNoteView()
const { navigate, current } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const showRssFeed = storage.getShowRssFeed()
const handleRssClick = (e: React.MouseEvent) => {
e.stopPropagation()
// Navigate to home if not already there
if (current !== 'home' || primaryViewType !== null) {
navigate('home')
// Wait a bit for navigation to complete, then switch to RSS
setTimeout(() => {
window.dispatchEvent(new CustomEvent('switchToRssFeed'))
}, 100)
} else {
// Already on home, just switch to RSS tab
window.dispatchEvent(new CustomEvent('switchToRssFeed'))
}
}
return (
<div className="relative flex gap-1 items-center h-full justify-between">
<div className="flex gap-1 items-center">
@ -188,16 +168,6 @@ function NoteListPageTitlebar({ @@ -188,16 +168,6 @@ function NoteListPageTitlebar({
</div>
)}
<div className="shrink-0 flex gap-1 items-center">
{isSmallScreen && showRssFeed && (
<Button
variant="ghost"
size="titlebar-icon"
onClick={handleRssClick}
title="RSS Feed"
>
<Rss />
</Button>
)}
{setShowRelayDetails && (
<Button
variant="ghost"

259
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -0,0 +1,259 @@ @@ -0,0 +1,259 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import indexedDb from '@/services/indexed-db.service'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import logger from '@/lib/logger'
const DEFAULT_PARAMS: TSpellDraftParams = {
cmd: 'REQ',
content: '',
name: '',
alt: '',
kinds: ['1'],
authors: ['$me', '$contacts'],
ids: [],
tagFilters: [],
limit: '50',
since: '7d',
until: '',
search: '',
relays: [],
topics: [],
closeOnEose: false
}
export default function CreateSpellDialog({
open,
onOpenChange,
onSaved
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSaved?: () => void
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS)
const [saving, setSaving] = useState(false)
const handleClear = () => setForm({ ...DEFAULT_PARAMS })
const handleCancel = () => {
handleClear()
onOpenChange(false)
}
const handleSave = async () => {
if (!pubkey) {
checkLogin()
return
}
setSaving(true)
try {
const draft = createSpellDraftEvent(form)
const event = await publish(draft)
await indexedDb.putSpellEvent(event)
handleClear()
onOpenChange(false)
onSaved?.()
showSimplePublishSuccess(t('Spell published'))
} catch (e) {
logger.error('[CreateSpellDialog] Publish failed', e)
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setSaving(false)
}
}
const kindsStr = form.kinds.length ? form.kinds.join(', ') : ''
const setKindsStr = (s: string) =>
setForm((f) => ({ ...f, kinds: s.split(/[\s,]+/).filter(Boolean) }))
const authorsStr = form.authors.join(', ')
const setAuthorsStr = (s: string) =>
setForm((f) => ({ ...f, authors: s.split(/[\s,]+/).filter(Boolean) }))
const idsStr = form.ids.join(', ')
const setIdsStr = (s: string) =>
setForm((f) => ({ ...f, ids: s.split(/[\s,]+/).filter(Boolean) }))
const relaysStr = form.relays.join(', ')
const setRelaysStr = (s: string) =>
setForm((f) => ({ ...f, relays: s.split(/[\s,]+/).filter(Boolean) }))
const topicsStr = form.topics.join(', ')
const setTopicsStr = (s: string) =>
setForm((f) => ({ ...f, topics: s.split(/[\s,]+/).filter(Boolean) }))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto max-w-2xl" withoutClose>
<DialogHeader className="flex flex-row items-center justify-between gap-2 pr-8">
<DialogTitle>{t('Create a Spell')}</DialogTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.')}
</p>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>{t('Command')}</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={form.cmd}
onChange={(e) => setForm((f) => ({ ...f, cmd: e.target.value as 'REQ' | 'COUNT' }))}
>
<option value="REQ">REQ (subscribe to events)</option>
<option value="COUNT">COUNT (count only)</option>
</select>
<p className="text-xs text-muted-foreground">{t('REQ returns a feed; COUNT returns a number.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Name')}</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder={t('Human-readable spell name')}
/>
</div>
<div className="grid gap-2">
<Label>{t('Description (content)')}</Label>
<Textarea
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder={t('Plain text description of the query')}
rows={2}
/>
</div>
<div className="grid gap-2">
<Label>{t('Kinds')}</Label>
<Input
value={kindsStr}
onChange={(e) => setKindsStr(e.target.value)}
placeholder="e.g. 1, 6, 7"
/>
<p className="text-xs text-muted-foreground">{t('Comma-separated kind numbers (e.g. 1 for notes).')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Authors')}</Label>
<Input
value={authorsStr}
onChange={(e) => setAuthorsStr(e.target.value)}
placeholder="$me, $contacts, or npub1..."
/>
<p className="text-xs text-muted-foreground">{t('$me = your pubkey, $contacts = your follow list. Comma-separated.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Event IDs (ids)')}</Label>
<Input
value={idsStr}
onChange={(e) => setIdsStr(e.target.value)}
placeholder={t('Comma-separated event ids')}
/>
</div>
<div className="grid gap-2">
<Label>{t('Limit')}</Label>
<Input
type="number"
value={form.limit}
onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))}
placeholder="50"
/>
</div>
<div className="grid gap-2">
<Label>{t('Since')}</Label>
<Input
value={form.since}
onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))}
placeholder="7d or 1704067200 or now"
/>
<p className="text-xs text-muted-foreground">{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Until')}</Label>
<Input
value={form.until}
onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))}
placeholder={t('Optional')}
/>
</div>
<div className="grid gap-2">
<Label>{t('Search (NIP-50)')}</Label>
<Input
value={form.search}
onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))}
placeholder={t('Full-text search query')}
/>
</div>
<div className="grid gap-2">
<Label>{t('Relays')}</Label>
<Input
value={relaysStr}
onChange={(e) => setRelaysStr(e.target.value)}
placeholder="wss://relay.example.com, ..."
/>
<p className="text-xs text-muted-foreground">{t('Leave empty to use your read relays.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Topics (t tags for categorization)')}</Label>
<Input
value={topicsStr}
onChange={(e) => setTopicsStr(e.target.value)}
placeholder={t('Comma-separated topics')}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="close-on-eose"
checked={form.closeOnEose}
onChange={(e) => setForm((f) => ({ ...f, closeOnEose: e.target.checked }))}
/>
<Label htmlFor="close-on-eose">{t('Close subscription after EOSE')}</Label>
</div>
</div>
<div className="flex flex-wrap gap-2 justify-end pt-2 border-t">
<Button variant="outline" onClick={handleClear}>
{t('Clear')}
</Button>
<Button variant="outline" onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t('Saving…') : t('Save')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

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

@ -0,0 +1,211 @@ @@ -0,0 +1,211 @@
import NoteList from '@/components/NoteList'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
getRelaysForSpell,
getSpellName,
spellEventToFilter,
spellIsCount
} from '@/services/spell.service'
import { TFeedSubRequest } from '@/types'
import { ChevronLeft, Plus, Wand2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types'
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const { isSmallScreen } = useScreenSize()
const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [contacts, setContacts] = useState<string[]>([])
const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([
indexedDb.getSpellEvents(),
indexedDb.getSpellFavoriteIds()
])
setSpells(events)
setFavoriteIds(new Set(ids))
}, [])
useEffect(() => {
loadSpells()
}, [loadSpells])
useEffect(() => {
if (!pubkey) {
setContacts([])
return
}
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([]))
}, [pubkey])
useEffect(() => {
if (!selectedSpell) {
setSubRequests([])
return
}
if (spellIsCount(selectedSpell)) {
setSubRequests([])
return
}
const defaultRelays = [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays
const ctx = {
pubkey,
contacts,
relayListRead
}
const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) {
setSubRequests([])
return
}
const relays = getRelaysForSpell(selectedSpell, { relayListRead })
if (!relays.length) {
setSubRequests([])
return
}
setSubRequests([{ urls: relays, filter }])
}, [selectedSpell, pubkey, contacts, relayList?.read])
const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids)
if (set.has(spellId)) set.delete(spellId)
else set.add(spellId)
await indexedDb.setSpellFavoriteIds([...set])
setFavoriteIds(set)
}, [])
const orderedSpells = [...spells].sort((a, b) => {
const aFav = favoriteIds.has(a.id)
const bFav = favoriteIds.has(b.id)
if (aFav && !bFav) return -1
if (!aFav && bFav) return 1
return (b.created_at ?? 0) - (a.created_at ?? 0)
})
return (
<PrimaryPageLayout
ref={ref}
pageName="spells"
titlebar={
isSmallScreen ? (
<div className="flex items-center justify-between w-full gap-2">
{selectedSpell ? (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setSelectedSpell(null)}
title={t('Back to spell list')}
>
<ChevronLeft className="size-5" />
</Button>
) : (
<div className="w-10 shrink-0" />
)}
<div className="font-semibold flex-1 text-center min-w-0 truncate">
{selectedSpell ? getSpellName(selectedSpell) : t('Spells')}
</div>
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setCreateOpen(true)}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
) : (
<div className="font-semibold">{t('Spells')}</div>
)
}
displayScrollToTopButton
>
<div className="flex flex-col md:flex-row min-h-0 flex-1 gap-4 p-4">
{/* Left (desktop) / Top (mobile) pane: spell list */}
<div className={`flex flex-col gap-2 shrink-0 ${isSmallScreen ? 'order-1 border-b border-border pb-4' : 'w-64 border-r border-border pr-4'}`}>
<Button
className="w-full justify-start gap-2"
variant="outline"
onClick={() => setCreateOpen(true)}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
<div className="text-sm text-muted-foreground mt-1">
{t('Select a spell to run its filter and see the feed.')}
</div>
<ul className="space-y-1 overflow-y-auto min-h-0">
{orderedSpells.length === 0 && (
<li className="text-sm text-muted-foreground py-2">{t('No spells yet. Create one above.')}</li>
)}
{orderedSpells.map((spell) => (
<li key={spell.id} className="flex items-center gap-1">
<button
type="button"
className={`flex-1 text-left text-sm px-2 py-1.5 rounded truncate ${selectedSpell?.id === spell.id ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted'}`}
onClick={() => setSelectedSpell(spell)}
>
{getSpellName(spell)}
</button>
<button
type="button"
className="shrink-0 p-1 text-muted-foreground hover:text-foreground"
onClick={() => toggleFavorite(spell.id)}
title={favoriteIds.has(spell.id) ? t('Remove from favorites') : t('Add to favorites')}
>
{favoriteIds.has(spell.id) ? '★' : '☆'}
</button>
</li>
))}
</ul>
</div>
{/* Right (desktop) / Bottom (mobile) pane: feed */}
<div className="flex-1 min-w-0 flex flex-col">
{selectedSpell ? (
subRequests.length > 0 ? (
<NoteList
subRequests={subRequests}
showKinds={selectedSpell.tags.filter((t) => t[0] === 'k').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) || [1]}
useFilterAsIs
/>
) : spellIsCount(selectedSpell) ? (
<div className="text-muted-foreground py-8 text-center">{t('COUNT spells show a number, not a feed.')}</div>
) : !pubkey && (selectedSpell.tags.some((t) => t[0] === 'authors' && (t.includes('$me') || t.includes('$contacts')))) ? (
<div className="text-muted-foreground py-8 text-center">{t('Log in to run this spell (it uses $me or $contacts).')}</div>
) : (
<div className="text-muted-foreground py-8 text-center">
{t('Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.')}
</div>
)
) : (
<div className="text-muted-foreground py-8 text-center">{t('Select a spell from the list to view its feed.')}</div>
)}
</div>
</div>
<CreateSpellDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSaved={loadSpells}
/>
</PrimaryPageLayout>
)
})
export default SpellsPage

25
src/services/client.service.ts

@ -1113,7 +1113,14 @@ class ClientService extends EventTarget { @@ -1113,7 +1113,14 @@ class ClientService extends EventTarget {
let events: NEvent[] = []
let eosedAt: number | null = null
let initialBatchScheduled = false
const PROGRESSIVE_DELAY_MS = 150
const PROGRESSIVE_DELAY_MS = 0
const PROGRESSIVE_INTERVAL_MS = 200
let progressiveIntervalId: ReturnType<typeof setInterval> | null = null
const deliverProgressive = () => {
if (eosedAt || events.length === 0) return
const snap = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
onEvents(needSort ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false)
}
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
startLogin,
onevent: (evt: NEvent) => {
@ -1121,13 +1128,13 @@ class ClientService extends EventTarget { @@ -1121,13 +1128,13 @@ class ClientService extends EventTarget {
// not eosed yet, push to events
if (!eosedAt) {
events.push(evt)
// Deliver first batch quickly so UI (e.g. notifications) doesn't wait for all relays to EOSE
// Deliver first batch quickly so UI doesn't wait for all relays to EOSE
if (needSort && events.length > 0 && !initialBatchScheduled) {
initialBatchScheduled = true
setTimeout(() => {
if (!eosedAt && events.length > 0) {
const snap = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
onEvents(snap.concat(cachedEvents).slice(0, filter.limit), false)
deliverProgressive()
if (!progressiveIntervalId) {
progressiveIntervalId = setInterval(deliverProgressive, PROGRESSIVE_INTERVAL_MS)
}
}, PROGRESSIVE_DELAY_MS)
}
@ -1164,6 +1171,10 @@ class ClientService extends EventTarget { @@ -1164,6 +1171,10 @@ class ClientService extends EventTarget {
oneose: (eosed) => {
if (eosed && !eosedAt) {
eosedAt = dayjs().unix()
if (progressiveIntervalId) {
clearInterval(progressiveIntervalId)
progressiveIntervalId = null
}
}
// (algo feeds) no need to sort and cache
if (!needSort) {
@ -1208,6 +1219,10 @@ class ClientService extends EventTarget { @@ -1208,6 +1219,10 @@ class ClientService extends EventTarget {
return {
timelineKey: key,
closer: () => {
if (progressiveIntervalId) {
clearInterval(progressiveIntervalId)
progressiveIntervalId = null
}
onEvents = () => {}
onNew = () => {}
subCloser.close()

82
src/services/indexed-db.service.ts

@ -44,11 +44,13 @@ export const StoreNames = { @@ -44,11 +44,13 @@ export const StoreNames = {
/** Cached GIF list (parsed from kind 1063 + 1/1111). Key: 'gifList', value: { gifs, cachedAt }. */
GIF_CACHE: 'gifCache',
/** App settings (replaces in-memory/localStorage for persisted settings). Key: setting key, value: string. */
SETTINGS: 'settings'
SETTINGS: 'settings',
/** NIP-A7 spell events (kind 777). Key: event id. */
SPELL_EVENTS: 'spellEvents'
}
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 23
const DB_VERSION = 24
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
@ -214,6 +216,9 @@ class IndexedDbService { @@ -214,6 +216,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.SETTINGS)) {
db.createObjectStore(StoreNames.SETTINGS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
db.createObjectStore(StoreNames.SPELL_EVENTS, { keyPath: 'key' })
}
}
}
);
@ -1777,6 +1782,79 @@ class IndexedDbService { @@ -1777,6 +1782,79 @@ class IndexedDbService {
transaction.onerror = () => reject(transaction.error)
})
}
/** Settings key for favorite spell event ids (JSON array of strings). */
static readonly SPELL_FAVORITE_IDS_KEY = 'spellFavoriteIds'
/**
* Store a NIP-A7 spell event (kind 777) in IndexedDB by event id.
*/
async putSpellEvent(event: Event): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
logger.warn('[IndexedDB] Spell events store not found')
return
}
const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SPELL_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.SPELL_EVENTS)
const key = cleanEvent.id
const value: TValue<Event> = {
key,
value: cleanEvent,
addedAt: Date.now()
}
store.put(value)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Get all spell events from IndexedDB.
*/
async getSpellEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SPELL_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.SPELL_EVENTS)
const request = store.getAll()
request.onsuccess = () => {
const rows = (request.result || []) as TValue<Event>[]
const events = rows
.filter((r) => r?.value != null)
.map((r) => r.value as Event)
resolve(events)
}
request.onerror = () => reject(request.error)
})
}
/**
* Get favorite spell ids from settings (JSON array of event ids).
*/
async getSpellFavoriteIds(): Promise<string[]> {
const raw = await this.getSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY)
if (!raw) return []
try {
const arr = JSON.parse(raw) as unknown
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
/**
* Set favorite spell ids in settings.
*/
async setSpellFavoriteIds(ids: string[]): Promise<void> {
await this.setSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY, JSON.stringify(ids))
}
}
const instance = IndexedDbService.getInstance()

177
src/services/spell.service.ts

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
/**
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters.
*/
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import logger from '@/lib/logger'
import type { Event } from 'nostr-tools'
import type { Filter } from 'nostr-tools'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
const RELATIVE_UNIT_SECONDS: Record<string, number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
mo: 2592000,
y: 31536000
}
/**
* Resolve relative time to Unix timestamp.
* "now" -> current time; "7d" -> now - 7*86400; "1704067200" -> 1704067200.
*/
export function resolveRelativeTime(value: string): number {
const trimmed = (value || '').trim()
if (trimmed === 'now' || trimmed === '') {
return Math.floor(Date.now() / 1000)
}
const num = parseInt(trimmed, 10)
if (!Number.isNaN(num) && trimmed === String(num)) {
return num
}
const match = trimmed.match(/^(\d+)(s|m|h|d|w|mo|y)$/)
if (!match) {
return Math.floor(Date.now() / 1000)
}
const n = parseInt(match[1]!, 10)
const unit = match[2]!
const sec = RELATIVE_UNIT_SECONDS[unit] ?? 86400
return Math.floor(Date.now() / 1000) - n * sec
}
export type SpellExecutionContext = {
pubkey: string | null
contacts: string[]
relayListRead: string[]
}
/**
* Get relay URLs for executing a spell: from spell's `relays` tag or fallback to context relay list / fast-read.
*/
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] {
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) {
const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
if (urls.length) return urls
}
if (context.relayListRead.length) return context.relayListRead
return [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
}
/**
* Resolve authors: replace $me with pubkey and $contacts with contacts array.
*/
function resolveAuthors(authorsTag: string[] | undefined, ctx: SpellExecutionContext): string[] | undefined {
const raw = authorsTag?.slice(1) ?? []
const out: string[] = []
for (const v of raw) {
if (v === '$me') {
if (ctx.pubkey) out.push(ctx.pubkey)
} else if (v === '$contacts') {
out.push(...ctx.contacts)
} else {
out.push(v)
}
}
return out.length ? out : undefined
}
/**
* Resolve tag filter values: replace $me and $contacts in ["tag", "p", "$me", "x"] etc.
*/
function resolveTagFilterValues(values: string[], ctx: SpellExecutionContext): string[] {
const out: string[] = []
for (const v of values) {
if (v === '$me') {
if (ctx.pubkey) out.push(ctx.pubkey)
} else if (v === '$contacts') {
out.push(...ctx.contacts)
} else {
out.push(v)
}
}
return out
}
/**
* Build a Nostr REQ filter from a spell event, resolving variables and relative times.
*/
export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Filter | null {
const filter: Filter = {}
const cmd = spell.tags.find(tagNameEquals('cmd'))?.[1]
if (cmd !== 'REQ' && cmd !== 'COUNT') {
logger.warn('[Spell] Unsupported cmd', { cmd })
return null
}
const kTag = spell.tags.filter(tagNameEquals('k'))
if (kTag.length) {
const kinds = kTag
.map((t) => t[1])
.filter((x): x is string => x != null && x !== '')
.map((x) => parseInt(x, 10))
.filter((n) => !Number.isNaN(n))
if (kinds.length) filter.kinds = kinds
}
const authorsTag = spell.tags.find(tagNameEquals('authors'))
const authors = resolveAuthors(authorsTag ? [authorsTag[0]!, ...authorsTag.slice(1)] : undefined, ctx)
if (authors?.length) filter.authors = authors
const idsTag = spell.tags.find(tagNameEquals('ids'))
if (idsTag && idsTag.length > 1) {
filter.ids = idsTag.slice(1).filter((x): x is string => typeof x === 'string' && x.length > 0)
}
const limitTag = spell.tags.find(tagNameEquals('limit'))
if (limitTag?.[1]) {
const n = parseInt(limitTag[1], 10)
if (!Number.isNaN(n)) filter.limit = n
}
const sinceTag = spell.tags.find(tagNameEquals('since'))
if (sinceTag?.[1]) filter.since = resolveRelativeTime(sinceTag[1])
const untilTag = spell.tags.find(tagNameEquals('until'))
if (untilTag?.[1]) filter.until = resolveRelativeTime(untilTag[1])
const searchTag = spell.tags.find(tagNameEquals('search'))
if (searchTag?.[1]) filter.search = searchTag[1]
for (const tag of spell.tags) {
if (tag[0] === 'tag' && tag.length >= 2) {
const letter = tag[1]
const values = resolveTagFilterValues(tag.slice(2), ctx)
if (letter && values.length) {
(filter as any)[`#${letter}`] = values
}
}
}
return filter
}
/**
* Whether the spell is COUNT (we only support REQ for feed display).
*/
export function spellIsCount(spell: Event): boolean {
return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT'
}
/**
* Get display name for a spell (from "name" tag or content).
*/
export function getSpellName(spell: Event): string {
const nameTag = spell.tags.find(tagNameEquals('name'))
if (nameTag?.[1]) return nameTag[1]
if (spell.content?.trim()) return spell.content.trim().slice(0, 80)
return `Spell ${spell.id.slice(0, 8)}`
}
export function isSpellEvent(event: Event): boolean {
return event.kind === ExtendedKind.SPELL
}
Loading…
Cancel
Save