From 98764541f5e8cceb223bb330211981fa848f14ef Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 30 Mar 2026 16:45:34 +0200 Subject: [PATCH] bug-fixes --- src/components/BookmarkButton/index.tsx | 6 +- .../PersonalListBech32List/index.tsx | 21 ++- .../PersonalListNoteRefRow/index.tsx | 147 ++++++++++++++---- src/hooks/useRemovePinListEntry.ts | 63 ++++++++ src/i18n/locales/en.ts | 4 + src/lib/personal-list-mutations.ts | 40 +++++ src/lib/replaceable-list-latest.ts | 25 +++ .../secondary/BookmarkListPage/index.tsx | 2 +- src/pages/secondary/PinListPage/index.tsx | 6 +- src/providers/BookmarksProvider.tsx | 46 +++++- src/providers/bookmarks-context.tsx | 5 +- 11 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 src/hooks/useRemovePinListEntry.ts create mode 100644 src/lib/personal-list-mutations.ts diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx index 6c536098..8e0a66e1 100644 --- a/src/components/BookmarkButton/index.tsx +++ b/src/components/BookmarkButton/index.tsx @@ -15,7 +15,11 @@ export default function BookmarkButton({ event }: { event: Event }) { const accountPubkey = nostrContext?.pubkey ?? null const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null const checkLogin = nostrContext?.checkLogin ?? (async () => {}) - const { addBookmark, removeBookmark } = bookmarksContext ?? { addBookmark: async () => {}, removeBookmark: async () => {} } + const { addBookmark, removeBookmark } = bookmarksContext ?? { + addBookmark: async () => {}, + removeBookmark: async () => false, + removeBookmarkByBech32: async () => false + } const [updating, setUpdating] = useState(false) const isBookmarked = useMemo(() => { const isReplaceable = isReplaceableEvent(event.kind) diff --git a/src/components/PersonalListBech32List/index.tsx b/src/components/PersonalListBech32List/index.tsx index 1a6bc899..32f79739 100644 --- a/src/components/PersonalListBech32List/index.tsx +++ b/src/components/PersonalListBech32List/index.tsx @@ -3,8 +3,18 @@ import { useEffect, useRef, useState } from 'react' const PAGE = 10 +type TListMode = 'bookmark' | 'pin' + /** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */ -export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: string[] }) { +export default function PersonalListBech32List({ + bech32Ids, + listMode, + onEntryRemoved +}: { + bech32Ids: string[] + listMode?: TListMode + onEntryRemoved?: () => void +}) { const [visible, setVisible] = useState([]) const bottomRef = useRef(null) @@ -28,9 +38,14 @@ export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: strin }, [visible, bech32Ids]) return ( -
+
{visible.map((id) => ( - + ))} {bech32Ids.length > visible.length ?
: null}
diff --git a/src/components/PersonalListNoteRefRow/index.tsx b/src/components/PersonalListNoteRefRow/index.tsx index e47638f4..2fdd22a8 100644 --- a/src/components/PersonalListNoteRefRow/index.tsx +++ b/src/components/PersonalListNoteRefRow/index.tsx @@ -1,21 +1,41 @@ import { useFetchEvent } from '@/hooks' +import { useRemovePinListEntry } from '@/hooks/useRemovePinListEntry' import { toNote } from '@/lib/link' import { useSmartNoteNavigation } from '@/PageManager' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' -import { ChevronRight } from 'lucide-react' -import { useMemo } from 'react' +import { useBookmarksOptional } from '@/providers/bookmarks-context' +import { useNostr } from '@/providers/NostrProvider' +import { ChevronRight, Trash2 } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import type { Event } from 'nostr-tools' + +type TListMode = 'bookmark' | 'pin' /** * One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists). */ -export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string }) { +export default function PersonalListNoteRefRow({ + bech32Id, + listMode, + onEntryRemoved +}: { + bech32Id: string + listMode?: TListMode + onEntryRemoved?: () => void +}) { const { t } = useTranslation() const { event, isFetching } = useFetchEvent(bech32Id) const { navigateToNote } = useSmartNoteNavigation() + const { checkLogin } = useNostr() + const bookmarks = useBookmarksOptional() + const removePinEntry = useRemovePinListEntry(onEntryRemoved) + const [removing, setRemoving] = useState(false) + const preview = useMemo(() => { const c = event?.content?.trim() if (!c) return '' @@ -24,6 +44,59 @@ export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string const onOpen = () => navigateToNote(toNote(bech32Id)) + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (!listMode || removing) return + void checkLogin(async () => { + setRemoving(true) + try { + if (listMode === 'bookmark') { + if (!bookmarks) { + toast.error(t('Remove bookmark failed')) + return + } + const ok = event + ? await bookmarks.removeBookmark(event as Event) + : await bookmarks.removeBookmarkByBech32(bech32Id) + if (ok) { + toast.success(t('Removed from bookmarks')) + } else { + toast.info(t('Bookmark not in list')) + } + } else { + const ok = await removePinEntry(bech32Id, event as Event | null) + if (ok) { + toast.success(t('Note unpinned')) + } else { + toast.info(t('Pin not in list')) + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.error( + listMode === 'bookmark' + ? `${t('Remove bookmark failed')}: ${msg}` + : `${t('Failed to remove pin')}: ${msg}` + ) + } finally { + setRemoving(false) + } + }) + }, + [ + bech32Id, + bookmarks, + checkLogin, + event, + listMode, + removePinEntry, + removing, + t + ] + ) + if (isFetching) { return (
@@ -37,33 +110,49 @@ export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string } return ( - + )} + + + {listMode ? ( + + ) : null} +
) } diff --git a/src/hooks/useRemovePinListEntry.ts b/src/hooks/useRemovePinListEntry.ts new file mode 100644 index 00000000..39b1a3c8 --- /dev/null +++ b/src/hooks/useRemovePinListEntry.ts @@ -0,0 +1,63 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { + buildPinListTagsAfterRemovingRef, + buildPinListTagsAfterToggle, + fetchNewestPinListForPubkey, + isEventInPinList +} from '@/lib/replaceable-list-latest' +import { decodePersonalListBech32Ref } from '@/lib/personal-list-mutations' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import indexedDb from '@/services/indexed-db.service' +import { useCallback } from 'react' +import type { Event } from 'nostr-tools' + +/** + * Publish an updated kind 10001 pin list without the given entry (by loaded event or NIP-19 ref). + */ +export function useRemovePinListEntry(onSuccess?: () => void) { + const { publish, pubkey } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + + const removePinEntry = useCallback( + async (bech32Id: string, loadedEvent: Event | null): Promise => { + if (!pubkey) return false + const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + if (!comprehensiveRelays.length) return false + + const latest = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) + if (!latest) return false + + let newTags: string[][] | null = null + if (loadedEvent) { + if (!isEventInPinList(latest, loadedEvent)) return false + newTags = buildPinListTagsAfterToggle(latest, loadedEvent, false) + } else { + const ref = decodePersonalListBech32Ref(bech32Id) + if (!ref) return false + newTags = buildPinListTagsAfterRemovingRef(latest.tags, ref) + } + if (!newTags) return false + + const published = await publish( + { + kind: 10001, + tags: newTags, + content: '', + created_at: Math.floor(Date.now() / 1000) + }, + { specifiedRelayUrls: comprehensiveRelays } + ) + await indexedDb.putReplaceableEvent(published as Event) + onSuccess?.() + return true + }, + [blockedRelays, favoriteRelays, onSuccess, publish, pubkey] + ) + + return removePinEntry +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 58f1a591..421924e5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -679,6 +679,10 @@ export default { 'Lightning Invoice': 'Lightning Invoice', 'Bookmark failed': 'Bookmark failed', 'Remove bookmark failed': 'Remove bookmark failed', + 'Removed from bookmarks': 'Removed from bookmarks', + 'Bookmark not in list': 'This bookmark is not in your list (already removed or out of sync).', + 'Pin not in list': 'This pin is not in your list (already removed or out of sync).', + 'Failed to remove pin': 'Failed to remove pin', Translation: 'Translation', Balance: 'Balance', characters: 'characters', diff --git a/src/lib/personal-list-mutations.ts b/src/lib/personal-list-mutations.ts new file mode 100644 index 00000000..8269e305 --- /dev/null +++ b/src/lib/personal-list-mutations.ts @@ -0,0 +1,40 @@ +import { nip19 } from 'nostr-tools' + +/** Decoded target for one bookmark/pin list entry (NIP-19 nevent/note or naddr). */ +export type TPersonalListBech32Ref = { eIdLower?: string; aCoordLower?: string } + +export function decodePersonalListBech32Ref(bech32Id: string): TPersonalListBech32Ref | null { + try { + const dec = nip19.decode(bech32Id.trim()) + if (dec.type === 'nevent') { + return { eIdLower: dec.data.id.toLowerCase() } + } + if (dec.type === 'note') { + return { eIdLower: dec.data.toLowerCase() } + } + if (dec.type === 'naddr') { + const { kind, pubkey, identifier } = dec.data + return { aCoordLower: `${kind}:${pubkey}:${identifier}`.toLowerCase() } + } + } catch { + return null + } + return null +} + +/** + * Next bookmark list (kind 10003) tags after dropping one `e` or `a` ref. + * Returns null if nothing matched (list unchanged). + */ +export function bookmarkListTagsAfterRemovingRef( + tags: string[][], + ref: TPersonalListBech32Ref +): string[][] | null { + if (!ref.eIdLower && !ref.aCoordLower) return null + const next = tags.filter((tag) => { + if (ref.eIdLower && tag[0] === 'e' && tag[1]?.toLowerCase() === ref.eIdLower) return false + if (ref.aCoordLower && tag[0] === 'a' && tag[1]?.toLowerCase() === ref.aCoordLower) return false + return true + }) + return next.length === tags.length ? null : next +} diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts index fa4b2a74..faa1d545 100644 --- a/src/lib/replaceable-list-latest.ts +++ b/src/lib/replaceable-list-latest.ts @@ -2,6 +2,7 @@ import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEO import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' import client, { queryService } from '@/services/client.service' +import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations' import type { Event } from 'nostr-tools' /** @@ -104,6 +105,30 @@ export function buildPinListTagsAfterToggle( return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] } +/** + * Pin list tags after removing an entry identified only by nevent/note id and/or naddr coordinate + * (when the pinned event is not loaded). Returns null if nothing matched. + */ +export function buildPinListTagsAfterRemovingRef( + tags: string[][], + ref: TPersonalListBech32Ref +): string[][] | null { + if (!ref.eIdLower && !ref.aCoordLower) return null + const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a') + let aKeep = tags.filter((t) => t[0] === 'a' && t[1]) + const origALen = aKeep.length + if (ref.aCoordLower) { + aKeep = aKeep.filter((t) => t[1]!.toLowerCase() !== ref.aCoordLower) + } + let eIds = orderedUniqueEHexIds(tags) + const origELen = eIds.length + if (ref.eIdLower) { + eIds = eIds.filter((x) => x !== ref.eIdLower) + } + if (aKeep.length === origALen && eIds.length === origELen) return null + return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] +} + /** Dedupe `p` tags (case-insensitive hex), preserve other tags and first-seen `p` casing. */ function dedupePTags(tags: string[][]): string[][] { const nonP = tags.filter((t) => t[0] !== 'p') diff --git a/src/pages/secondary/BookmarkListPage/index.tsx b/src/pages/secondary/BookmarkListPage/index.tsx index 5b85f9f4..0ca6cd59 100644 --- a/src/pages/secondary/BookmarkListPage/index.tsx +++ b/src/pages/secondary/BookmarkListPage/index.tsx @@ -131,7 +131,7 @@ const BookmarkListPage = forwardRef( {bech32Ids.length === 0 ? (

{t('No entries in bookmark list')}

) : ( - + )}
diff --git a/src/pages/secondary/PinListPage/index.tsx b/src/pages/secondary/PinListPage/index.tsx index a8b7910a..acfba518 100644 --- a/src/pages/secondary/PinListPage/index.tsx +++ b/src/pages/secondary/PinListPage/index.tsx @@ -139,7 +139,11 @@ const PinListPage = forwardRef( {bech32Ids.length === 0 ? (

{t('No pinned notes in list')}

) : ( - + void loadPins()} + /> )}
diff --git a/src/providers/BookmarksProvider.tsx b/src/providers/BookmarksProvider.tsx index f318c036..9bb63472 100644 --- a/src/providers/BookmarksProvider.tsx +++ b/src/providers/BookmarksProvider.tsx @@ -1,6 +1,10 @@ import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildATag, buildETag, createBookmarkDraftEvent } from '@/lib/draft-event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { + bookmarkListTagsAfterRemovingRef, + decodePersonalListBech32Ref +} from '@/lib/personal-list-mutations' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import logger from '@/lib/logger' import client from '@/services/client.service' @@ -59,8 +63,8 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { await updateBookmarkListEvent(newBookmarkEvent) } - const removeBookmark = async (event: Event) => { - if (!accountPubkey) return + const removeBookmark = async (event: Event): Promise => { + if (!accountPubkey) return false const comprehensiveRelays = await buildComprehensiveRelayList() let bookmarkListEvent = @@ -68,7 +72,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { if (!bookmarkListEvent) { bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null } - if (!bookmarkListEvent) return + if (!bookmarkListEvent) return false const isReplaceable = isReplaceableEvent(event.kind) const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id @@ -76,7 +80,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { const newTags = bookmarkListEvent.tags.filter((tag) => isReplaceable ? tag[0] !== 'a' || tag[1] !== eventKey : tag[0] !== 'e' || tag[1] !== eventKey ) - if (newTags.length === bookmarkListEvent.tags.length) return + if (newTags.length === bookmarkListEvent.tags.length) return false const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) @@ -86,13 +90,45 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { specifiedRelayUrls: comprehensiveRelays }) await updateBookmarkListEvent(newBookmarkEvent) + return true + } + + const removeBookmarkByBech32 = async (bech32Id: string): Promise => { + if (!accountPubkey) return false + + const ref = decodePersonalListBech32Ref(bech32Id) + if (!ref) return false + + const comprehensiveRelays = await buildComprehensiveRelayList() + let bookmarkListEvent = + (await fetchLatestReplaceableListEvent(accountPubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null + if (!bookmarkListEvent) { + bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null + } + if (!bookmarkListEvent) return false + + const newTags = bookmarkListTagsAfterRemovingRef(bookmarkListEvent.tags, ref) + if (!newTags) return false + + const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) + + logger.component('BookmarksProvider', 'Publishing bookmark list update (remove by bech32)', { + count: comprehensiveRelays.length + }) + + const newBookmarkEvent = await publish(newBookmarkDraftEvent, { + specifiedRelayUrls: comprehensiveRelays + }) + await updateBookmarkListEvent(newBookmarkEvent) + return true } return ( {children} diff --git a/src/providers/bookmarks-context.tsx b/src/providers/bookmarks-context.tsx index eff42086..4d42d9d3 100644 --- a/src/providers/bookmarks-context.tsx +++ b/src/providers/bookmarks-context.tsx @@ -8,7 +8,10 @@ import { createContext, useContext } from 'react' export type TBookmarksContext = { addBookmark: (event: Event) => Promise - removeBookmark: (event: Event) => Promise + /** `true` if a new list event was published. */ + removeBookmark: (event: Event) => Promise + /** Remove by nevent / note / naddr id when the row has no loaded event (or as fallback). */ + removeBookmarkByBech32: (bech32Id: string) => Promise } export const BookmarksContext = createContext(undefined)