diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index cf6bb8c0..3b3804c8 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1438,6 +1438,9 @@ export default { 'Subscribing...': 'Subscribing...', Summary: 'Summary', 'Unknown note reference tags': 'Referenz-Tags (e, p, q, a)', + 'Starred spells': 'Markierte Sprüche', + 'Spell star add title': 'Spruch markieren (wird zu deinen Nostr-Lesezeichen hinzugefügt)', + 'Spell star remove title': 'Markierung aufheben (aus Nostr-Lesezeichen entfernen)', 'Supported Event Types': 'Supported Event Types', 'Take a note': 'Take a note', 'The full prompt conversation (optional)': 'The full prompt conversation (optional)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fe8cb8a6..8005fc9f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -413,6 +413,9 @@ export default { 'Unknown note tagged pubkey': 'Tagged pubkey', 'Unknown note tagged content': 'Content', 'Unknown note reference tags': 'Reference tags (e, p, q, a)', + 'Starred spells': 'Starred spells', + 'Spell star add title': 'Star spell (adds to your Nostr bookmarks)', + 'Spell star remove title': 'Unstar spell (removes from your Nostr bookmarks)', 'Copy JSON': 'Copy JSON', Verse: 'Verse', 'Notification reaction summary': 'reacted to this note.', diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index 07cb354b..e9f43bfa 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -18,6 +18,7 @@ import { dedupeAppendIds, resolveSpellListATags } from '@/lib/spell-list-import' +import { useBookmarks } from '@/providers/BookmarksProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' @@ -290,6 +291,7 @@ export default function CreateSpellDialog({ }) { const { t } = useTranslation() const { pubkey, publish, checkLogin, relayList } = useNostr() + const { addBookmark, removeBookmark } = useBookmarks() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [form, setForm] = useState(DEFAULT_PARAMS) const [saving, setSaving] = useState(false) @@ -402,8 +404,20 @@ export default function CreateSpellDialog({ if (replaceSpellId) { await indexedDb.deleteSpellEvent(replaceSpellId) const favs = await indexedDb.getSpellFavoriteIds() + const ridLower = replaceSpellId.toLowerCase() + const wasStarred = favs.some((id) => id.toLowerCase() === ridLower) if (favs.length) { - await indexedDb.setSpellFavoriteIds(favs.map((id) => (id === replaceSpellId ? event.id : id))) + await indexedDb.setSpellFavoriteIds( + favs.map((id) => (id.toLowerCase() === ridLower ? event.id : id)) + ) + } + if (wasStarred && spellToEdit) { + try { + await removeBookmark(spellToEdit) + await addBookmark(event) + } catch (e) { + logger.warn('[CreateSpellDialog] Bookmark migrate after spell edit failed', e) + } } } await indexedDb.putSpellEvent(event) diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index de193995..6c21859f 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -27,6 +27,7 @@ import { cn } from '@/lib/utils' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilter } from '@/providers/KindFilterProvider' +import { useBookmarks } from '@/providers/BookmarksProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' @@ -169,7 +170,11 @@ function SpellSheetOptionRow({ accountPubkey, labelFor, onPick, - groupedUnderAuthor = false + groupedUnderAuthor = false, + starred = false, + onToggleStar, + starTitleAdd, + starTitleRemove }: { spell: Event selected: boolean @@ -178,32 +183,62 @@ function SpellSheetOptionRow({ onPick: (e: Event) => void /** Author shown in a header above this block — hide npub under each row */ groupedUnderAuthor?: boolean + starred?: boolean + onToggleStar?: (spell: Event) => void + starTitleAdd?: string + starTitleRemove?: string }) { + const { t } = useTranslation() const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, { omitAuthorNpub: groupedUnderAuthor }) return ( - + + {onToggleStar ? ( + + ) : null} + ) } @@ -276,6 +311,7 @@ const SpellsPage = forwardRef(function SpellsPage( const { t } = useTranslation() const { navigate: navigatePrimary } = usePrimaryPage() const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr() + const { addBookmark, removeBookmark } = useBookmarks() const { hideUntrustedNotifications } = useUserTrust() const { isSmallScreen } = useScreenSize() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -287,7 +323,8 @@ const SpellsPage = forwardRef(function SpellsPage( } = useKindFilter() const hideRepliesFollowing = useNoteListHideReplies() const [spells, setSpells] = useState([]) - const [favoriteIds, setFavoriteIds] = useState>(new Set()) + /** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */ + const [favoriteSpellIds, setFavoriteSpellIds] = useState([]) const [selectedSpell, setSelectedSpell] = useState(null) const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) const [followSetListEvents, setFollowSetListEvents] = useState([]) @@ -361,7 +398,7 @@ const SpellsPage = forwardRef(function SpellsPage( indexedDb.getSpellFavoriteIds() ]) setSpells(events) - setFavoriteIds(new Set(ids)) + setFavoriteSpellIds(ids) }, []) const refreshSpellsFeedAndCatalog = useCallback(() => { @@ -891,14 +928,47 @@ const SpellsPage = forwardRef(function SpellsPage( return () => removeRelayUrls(urls) }, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls]) - 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 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) => { @@ -912,7 +982,15 @@ const SpellsPage = forwardRef(function SpellsPage( try { await indexedDb.deleteSpellEvent(spell.id) const ids = await indexedDb.getSpellFavoriteIds() - await indexedDb.setSpellFavoriteIds(ids.filter((id) => id !== spell.id)) + 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) { @@ -922,9 +1000,19 @@ const SpellsPage = forwardRef(function SpellsPage( ) } }, - [attemptDelete, loadSpells, selectedSpell?.id, t] + [attemptDelete, loadSpells, pubkey, removeBookmark, selectedSpell?.id, t] ) + const starredSpellsForPicker = useMemo(() => { + const byId = new Map() + for (const s of spells) { + byId.set(s.id.toLowerCase(), s) + } + return favoriteSpellIds + .map((id) => byId.get(id.toLowerCase())) + .filter((s): s is Event => s != null) + }, [spells, favoriteSpellIds]) + const { ownSpells, followSpells, otherSpells, spellsForSelect } = useMemo(() => { const byName = (a: Event, b: Event) => getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) @@ -935,6 +1023,7 @@ const SpellsPage = forwardRef(function SpellsPage( 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) @@ -950,7 +1039,7 @@ const SpellsPage = forwardRef(function SpellsPage( otherSpells: other, spellsForSelect: [...own, ...follow, ...other] } - }, [spells, pubkey, contacts]) + }, [spells, pubkey, contacts, favoriteSpellSet]) const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells]) const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells]) @@ -1011,8 +1100,9 @@ const SpellsPage = forwardRef(function SpellsPage( }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) const spellMenuLabel = useCallback( - (spell: Event) => (favoriteIds.has(spell.id) ? `★ ${getSpellName(spell)}` : getSpellName(spell)), - [favoriteIds] + (spell: Event) => + favoriteSpellSet.has(spell.id.toLowerCase()) ? `★ ${getSpellName(spell)}` : getSpellName(spell), + [favoriteSpellSet] ) const selectedFauxSpellDisplayLabel = useMemo(() => { @@ -1133,8 +1223,33 @@ const SpellsPage = forwardRef(function SpellsPage( (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)) ) + const spellStarAddTitle = t('Spell star add title') + const spellStarRemoveTitle = t('Spell star remove title') + const spellPickerList = ( <> + {starredSpellsForPicker.length > 0 ? ( + <> +

+ {t('Starred spells')} +

+ {starredSpellsForPicker.map((spell) => ( + getSpellName(e)} + onPick={pickSpell} + starred + onToggleStar={(s) => void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} + /> + ))} + + + ) : null} {FAUX_SPELL_ORDER.flatMap((name) => { if ( (name === 'notifications' || @@ -1235,6 +1350,10 @@ const SpellsPage = forwardRef(function SpellsPage( accountPubkey={pubkey ?? undefined} labelFor={spellMenuLabel} onPick={pickSpell} + starred={favoriteSpellSet.has(spell.id.toLowerCase())} + onToggleStar={(s) => void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} /> ))} @@ -1259,6 +1378,10 @@ const SpellsPage = forwardRef(function SpellsPage( labelFor={spellMenuLabel} onPick={pickSpell} groupedUnderAuthor + starred={favoriteSpellSet.has(spell.id.toLowerCase())} + onToggleStar={(s) => void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} /> ))} @@ -1286,6 +1409,10 @@ const SpellsPage = forwardRef(function SpellsPage( labelFor={spellMenuLabel} onPick={pickSpell} groupedUnderAuthor + starred={favoriteSpellSet.has(spell.id.toLowerCase())} + onToggleStar={(s) => void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} /> ))} @@ -1444,14 +1571,14 @@ const SpellsPage = forwardRef(function SpellsPage( size="icon" className="shrink-0" title={ - favoriteIds.has(selectedSpell.id) - ? t('Remove from favorites') - : t('Add to favorites') + favoriteSpellSet.has(selectedSpell.id.toLowerCase()) + ? t('Spell star remove title') + : t('Spell star add title') } - onClick={() => toggleFavorite(selectedSpell.id)} + onClick={() => void toggleFavoriteSpell(selectedSpell)} >