From 867d5f3852d7de417e235f0e3d8f746fadab16dd Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Mar 2026 12:03:35 +0100 Subject: [PATCH] group spells by author copy spells --- src/i18n/locales/de.ts | 6 + src/i18n/locales/en.ts | 6 + .../primary/SpellsPage/CreateSpellDialog.tsx | 30 ++- src/pages/primary/SpellsPage/index.tsx | 224 +++++++++++++----- src/providers/NostrProvider/index.tsx | 3 +- src/services/client.service.ts | 23 +- 6 files changed, 221 insertions(+), 71 deletions(-) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 83a1a95a..ba8ebaa3 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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 { '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', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8b57fd61..9caf9b64 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { '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', diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index 44851d8d..f8f4fd06 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -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({ 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({ 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) => { const el = scrollBodyRef.current @@ -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({ - {replaceSpellId ? t('Edit spell') : t('Create a Spell')} + + {replaceSpellId ? t('Edit spell') : spellToClone ? t('Clone spell') : t('Create a Spell')} +

- {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.' + )}

diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 5b231a95..53ee26fc 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -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 { 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(function SpellsPage(_, ref) { const { t } = useTranslation() const { pubkey, relayList } = useNostr() @@ -57,6 +50,7 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { const [selectedSpell, setSelectedSpell] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) + const [spellToClone, setSpellToClone] = useState(null) const [definitionSpell, setDefinitionSpell] = useState(null) const [subRequests, setSubRequests] = useState([]) const [contacts, setContacts] = useState([]) @@ -355,13 +349,51 @@ const SpellsPage = forwardRef(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) => ( + setSelectedSpell(spell)} className="gap-2"> + + {selectedSpell?.id === spell.id ? : null} + + {spellMenuLabel(spell)} + + ), + [selectedSpell?.id, spellMenuLabel] + ) + + const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) return ( (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(function SpellsPage(_, ref) {
{/* Spell picker + actions above the feed */}
- + {ownSpells.length > 0 && (followSpells.length > 0 || otherSpells.length > 0) && ( + + )} + {followSpells.length > 0 ? ( + + + {t('Spells from follows', { count: followSpells.length })} + + + {followSpells.map((spell) => ( + {renderSpellMenuItem(spell)} + ))} + + + ) : null} + {otherSpells.length > 0 && (ownSpells.length > 0 || followSpells.length > 0) ? ( + + ) : null} + {otherSpells.length > 0 ? ( + + + {t('Other spells', { count: otherSpells.length })} + + + {otherSpells.map((spell) => ( + {renderSpellMenuItem(spell)} + ))} + + + ) : null} + +
- { - setSpellToEdit(selectedSpell) - setCreateOpen(true) - }} - > - - {t('Edit spell')} - + {selectedSpellIsOwn ? ( + { + setSpellToClone(null) + setSpellToEdit(selectedSpell) + setCreateOpen(true) + }} + > + + {t('Edit spell')} + + ) : ( + { + setSpellToEdit(null) + setSpellToClone(selectedSpell) + setCreateOpen(true) + }} + > + + {t('Clone spell')} + + )} setDefinitionSpell(selectedSpell)}> {t('View definition')} - - handleDeleteSpell(selectedSpell)} - > - - {t('Delete')} - + {selectedSpellIsOwn ? ( + <> + + handleDeleteSpell(selectedSpell)} + > + + {t('Delete')} + + + ) : null} @@ -478,7 +579,7 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) {

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

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

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

)} @@ -605,14 +706,21 @@ const SpellsPage = forwardRef(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) + } }} /> diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index dde1d730..4038e1e5 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 1eddbe84..abe3e4a7 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 { 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 { } } + /** 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 { 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 { 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')