import { Helpers } from 'applesauce-core' import { ActiveAccount, IndividualBookmark } from '../types/bookmarks' import { BookmarkHiddenSymbol, hasNip04Decrypt, hasNip44Decrypt, processApplesauceBookmarks } from './bookmarkHelpers' import type { NostrEvent } from './bookmarkHelpers' type DecryptFn = (pubkey: string, content: string) => Promise type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags type HiddenContentSigner = Parameters[1] type UnlockMode = Parameters[2] /** * Decrypt/unlock a single event and return private bookmarks */ async function decryptEvent( evt: NostrEvent, activeAccount: ActiveAccount, signerCandidate: unknown, metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } ): Promise { const { dTag, setTitle, setDescription, setImage } = metadata const privateItems: IndividualBookmark[] = [] try { if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) { try { await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner) } catch { try { await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode) } catch (_err) { // Ignore unlock errors } } } else if (evt.content && evt.content.length > 0) { let decryptedContent: string | undefined // Try to detect encryption method from content format // NIP-44 starts with version byte (currently 0x02), NIP-04 is base64 const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=') // Try the likely method first (no timeout - let it fail naturally like debug page) if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) { try { decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content) } catch (_err) { // Ignore NIP-44 decryption errors } } // Fallback to nip04 if nip44 failed or content looks like nip04 if (!decryptedContent && hasNip04Decrypt(signerCandidate)) { try { decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content) } catch (_err) { // Ignore NIP-04 decryption errors } } if (decryptedContent) { try { const hiddenTags = JSON.parse(decryptedContent) as string[][] const manualPrivate = Helpers.parseBookmarkTags(hiddenTags) privateItems.push( ...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({ ...i, sourceKind: evt.kind, setName: dTag, setTitle, setDescription, setImage })) ) Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate) Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent) } catch (err) { // ignore parse errors } } } const priv = Helpers.getHiddenBookmarks(evt) if (priv) { privateItems.push( ...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({ ...i, sourceKind: evt.kind, setName: dTag, setTitle, setDescription, setImage })) ) } } catch { // ignore individual event failures } return privateItems } export async function collectBookmarksFromEvents( bookmarkListEvents: NostrEvent[], activeAccount: ActiveAccount, signerCandidate?: unknown ): Promise<{ publicItemsAll: IndividualBookmark[] privateItemsAll: IndividualBookmark[] newestCreatedAt: number latestContent: string allTags: string[][] }> { const publicItemsAll: IndividualBookmark[] = [] let newestCreatedAt = 0 let latestContent = '' let allTags: string[][] = [] // Build list of events needing decrypt and collect public items immediately const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = [] for (const evt of bookmarkListEvents) { newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0) if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags) const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined const metadata = { dTag, setTitle, setDescription, setImage } // Handle web bookmarks (kind:39701) as individual bookmarks if (evt.kind === 39701) { // Use coordinate format for web bookmarks to enable proper deduplication // Web bookmarks are replaceable events (kind:39701:pubkey:d-tag) const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id publicItemsAll.push({ id: webBookmarkId, content: evt.content || '', created_at: evt.created_at ?? null, pubkey: evt.pubkey, kind: evt.kind, tags: evt.tags || [], parsedContent: undefined, type: 'web' as const, isPrivate: false, sourceKind: 39701, setName: dTag, setTitle, setDescription, setImage, listUpdatedAt: evt.created_at ?? null }) continue } const pub = Helpers.getPublicBookmarks(evt) const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at) publicItemsAll.push( ...processedPub.map(i => ({ ...i, sourceKind: evt.kind, setName: dTag, setTitle, setDescription, setImage })) ) // Schedule decrypt if needed // Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags const hasNip04Content = evt.content && evt.content.includes('?iv=') const needsDecrypt = signerCandidate && ( (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) || Helpers.hasHiddenContent(evt) || hasNip04Content ) if (needsDecrypt) { decryptJobs.push({ evt, metadata }) } else { // Check for already-unlocked hidden bookmarks const priv = Helpers.getHiddenBookmarks(evt) if (priv) { publicItemsAll.push( ...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({ ...i, sourceKind: evt.kind, setName: dTag, setTitle, setDescription, setImage })) ) } } } // Decrypt events sequentially const privateItemsAll: IndividualBookmark[] = [] if (decryptJobs.length > 0 && signerCandidate) { for (const job of decryptJobs) { const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata) if (privateItems && privateItems.length > 0) { privateItemsAll.push(...privateItems) } } } return { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } }