Files
boris/src/services/bookmarkHelpers.ts
Gigi 0f5d42465d debug: add detailed logging to hydrateItems
- Log which kind:1 items are being processed
- Show how many match events in the idToEvent map
- Compare sample IDs from items vs map keys
- Identify ID mismatch issue between bookmarks and fetched events
2025-10-22 00:08:47 +02:00

260 lines
8.0 KiB
TypeScript

import { getParsedContent } from 'applesauce-content/text'
import { Helpers } from 'applesauce-core'
import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks'
import type { NostrEvent } from './bookmarkEvents'
const { getArticleTitle } = Helpers
// 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 AddressPointer {
kind: number
pubkey: string
identifier: string
relays?: string[]
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
}
export interface ApplesauceBookmarks {
notes?: EventPointer[]
articles?: AddressPointer[]
hashtags?: string[]
urls?: string[]
}
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,
parentCreatedAt?: number
): IndividualBookmark[] => {
if (!bookmarks) return []
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
const allItems: IndividualBookmark[] = []
// Process notes (EventPointer[])
if (applesauceBookmarks.notes) {
applesauceBookmarks.notes.forEach((note: EventPointer) => {
allItems.push({
id: note.id,
content: '',
created_at: parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
})
})
}
// Process articles (AddressPointer[])
if (applesauceBookmarks.articles) {
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
allItems.push({
id: coordinate,
content: '',
created_at: parentCreatedAt || 0,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
})
})
}
// Process hashtags (string[])
if (applesauceBookmarks.hashtags) {
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
})
})
}
// Process URLs (string[])
if (applesauceBookmarks.urls) {
applesauceBookmarks.urls.forEach((url: string) => {
allItems.push({
id: `url-${url}`,
content: url,
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
})
})
}
return allItems
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || parentCreatedAt || 0
}))
}
// Types and guards around signer/decryption APIs
export function hydrateItems(
items: IndividualBookmark[],
idToEvent: Map<string, NostrEvent>
): IndividualBookmark[] {
// Debug: log what we're trying to hydrate
const kind1Items = items.filter(b => b.kind === 1)
if (kind1Items.length > 0 && idToEvent.size > 0) {
const found = kind1Items.filter(b => idToEvent.has(b.id))
console.log('💧 hydrateItems processing:', {
totalKind1Items: kind1Items.length,
mapSize: idToEvent.size,
found: found.length,
notFound: kind1Items.length - found.length,
sampleItemIds: kind1Items.slice(0, 2).map(b => b.id.slice(0, 12)),
sampleMapKeys: Array.from(idToEvent.keys()).slice(0, 2).map(id => id.slice(0, 12))
})
}
return items
.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
// Ensure all events with content get parsed content for proper rendering
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: parsedContent || item.parsedContent
}
})
.filter(item => {
// Filter out bookmark list events (they're containers, not content)
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
return !isBookmarkListEvent
})
}
// 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'
}
export function dedupeBookmarksById(bookmarks: IndividualBookmark[]): IndividualBookmark[] {
const seen = new Set<string>()
const result: IndividualBookmark[] = []
for (const b of bookmarks) {
if (!seen.has(b.id)) {
seen.add(b.id)
result.push(b)
}
}
return result
}
export function extractUrlsFromContent(content: string): string[] {
if (!content) return []
// Basic URL regex covering http(s) schemes
const urlRegex = /https?:\/\/[\w.-]+(?:\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?/gi
const matches = content.match(urlRegex)
if (!matches) return []
// Normalize by trimming trailing punctuation
return Array.from(new Set(matches.map(u => u.replace(/[),.;]+$/, ''))))
}