diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index c827aed8..e42b1eaf 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -7,7 +7,11 @@ import logger from '@/lib/logger' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl, simplifyUrl } from '@/lib/url' import { speakNoteReadAloud } from '@/lib/read-aloud' -import { buildPinListTagsAfterToggle, fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' +import { + buildPinListTagsAfterToggle, + fetchNewestPinListForPubkey, + isEventInPinList +} from '@/lib/replaceable-list-latest' import { generateBech32IdFromATag } from '@/lib/tag' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -150,29 +154,16 @@ export function useMenuActions({ .filter((url): url is string => !!url) const comprehensiveRelays = Array.from(new Set(normalizedRelays)) - - let pinListEvent: Event | null | undefined = await fetchLatestReplaceableListEvent( - pubkey, - 10001, - comprehensiveRelays - ) - if (!pinListEvent) { - try { - pinListEvent = (await client.fetchPinListEvent(pubkey)) ?? null - } catch (error) { - logger.component('PinStatus', 'Error fetching pin list fallback', { - error: (error as Error).message - }) - pinListEvent = null - } - } - + + const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) if (pinListEvent) { - const isEventPinned = pinListEvent.tags.some(tag => tag[0] === 'e' && tag[1] === event.id) - setIsPinned(isEventPinned) + setIsPinned(isEventInPinList(pinListEvent, event)) + } else { + setIsPinned(false) } } catch (error) { logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message }) + setIsPinned(false) } } checkIfPinned() @@ -196,19 +187,12 @@ export function useMenuActions({ .filter((url): url is string => !!url) const comprehensiveRelays = Array.from(new Set(normalizedRelays)) - - let latestPinList = await fetchLatestReplaceableListEvent(pubkey, 10001, comprehensiveRelays) - if (!latestPinList) { - try { - latestPinList = (await client.fetchPinListEvent(pubkey)) ?? undefined - } catch (error) { - logger.component('PinNote', 'Pin list fallback fetch failed', { error: (error as Error).message }) - } - } + + const latestPinList = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) logger.component('PinNote', 'Current pin list event', { hasEvent: !!latestPinList }) - const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event.id, !isPinned) + const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event, !isPinned) const successMessage = isPinned ? t('Note unpinned') : t('Note pinned') logger.component('PinNote', 'Pin list tag count after merge', { count: newTags.length }) diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts index c5d634fd..fa4b2a74 100644 --- a/src/lib/replaceable-list-latest.ts +++ b/src/lib/replaceable-list-latest.ts @@ -1,7 +1,7 @@ import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' -import { queryService } from '@/services/client.service' +import client, { queryService } from '@/services/client.service' import type { Event } from 'nostr-tools' /** @@ -29,6 +29,39 @@ export async function fetchLatestReplaceableListEvent( return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) } +/** + * Kind 10001 from browsing relays can be stale vs the copy resolved via the author’s relay set + * ({@link client.fetchPinListEvent}). Merge both and keep the newest `created_at` so pin UI and merges + * match the profile pin list. + */ +export async function fetchNewestPinListForPubkey( + pubkeyHex: string, + relayUrls: string[] +): Promise { + const pk = normalizeHexPubkey(pubkeyHex) + const [fromRelays, fromService] = await Promise.all([ + relayUrls.length + ? fetchLatestReplaceableListEvent(pk, 10001, relayUrls) + : Promise.resolve(undefined), + client.fetchPinListEvent(pk).catch(() => undefined) + ]) + if (!fromRelays) return fromService + if (!fromService) return fromRelays + return fromService.created_at >= fromRelays.created_at ? fromService : fromRelays +} + +/** Whether this event is referenced by the pin list via `e` (hex id) or `a` (NIP-33 coordinate). */ +export function isEventInPinList(pinList: Event, event: Event): boolean { + const idLower = event.id.toLowerCase() + const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '' + const coord = `${event.kind}:${event.pubkey}:${d}`.toLowerCase() + for (const tag of pinList.tags) { + if (tag[0] === 'e' && tag[1] && tag[1].toLowerCase() === idLower) return true + if (tag[0] === 'a' && tag[1] && tag[1].toLowerCase() === coord) return true + } + return false +} + function orderedUniqueEHexIds(tags: string[][]): string[] { const seen = new Set() const out: string[] = [] @@ -46,17 +79,23 @@ function orderedUniqueEHexIds(tags: string[][]): string[] { /** * Next pin list (kind 10001) tags: preserve non-`e`/`a` tags and `a` pins, merge `e` hex ids with dedupe. + * Unpin removes both the `e` id and an `a` coordinate when the list used NIP-33 pins. */ export function buildPinListTagsAfterToggle( latest: Event | null | undefined, - noteHexId: string, + targetEvent: Event, shouldPin: boolean ): string[][] { const tags = latest?.tags ?? [] const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a') - const aKeep = tags.filter((t) => t[0] === 'a' && t[1]) + const d = targetEvent.tags.find((t) => t[0] === 'd')?.[1] ?? '' + const coord = `${targetEvent.kind}:${targetEvent.pubkey}:${d}`.toLowerCase() + let aKeep = tags.filter((t) => t[0] === 'a' && t[1]) + if (!shouldPin) { + aKeep = aKeep.filter((t) => t[1]!.toLowerCase() !== coord) + } let eIds = orderedUniqueEHexIds(tags) - const id = noteHexId.toLowerCase() + const id = targetEvent.id.toLowerCase() if (shouldPin) { if (!eIds.includes(id)) eIds = [...eIds, id] } else {