Browse Source

group spells by author

copy spells
imwald
Silberengel 1 month ago
parent
commit
867d5f3852
  1. 6
      src/i18n/locales/de.ts
  2. 6
      src/i18n/locales/en.ts
  3. 30
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  4. 224
      src/pages/primary/SpellsPage/index.tsx
  5. 3
      src/providers/NostrProvider/index.tsx
  6. 23
      src/services/client.service.ts

6
src/i18n/locales/de.ts

@ -629,6 +629,8 @@ export default { @@ -629,6 +629,8 @@ export default {
'Noch keine Zaubersprüche. Lege mit dem Button oben einen an.',
'Loading spells from your relays…': 'Zaubersprüche werden von deinen Relays geladen…',
'Select a spell…': 'Zauberspruch wählen…',
'Spells from follows': 'Von Leuten, denen du folgst ({{count}})',
'Other spells': 'Weitere Zaubersprüche ({{count}})',
'View definition': 'Definition anzeigen',
'Add to favorites': 'Zu Favoriten hinzufügen',
'Remove from favorites': 'Aus Favoriten entfernen',
@ -661,6 +663,10 @@ export default { @@ -661,6 +663,10 @@ export default {
'Spell definition': 'Zauberspruch-Definition',
'Spell published': 'Zauberspruch veröffentlicht',
'Edit spell': 'Zauberspruch bearbeiten',
'Clone spell': 'Zauberspruch klonen',
'Spell cloned': 'Zauberspruch geklont',
'Clone spell intro':
'Dieser Zauberspruch wurde aus der Definition eines anderen Autors übernommen. Passe alles an, dann speichern, um einen neuen Zauberspruch mit deinem Konto zu veröffentlichen.',
'Spell updated': 'Zauberspruch aktualisiert',
'Relay URL': 'Relay',
Count: 'Anzahl',

6
src/i18n/locales/en.ts

@ -707,6 +707,8 @@ export default { @@ -707,6 +707,8 @@ export default {
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.':
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.',
'Select a spell…': 'Select a spell…',
'Spells from follows': 'From people you follow ({{count}})',
'Other spells': 'Other spells ({{count}})',
'Select a spell to view its feed.': 'Select a spell to view its feed.',
'Add another row': 'Add another row',
'Remove this row': 'Remove this row',
@ -721,6 +723,10 @@ export default { @@ -721,6 +723,10 @@ export default {
'Spell form fields': 'Spell form fields',
'Counting matching events…': 'Counting matching events…',
'Edit spell': 'Edit spell',
'Clone spell': 'Clone spell',
'Spell cloned': 'Spell cloned',
'Clone spell intro':
'This spell is preloaded from another author’s definition. Change anything you like, then save to publish a new spell signed with your account.',
'Spell updated': 'Spell updated',
'Relay URL': 'Relay',
Count: 'Count',

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

@ -134,7 +134,8 @@ export default function CreateSpellDialog({ @@ -134,7 +134,8 @@ export default function CreateSpellDialog({
open,
onOpenChange,
onSaved,
spellToEdit
spellToEdit,
spellToClone
}: {
open: boolean
onOpenChange: (open: boolean) => void
@ -142,6 +143,8 @@ export default function CreateSpellDialog({ @@ -142,6 +143,8 @@ export default function CreateSpellDialog({
onSaved?: (publishedEvent?: NostrEvent) => void
/** When set, form is preloaded and save replaces this spell id in storage/favorites. */
spellToEdit?: NostrEvent | null
/** When set, form is preloaded from this spell but save always publishes a new event (your pubkey). */
spellToClone?: NostrEvent | null
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
@ -151,12 +154,13 @@ export default function CreateSpellDialog({ @@ -151,12 +154,13 @@ export default function CreateSpellDialog({
useEffect(() => {
if (!open) return
if (spellToEdit) {
setForm(spellEventToDraftParams(spellToEdit))
const source = spellToClone ?? spellToEdit
if (source) {
setForm(spellEventToDraftParams(source))
} else {
setForm({ ...DEFAULT_PARAMS })
}
}, [open, spellToEdit])
}, [open, spellToEdit, spellToClone])
const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const el = scrollBodyRef.current
@ -210,7 +214,9 @@ export default function CreateSpellDialog({ @@ -210,7 +214,9 @@ export default function CreateSpellDialog({
handleClear()
onSaved?.(event)
onOpenChange(false)
showSimplePublishSuccess(replaceSpellId ? t('Spell updated') : t('Spell published'))
showSimplePublishSuccess(
replaceSpellId ? t('Spell updated') : spellToClone ? t('Spell cloned') : t('Spell published')
)
} catch (e) {
logger.error('[CreateSpellDialog] Publish failed', e)
showPublishingError(e instanceof Error ? e : new Error(String(e)))
@ -238,12 +244,18 @@ export default function CreateSpellDialog({ @@ -238,12 +244,18 @@ export default function CreateSpellDialog({
<X className="size-4" />
</Button>
<DialogHeader className="space-y-1.5 pr-10 text-left sm:text-left">
<DialogTitle>{replaceSpellId ? t('Edit spell') : t('Create a Spell')}</DialogTitle>
<DialogTitle>
{replaceSpellId ? t('Edit spell') : spellToClone ? t('Clone spell') : t('Create a Spell')}
</DialogTitle>
</DialogHeader>
<p className="mt-2 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.'
)}
{spellToClone
? t(
'This spell is preloaded from someone else’s definition. Adjust anything you like, then save to publish a new spell signed by you.'
)
: 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>

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

@ -12,15 +12,11 @@ import { @@ -12,15 +12,11 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider'
@ -38,17 +34,14 @@ import { @@ -38,17 +34,14 @@ import {
spellIsCount
} from '@/services/spell.service'
import { TFeedSubRequest } from '@/types'
import { FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react'
import { Check, ChevronDown, 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, useMemo, useRef, useState } from 'react'
import { Fragment, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types'
/** Sentinel value for Radix Select when no spell is selected */
const SPELL_SELECT_NONE = '__spell_none__'
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
@ -57,6 +50,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -57,6 +50,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
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 [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [contacts, setContacts] = useState<string[]>([])
@ -355,13 +349,51 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -355,13 +349,51 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
[loadSpells, selectedSpell?.id]
)
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)
})
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 (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])
const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
[favoriteIds]
)
const renderSpellMenuItem = useCallback(
(spell: Event) => (
<DropdownMenuItem onSelect={() => setSelectedSpell(spell)} className="gap-2">
<span className="flex size-4 shrink-0 items-center justify-center">
{selectedSpell?.id === spell.id ? <Check className="size-4" aria-hidden /> : null}
</span>
<span className="min-w-0 truncate">{spellMenuLabel(spell)}</span>
</DropdownMenuItem>
),
[selectedSpell?.id, spellMenuLabel]
)
const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey)
return (
<PrimaryPageLayout
@ -375,6 +407,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -375,6 +407,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
@ -388,26 +421,74 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -388,26 +421,74 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Select
value={selectedSpell?.id ?? SPELL_SELECT_NONE}
onValueChange={(v) => {
if (v === SPELL_SELECT_NONE) setSelectedSpell(null)
else setSelectedSpell(orderedSpells.find((s) => s.id === v) ?? null)
}}
disabled={orderedSpells.length === 0}
>
<SelectTrigger className="min-w-0 flex-1 sm:max-w-md">
<SelectValue placeholder={t('Select a spell…')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={SPELL_SELECT_NONE}>{t('Select a spell…')}</SelectItem>
{orderedSpells.map((spell) => (
<SelectItem key={spell.id} value={spell.id}>
{favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)}
</SelectItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={spellsForSelect.length === 0}
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
>
<span className="truncate">
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-[min(24rem,70vh)] w-[var(--radix-dropdown-menu-trigger-width)] min-w-[12rem] overflow-y-auto sm:max-w-md"
>
<DropdownMenuItem onSelect={() => setSelectedSpell(null)} className="gap-2">
<span className="flex size-4 shrink-0 items-center justify-center">
{!selectedSpell ? <Check className="size-4" aria-hidden /> : null}
</span>
<span className="truncate">{t('Select a spell…')}</span>
</DropdownMenuItem>
{(ownSpells.length > 0 || followSpells.length > 0 || otherSpells.length > 0) && (
<DropdownMenuSeparator />
)}
{ownSpells.map((spell) => (
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment>
))}
</SelectContent>
</Select>
{ownSpells.length > 0 && (followSpells.length > 0 || otherSpells.length > 0) && (
<DropdownMenuSeparator />
)}
{followSpells.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-default">
{t('Spells from follows', { count: followSpells.length })}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] min-w-[12rem] overflow-y-auto sm:min-w-[16rem]"
showScrollButtons
>
{followSpells.map((spell) => (
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
{otherSpells.length > 0 && (ownSpells.length > 0 || followSpells.length > 0) ? (
<DropdownMenuSeparator />
) : null}
{otherSpells.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-default">
{t('Other spells', { count: otherSpells.length })}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[50vh] min-w-[12rem] overflow-y-auto sm:min-w-[16rem]"
showScrollButtons
>
{otherSpells.map((spell) => (
<Fragment key={spell.id}>{renderSpellMenuItem(spell)}</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
@ -415,6 +496,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -415,6 +496,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
variant="outline"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
>
@ -445,28 +527,47 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -445,28 +527,47 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Pencil className="size-4" />
{t('Edit spell')}
</DropdownMenuItem>
{selectedSpellIsOwn ? (
<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>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
{selectedSpellIsOwn ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
@ -478,7 +579,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -478,7 +579,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null}
{orderedSpells.length === 0 && !spellsCatalogSyncing && (
{spellsForSelect.length === 0 && !spellsCatalogSyncing && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
@ -605,14 +706,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -605,14 +706,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) setSpellToEdit(null)
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)
}
}}
/>

3
src/providers/NostrProvider/index.tsx

@ -551,7 +551,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -551,7 +551,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} else {
client.signer = undefined
}
}, [signer])
client.signerType = account?.signerType
}, [signer, account?.signerType])
useEffect(() => {
if (account) {

23
src/services/client.service.ts

@ -13,7 +13,15 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib @@ -13,7 +13,15 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TPublishOptions, TRelayList, TMailboxRelay, TSubRequestFilter } from '@/types'
import {
ISigner,
TProfile,
TPublishOptions,
TRelayList,
TMailboxRelay,
TSignerType,
TSubRequestFilter
} from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import DataLoader from 'dataloader'
import dayjs from 'dayjs'
@ -40,6 +48,8 @@ class ClientService extends EventTarget { @@ -40,6 +48,8 @@ class ClientService extends EventTarget {
static instance: ClientService
signer?: ISigner
/** Set with signer from NostrProvider; used to skip relay AUTH when read-only (e.g. npub). */
signerType?: TSignerType
pubkey?: string
private pool: SimplePool
@ -186,6 +196,13 @@ class ClientService extends EventTarget { @@ -186,6 +196,13 @@ class ClientService extends EventTarget {
}
}
/** Read-only logins (e.g. npub) cannot sign relay AUTH challenges; avoid calling signEvent. */
private canSignerAuthenticateRelay(): boolean {
if (!this.signer) return false
if (this.signerType === 'npub') return false
return true
}
/**
* Determine which relays to publish an event to.
* Fallbacks (used when user relay list is empty or fetch fails):
@ -684,7 +701,7 @@ class ClientService extends EventTarget { @@ -684,7 +701,7 @@ class ClientService extends EventTarget {
if (
error instanceof Error &&
error.message.startsWith('auth-required') &&
!!that.signer
that.canSignerAuthenticateRelay()
) {
logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url })
return relay
@ -1048,7 +1065,7 @@ class ClientService extends EventTarget { @@ -1048,7 +1065,7 @@ class ClientService extends EventTarget {
oneose: () => handleEose(i),
onclose: (reason: string) => {
releaseOnce()
if (reason.startsWith('auth-required: ') && that.signer) {
if (reason.startsWith('auth-required: ') && that.canSignerAuthenticateRelay()) {
relay.auth(async (authEvt: EventTemplate) => {
const evt = await that.signer!.signEvent(authEvt)
if (!evt) throw new Error('sign event failed')

Loading…
Cancel
Save