mirror of
https://github.com/dergigi/boris.git
synced 2026-01-10 02:14:56 +01:00
- Add logs in useReadingPosition: scroll position calculation (throttled to 5% changes) - Add logs for scheduling and triggering auto-save - Add detailed logs in ContentPanel handleSavePosition - Add logs in saveReadingPosition: event creation, signing, publishing - Add logs in publishEvent: event store addition, relay status, publishing - All logs prefixed with [progress] for easy filtering - Shows complete flow from scroll → calculate → save → create event → publish to relays
293 lines
8.1 KiB
TypeScript
293 lines
8.1 KiB
TypeScript
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
|
import { EventFactory } from 'applesauce-factory'
|
|
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
|
import { NostrEvent, nip19 } from 'nostr-tools'
|
|
import { firstValueFrom } from 'rxjs'
|
|
import { publishEvent } from './writeService'
|
|
import { RELAYS } from '../config/relays'
|
|
import { KINDS } from '../config/kinds'
|
|
|
|
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
|
|
|
|
export interface ReadingPosition {
|
|
position: number // 0-1 scroll progress
|
|
timestamp: number // Unix timestamp
|
|
scrollTop?: number // Optional: pixel position
|
|
}
|
|
|
|
export interface ReadingProgressContent {
|
|
progress: number // 0-1 scroll progress
|
|
ts?: number // Unix timestamp (optional, for display)
|
|
loc?: number // Optional: pixel position
|
|
ver?: string // Schema version
|
|
}
|
|
|
|
// Helper to extract and parse reading progress from event (kind 39802)
|
|
function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined {
|
|
if (!event.content || event.content.length === 0) return undefined
|
|
try {
|
|
const content = JSON.parse(event.content) as ReadingProgressContent
|
|
return {
|
|
position: content.progress,
|
|
timestamp: content.ts || event.created_at,
|
|
scrollTop: content.loc
|
|
}
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Generate d tag for kind 39802 based on target
|
|
// Test cases:
|
|
// - naddr1... → "30023:<pubkey>:<identifier>"
|
|
// - https://example.com/post → "url:<base64url>"
|
|
// - Invalid naddr → "url:<base64url>" (fallback)
|
|
function generateDTag(naddrOrUrl: string): string {
|
|
// If it's a nostr article (naddr format), decode and build coordinate
|
|
if (naddrOrUrl.startsWith('naddr1')) {
|
|
try {
|
|
const decoded = nip19.decode(naddrOrUrl)
|
|
if (decoded.type === 'naddr') {
|
|
return `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to decode naddr:', naddrOrUrl)
|
|
}
|
|
}
|
|
|
|
// For URLs, use url: prefix with base64url encoding
|
|
const base64url = btoa(naddrOrUrl)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '')
|
|
return `url:${base64url}`
|
|
}
|
|
|
|
// Generate tags for kind 39802 event
|
|
function generateProgressTags(naddrOrUrl: string): string[][] {
|
|
const dTag = generateDTag(naddrOrUrl)
|
|
const tags: string[][] = [['d', dTag]]
|
|
|
|
// Add 'a' tag for nostr articles
|
|
if (naddrOrUrl.startsWith('naddr1')) {
|
|
try {
|
|
const decoded = nip19.decode(naddrOrUrl)
|
|
if (decoded.type === 'naddr') {
|
|
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
|
tags.push(['a', coordinate])
|
|
}
|
|
} catch (e) {
|
|
// Ignore decode errors
|
|
}
|
|
} else {
|
|
// Add 'r' tag for URLs
|
|
tags.push(['r', naddrOrUrl])
|
|
}
|
|
|
|
return tags
|
|
}
|
|
|
|
/**
|
|
* Generate a unique identifier for an article
|
|
* For Nostr articles: use the naddr directly
|
|
* For external URLs: use base64url encoding of the URL
|
|
*/
|
|
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
|
// If it starts with "nostr:", extract the naddr
|
|
if (naddrOrUrl.startsWith('nostr:')) {
|
|
return naddrOrUrl.replace('nostr:', '')
|
|
}
|
|
// For URLs, use base64url encoding (URL-safe)
|
|
return btoa(naddrOrUrl)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '')
|
|
}
|
|
|
|
/**
|
|
* Save reading position to Nostr (kind 39802)
|
|
*/
|
|
export async function saveReadingPosition(
|
|
relayPool: RelayPool,
|
|
eventStore: IEventStore,
|
|
factory: EventFactory,
|
|
articleIdentifier: string,
|
|
position: ReadingPosition
|
|
): Promise<void> {
|
|
console.log('[progress] 💾 saveReadingPosition: Starting save:', {
|
|
identifier: articleIdentifier.slice(0, 50) + '...',
|
|
position: position.position,
|
|
positionPercent: Math.round(position.position * 100) + '%',
|
|
timestamp: position.timestamp,
|
|
scrollTop: position.scrollTop
|
|
})
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
const progressContent: ReadingProgressContent = {
|
|
progress: position.position,
|
|
ts: position.timestamp,
|
|
loc: position.scrollTop,
|
|
ver: '1'
|
|
}
|
|
|
|
const tags = generateProgressTags(articleIdentifier)
|
|
|
|
console.log('[progress] 📝 Creating event with:', {
|
|
kind: READING_PROGRESS_KIND,
|
|
content: progressContent,
|
|
tags: tags.map(t => `[${t.join(', ')}]`).join(', '),
|
|
created_at: now
|
|
})
|
|
|
|
const draft = await factory.create(async () => ({
|
|
kind: READING_PROGRESS_KIND,
|
|
content: JSON.stringify(progressContent),
|
|
tags,
|
|
created_at: now
|
|
}))
|
|
|
|
console.log('[progress] ✍️ Signing event...')
|
|
const signed = await factory.sign(draft)
|
|
|
|
console.log('[progress] 📡 Publishing event:', {
|
|
id: signed.id,
|
|
kind: signed.kind,
|
|
pubkey: signed.pubkey.slice(0, 8) + '...',
|
|
content: signed.content,
|
|
tags: signed.tags
|
|
})
|
|
|
|
await publishEvent(relayPool, eventStore, signed)
|
|
|
|
console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16))
|
|
}
|
|
|
|
/**
|
|
* Load reading position from Nostr (kind 39802)
|
|
*/
|
|
export async function loadReadingPosition(
|
|
relayPool: RelayPool,
|
|
eventStore: IEventStore,
|
|
pubkey: string,
|
|
articleIdentifier: string
|
|
): Promise<ReadingPosition | null> {
|
|
const dTag = generateDTag(articleIdentifier)
|
|
|
|
console.log('📖 [ReadingProgress] Loading position:', {
|
|
pubkey: pubkey.slice(0, 8) + '...',
|
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
|
dTag: dTag.slice(0, 50) + '...'
|
|
})
|
|
|
|
// Check local event store first
|
|
try {
|
|
const localEvent = await firstValueFrom(
|
|
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
|
)
|
|
if (localEvent) {
|
|
const content = getReadingProgressContent(localEvent)
|
|
if (content) {
|
|
console.log('✅ [ReadingProgress] Loaded from local store:', {
|
|
position: content.position,
|
|
positionPercent: Math.round(content.position * 100) + '%',
|
|
timestamp: content.timestamp
|
|
})
|
|
|
|
// Fetch from relays in background to get any updates
|
|
relayPool
|
|
.subscription(RELAYS, {
|
|
kinds: [READING_PROGRESS_KIND],
|
|
authors: [pubkey],
|
|
'#d': [dTag]
|
|
})
|
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
|
.subscribe()
|
|
|
|
return content
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log('📭 No cached reading progress found, fetching from relays...')
|
|
}
|
|
|
|
// Fetch from relays
|
|
const result = await fetchFromRelays(
|
|
relayPool,
|
|
eventStore,
|
|
pubkey,
|
|
READING_PROGRESS_KIND,
|
|
dTag,
|
|
getReadingProgressContent
|
|
)
|
|
|
|
if (result) {
|
|
console.log('✅ [ReadingProgress] Loaded from relays')
|
|
return result
|
|
}
|
|
|
|
console.log('📭 No reading progress found')
|
|
return null
|
|
}
|
|
|
|
// Helper function to fetch from relays with timeout
|
|
async function fetchFromRelays(
|
|
relayPool: RelayPool,
|
|
eventStore: IEventStore,
|
|
pubkey: string,
|
|
kind: number,
|
|
dTag: string,
|
|
parser: (event: NostrEvent) => ReadingPosition | undefined
|
|
): Promise<ReadingPosition | null> {
|
|
return new Promise((resolve) => {
|
|
let hasResolved = false
|
|
const timeout = setTimeout(() => {
|
|
if (!hasResolved) {
|
|
hasResolved = true
|
|
resolve(null)
|
|
}
|
|
}, 3000)
|
|
|
|
const sub = relayPool
|
|
.subscription(RELAYS, {
|
|
kinds: [kind],
|
|
authors: [pubkey],
|
|
'#d': [dTag]
|
|
})
|
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
|
.subscribe({
|
|
complete: async () => {
|
|
clearTimeout(timeout)
|
|
if (!hasResolved) {
|
|
hasResolved = true
|
|
try {
|
|
const event = await firstValueFrom(
|
|
eventStore.replaceable(kind, pubkey, dTag)
|
|
)
|
|
if (event) {
|
|
const content = parser(event)
|
|
resolve(content || null)
|
|
} else {
|
|
resolve(null)
|
|
}
|
|
} catch (err) {
|
|
resolve(null)
|
|
}
|
|
}
|
|
},
|
|
error: () => {
|
|
clearTimeout(timeout)
|
|
if (!hasResolved) {
|
|
hasResolved = true
|
|
resolve(null)
|
|
}
|
|
}
|
|
})
|
|
|
|
setTimeout(() => {
|
|
sub.unsubscribe()
|
|
}, 3000)
|
|
})
|
|
}
|
|
|