From 12261245665ebbc7d41a7d37df8a2c9fe3be9a65 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 2 Oct 2025 22:03:47 +0200 Subject: [PATCH] refactor(services): extract helpers and event processing; keep files <210 lines; lint+types clean --- src/services/bookmarkEvents.ts | 39 ++++ src/services/bookmarkHelpers.ts | 122 +++++++++++++ src/services/bookmarkProcessing.ts | 104 +++++++++++ src/services/bookmarkService.ts | 279 +++-------------------------- 4 files changed, 287 insertions(+), 257 deletions(-) create mode 100644 src/services/bookmarkEvents.ts create mode 100644 src/services/bookmarkHelpers.ts create mode 100644 src/services/bookmarkProcessing.ts diff --git a/src/services/bookmarkEvents.ts b/src/services/bookmarkEvents.ts new file mode 100644 index 00000000..002b3faf --- /dev/null +++ b/src/services/bookmarkEvents.ts @@ -0,0 +1,39 @@ +export interface NostrEvent { + id: string + kind: number + created_at: number + tags: string[][] + content: string + pubkey: string + sig: string +} + +export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] { + const byId = new Map() + for (const e of events) { + if (e?.id && !byId.has(e.id)) byId.set(e.id, e) + } + const unique = Array.from(byId.values()) + + const bookmarkLists = unique + .filter(e => e.kind === 10003 || e.kind === 30001) + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) + const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd')) + + const byD = new Map() + for (const e of unique) { + if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) { + const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || '' + const prev = byD.get(d) + if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e) + } + } + + const setsAndNamedLists = Array.from(byD.values()) + const out: NostrEvent[] = [] + if (latestBookmarkList) out.push(latestBookmarkList) + out.push(...setsAndNamedLists) + return out +} + + diff --git a/src/services/bookmarkHelpers.ts b/src/services/bookmarkHelpers.ts new file mode 100644 index 00000000..ada30053 --- /dev/null +++ b/src/services/bookmarkHelpers.ts @@ -0,0 +1,122 @@ +import { getParsedContent } from 'applesauce-content/text' +import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks' +import type { NostrEvent } from './bookmarkEvents' + +// Global symbol for caching hidden bookmark content on events +export const BookmarkHiddenSymbol = Symbol.for('bookmark-hidden') + +export interface BookmarkData { + id?: string + content?: string + created_at?: number + kind?: number + tags?: string[][] +} + +export interface ApplesauceBookmarks { + notes?: BookmarkData[] + articles?: BookmarkData[] + hashtags?: BookmarkData[] + urls?: BookmarkData[] +} + +export interface AccountWithExtension { + pubkey: string + signer?: unknown + nip04?: unknown + nip44?: unknown + [key: string]: unknown +} + +export function isAccountWithExtension(account: unknown): account is AccountWithExtension { + return ( + typeof account === 'object' && + account !== null && + 'pubkey' in account && + typeof (account as { pubkey?: unknown }).pubkey === 'string' + ) +} + +export function isHexId(id: unknown): id is string { + return typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id) +} +export type { NostrEvent } from './bookmarkEvents' +export { dedupeNip51Events } from './bookmarkEvents' + +export const processApplesauceBookmarks = ( + bookmarks: unknown, + activeAccount: ActiveAccount, + isPrivate: boolean +): IndividualBookmark[] => { + if (!bookmarks) return [] + + if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) { + const applesauceBookmarks = bookmarks as ApplesauceBookmarks + const allItems: BookmarkData[] = [] + if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes) + if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles) + if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags) + if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls) + return allItems.map((bookmark: BookmarkData) => ({ + id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`, + content: bookmark.content || '', + created_at: bookmark.created_at || Date.now(), + pubkey: activeAccount.pubkey, + kind: bookmark.kind || 30001, + tags: bookmark.tags || [], + parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined, + type: 'event' as const, + isPrivate + })) + } + + const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks] + return bookmarkArray.map((bookmark: BookmarkData) => ({ + id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`, + content: bookmark.content || '', + created_at: bookmark.created_at || Date.now(), + pubkey: activeAccount.pubkey, + kind: bookmark.kind || 30001, + tags: bookmark.tags || [], + parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined, + type: 'event' as const, + isPrivate + })) +} + +// Types and guards around signer/decryption APIs +export function hydrateItems( + items: IndividualBookmark[], + idToEvent: Map +): IndividualBookmark[] { + return items.map(item => { + const ev = idToEvent.get(item.id) + if (!ev) return item + return { + ...item, + content: ev.content || item.content || '', + created_at: ev.created_at || item.created_at, + kind: ev.kind || item.kind, + tags: ev.tags || item.tags, + parsedContent: ev.content ? (getParsedContent(ev.content) as ParsedContent) : item.parsedContent + } + }) +} + +// Note: event decryption/collection lives in `bookmarkProcessing.ts` + +export type DecryptFn = (pubkey: string, content: string) => Promise +export type UnlockSigner = unknown +export type UnlockMode = unknown + +export function hasNip44Decrypt(obj: unknown): obj is { nip44: { decrypt: DecryptFn } } { + const nip44 = (obj as { nip44?: unknown })?.nip44 as { decrypt?: unknown } | undefined + return typeof nip44?.decrypt === 'function' +} + +export function hasNip04Decrypt(obj: unknown): obj is { nip04: { decrypt: DecryptFn } } { + const nip04 = (obj as { nip04?: unknown })?.nip04 as { decrypt?: unknown } | undefined + return typeof nip04?.decrypt === 'function' +} + + diff --git a/src/services/bookmarkProcessing.ts b/src/services/bookmarkProcessing.ts new file mode 100644 index 00000000..b312ec19 --- /dev/null +++ b/src/services/bookmarkProcessing.ts @@ -0,0 +1,104 @@ +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] + +export async function collectBookmarksFromEvents( + bookmarkListEvents: NostrEvent[], + activeAccount: ActiveAccount, + signerCandidate?: unknown +): Promise<{ + publicItemsAll: IndividualBookmark[] + privateItemsAll: IndividualBookmark[] + newestCreatedAt: number + latestContent: string + allTags: string[][] +}> { + const publicItemsAll: IndividualBookmark[] = [] + const privateItemsAll: IndividualBookmark[] = [] + let newestCreatedAt = 0 + let latestContent = '' + let allTags: 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 pub = Helpers.getPublicBookmarks(evt) + publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false)) + + try { + if (Helpers.hasHiddenTags(evt) && Helpers.isHiddenTagsLocked(evt) && signerCandidate) { + try { + await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner) + } catch { + try { + await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode) + } catch { + // ignore + } + } + } else if (evt.content && evt.content.length > 0 && signerCandidate) { + let decryptedContent: string | undefined + try { + if (hasNip44Decrypt(signerCandidate)) { + decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt( + evt.pubkey, + evt.content + ) + } + } catch { + // ignore + } + + if (!decryptedContent) { + try { + if (hasNip04Decrypt(signerCandidate)) { + decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt( + evt.pubkey, + evt.content + ) + } + } catch { + // ignore + } + } + + if (decryptedContent) { + try { + const hiddenTags = JSON.parse(decryptedContent) as string[][] + const manualPrivate = Helpers.parseBookmarkTags(hiddenTags) + privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true)) + Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate) + Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent) + if (!latestContent) { + latestContent = decryptedContent + } + } catch { + // ignore + } + } + } + + const priv = Helpers.getHiddenBookmarks(evt) + if (priv) { + privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true)) + } + } catch { + // ignore individual event failures + } + } + + return { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } +} + + diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index b7b943e2..5a838877 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,140 +1,17 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' -import { getParsedContent } from 'applesauce-content/text' -import { Helpers } from 'applesauce-core' import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' -// Import the bookmark hidden symbol for caching -const BookmarkHiddenSymbol = Symbol.for("bookmark-hidden") -import { Bookmark, IndividualBookmark, ParsedContent, ActiveAccount } from '../types/bookmarks' - -interface BookmarkData { - id?: string - content?: string - created_at?: number - kind?: number - tags?: string[][] -} - -interface ApplesauceBookmarks { - notes?: BookmarkData[] - articles?: BookmarkData[] - hashtags?: BookmarkData[] - urls?: BookmarkData[] -} - -interface AccountWithExtension { pubkey: string; signer?: unknown; nip04?: unknown; nip44?: unknown; [key: string]: unknown } - -function isAccountWithExtension(account: unknown): account is AccountWithExtension { - return typeof account === 'object' && account !== null && 'pubkey' in account && typeof (account as { pubkey?: unknown }).pubkey === 'string' -} - -// Note: Using applesauce's built-in hidden content detection instead of custom logic -// Encrypted content detection is handled by applesauce's hasHiddenContent() function - -function isHexId(id: unknown): id is string { - return typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id) -} - -interface NostrEvent { - id: string - kind: number - created_at: number - tags: string[][] - content: string - pubkey: string - sig: string -} - -// Helper types and guards to avoid using `any` for signer-related behavior -type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags -type UnlockSigner = Parameters[1] -type UnlockMode = Parameters[2] -type DecryptFn = (pubkey: string, content: string) => Promise - -function hasNip44Decrypt(obj: unknown): obj is { nip44: { decrypt: DecryptFn } } { - const nip44 = (obj as { nip44?: unknown })?.nip44 as { decrypt?: unknown } | undefined - return typeof nip44?.decrypt === 'function' -} - -function hasNip04Decrypt(obj: unknown): obj is { nip04: { decrypt: DecryptFn } } { - const nip04 = (obj as { nip04?: unknown })?.nip04 as { decrypt?: unknown } | undefined - return typeof nip04?.decrypt === 'function' -} - -function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] { - const byId = new Map() - for (const e of events) { if (e?.id && !byId.has(e.id)) byId.set(e.id, e) } - const unique = Array.from(byId.values()) - - // Get the latest bookmark list (10003/30001) - default bookmark list without 'd' tag - const bookmarkLists = unique - .filter(e => e.kind === 10003 || e.kind === 30001) - .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - const latestBookmarkList = bookmarkLists.find(list => - !list.tags?.some((t: string[]) => t[0] === 'd') - ) - - // Group bookmark sets (30003) and named bookmark lists (10003/30001 with 'd' tag) by their 'd' identifier - const byD = new Map() - for (const e of unique) { - if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) { - const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || '' - const prev = byD.get(d) - if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e) - } - } - - const setsAndNamedLists = Array.from(byD.values()) - const out: NostrEvent[] = [] - - // Add the default bookmark list if it exists - if (latestBookmarkList) out.push(latestBookmarkList) - - // Add all bookmark sets and named bookmark lists - out.push(...setsAndNamedLists) - - return out -} - -const processApplesauceBookmarks = ( - bookmarks: unknown, - activeAccount: ActiveAccount, - isPrivate: boolean -): IndividualBookmark[] => { - if (!bookmarks) return [] - - if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) { - const applesauceBookmarks = bookmarks as ApplesauceBookmarks - const allItems: BookmarkData[] = [] - if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes) - if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles) - if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags) - if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls) - return allItems.map((bookmark: BookmarkData) => ({ - id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`, - content: bookmark.content || '', - created_at: bookmark.created_at || Date.now(), - pubkey: activeAccount.pubkey, - kind: bookmark.kind || 30001, - tags: bookmark.tags || [], - parsedContent: bookmark.content ? getParsedContent(bookmark.content) as ParsedContent : undefined, - type: 'event' as const, - isPrivate - })) - } - // Fallback: map array-like bookmarks - const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks] - return bookmarkArray.map((bookmark: BookmarkData) => ({ - id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`, - content: bookmark.content || '', - created_at: bookmark.created_at || Date.now(), - pubkey: activeAccount.pubkey, - kind: bookmark.kind || 30001, - tags: bookmark.tags || [], - parsedContent: bookmark.content ? getParsedContent(bookmark.content) as ParsedContent : undefined, - type: 'event' as const, - isPrivate - })) -} +import { + AccountWithExtension, + NostrEvent, + dedupeNip51Events, + hydrateItems, + isAccountWithExtension, + isHexId, + hasNip04Decrypt, + hasNip44Decrypt +} from './bookmarkHelpers' +import { Bookmark } from '../types/bookmarks' +import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' @@ -210,114 +87,11 @@ export const fetchBookmarks = async ( console.log('๐Ÿ”‘ Signer has nip04:', hasNip04Decrypt(signerCandidate)) console.log('๐Ÿ”‘ Signer has nip44:', hasNip44Decrypt(signerCandidate)) } - const publicItemsAll: IndividualBookmark[] = [] - const privateItemsAll: IndividualBookmark[] = [] - let newestCreatedAt = 0 - let latestContent = '' - let allTags: string[][] = [] - for (const evt of bookmarkListEvents) { - const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' - const firstFewTags = evt.tags?.slice(0, 3).map((t: string[]) => `${t[0]}:${t[1]?.slice(0, 8)}`).join(', ') || 'none' - - console.log('๐Ÿ“‹ Processing bookmark event:', { - id: evt.id?.slice(0, 8), - kind: evt.kind, - contentLength: evt.content?.length || 0, - contentPreview: evt.content?.slice(0, 50) + (evt.content?.length > 50 ? '...' : ''), - tagsCount: evt.tags?.length || 0, - hasHiddenContent: Helpers.hasHiddenContent(evt), - canHaveHiddenTags: Helpers.canHaveHiddenTags(evt.kind), - dTag: dTag, - firstFewTags: firstFewTags - }) - - 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) - // public - const pub = Helpers.getPublicBookmarks(evt) - publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false)) - // hidden - try { - console.log('๐Ÿ”’ Event has hidden tags:', Helpers.hasHiddenTags(evt)) - console.log('๐Ÿ”’ Hidden tags locked:', Helpers.isHiddenTagsLocked(evt)) - console.log('๐Ÿ”’ Signer candidate available:', !!signerCandidate) - console.log('๐Ÿ”’ Signer candidate type:', typeof signerCandidate) - console.log('๐Ÿ”’ Event kind supports hidden tags:', Helpers.canHaveHiddenTags(evt.kind)) - - // Try to unlock hidden content using applesauce's standard approach first - if (Helpers.hasHiddenTags(evt) && Helpers.isHiddenTagsLocked(evt) && signerCandidate) { - try { - console.log('๐Ÿ”“ Attempting to unlock hidden tags with signer...') - await Helpers.unlockHiddenTags(evt, signerCandidate as unknown as UnlockSigner) - console.log('โœ… Successfully unlocked hidden tags') - } catch (error) { - console.warn('โŒ Failed to unlock with default method, trying NIP-44:', error) - try { - await Helpers.unlockHiddenTags(evt, signerCandidate as unknown as UnlockSigner, 'nip44' as unknown as UnlockMode) - console.log('โœ… Successfully unlocked hidden tags with NIP-44') - } catch (nip44Error) { - console.error('โŒ Failed to unlock with NIP-44:', nip44Error) - } - } - } - // For events that have content but aren't recognized as supporting hidden tags (like kind 30001) - else if (evt.content && evt.content.length > 0 && signerCandidate) { - console.log('๐Ÿ”“ Attempting manual decryption for event with unrecognized kind...') - console.log('๐Ÿ“„ Content to decrypt:', evt.content.slice(0, 100) + '...') - - // Try NIP-44 first (common for bookmark lists), then fall back to NIP-04 - let decryptedContent: string | undefined - try { - if (hasNip44Decrypt(signerCandidate)) { - console.log('๐Ÿงช Trying NIP-44 decryption...') - decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content) - } - } catch (nip44Err) { - console.warn('โŒ NIP-44 manual decryption failed, will try NIP-04:', nip44Err) - } - - if (!decryptedContent) { - try { - if (hasNip04Decrypt(signerCandidate)) { - console.log('๐Ÿงช Trying NIP-04 decryption...') - decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content) - } - } catch (nip04Err) { - console.warn('โŒ NIP-04 manual decryption failed:', nip04Err) - } - } - - if (decryptedContent) { - console.log('โœ… Successfully decrypted content manually') - // Parse the decrypted content as JSON (should be array of tags) - try { - const hiddenTags = JSON.parse(decryptedContent) as string[][] - console.log('๐Ÿ“‹ Decrypted hidden tags:', hiddenTags.length, 'tags') - - // Turn tags into Bookmarks using applesauce helper, then add to private list immediately - const manualPrivate = Helpers.parseBookmarkTags(hiddenTags) - privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true)) - - // Cache on event for any downstream consumers/debugging - Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate) - Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent) - if (!latestContent) { latestContent = decryptedContent } - } catch (parseError) { - console.warn('โŒ Failed to parse decrypted content as JSON:', parseError) - } - } - } - - const priv = Helpers.getHiddenBookmarks(evt) - console.log('๐Ÿ” Hidden bookmarks found:', priv ? Object.keys(priv).map(k => `${k}: ${priv[k as keyof typeof priv]?.length || 0}`).join(', ') : 'none') - if (priv) { - privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true)) - } - } catch (error) { - console.warn('โŒ Failed to process hidden bookmarks for event:', evt.id, error) - } - } + const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents( + bookmarkListEvents, + activeAccount, + signerCandidate + ) const allItems = [...publicItemsAll, ...privateItemsAll] const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId))) @@ -332,19 +106,10 @@ export const fetchBookmarks = async ( console.warn('Failed to fetch events for hydration:', error) } } - const hydrateItems = (items: IndividualBookmark[]): IndividualBookmark[] => items.map(item => { - const ev = idToEvent.get(item.id) - if (!ev) return item - return { - ...item, - content: ev.content || item.content || '', - created_at: ev.created_at || item.created_at, - kind: ev.kind || item.kind, - tags: ev.tags || item.tags, - parsedContent: ev.content ? getParsedContent(ev.content) as ParsedContent : item.parsedContent - } - }) - const allBookmarks = [...hydrateItems(publicItemsAll), ...hydrateItems(privateItemsAll)] + const allBookmarks = [ + ...hydrateItems(publicItemsAll, idToEvent), + ...hydrateItems(privateItemsAll, idToEvent) + ] // Sort individual bookmarks by timestamp (newest first) const sortedBookmarks = allBookmarks.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) @@ -357,7 +122,7 @@ export const fetchBookmarks = async ( created_at: newestCreatedAt || Date.now(), tags: allTags, bookmarkCount: sortedBookmarks.length, - eventReferences: allTags.filter(tag => tag[0] === 'e').map(tag => tag[1]), + eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]), individualBookmarks: sortedBookmarks, isPrivate: privateItemsAll.length > 0, encryptedContent: undefined