refactor(services): extract helpers and event processing; keep files <210 lines; lint+types clean

This commit is contained in:
Gigi
2025-10-02 22:03:47 +02:00
parent 8967963535
commit 1226124566
4 changed files with 287 additions and 257 deletions

View File

@@ -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<string, NostrEvent>()
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<string, NostrEvent>()
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
}

View File

@@ -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<string, NostrEvent>
): 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<string>
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'
}

View File

@@ -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<string>
type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[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 }
}

View File

@@ -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<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
type DecryptFn = (pubkey: string, content: string) => Promise<string>
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<string, NostrEvent>()
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<string, NostrEvent>()
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