diff --git a/public/md/NIP-39802.md b/public/md/NIP-39802.md new file mode 100644 index 00000000..98efe1fe --- /dev/null +++ b/public/md/NIP-39802.md @@ -0,0 +1,179 @@ +# NIP-39802 + +## Reading Progress + +`draft` `optional` + +This NIP defines a parameterized replaceable event kind for tracking reading progress across articles and web content. + +## Event Kind + +- `39802`: Reading Progress (Parameterized Replaceable) + +## Event Structure + +Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content. + +### Tags + +- `d` (required): Unique identifier for the target content + - For Nostr articles: `30023::` (matching the article's coordinate) + - For external URLs: `url:` +- `a` (optional but recommended for Nostr articles): Article coordinate `30023::` +- `r` (optional but recommended for URLs): Raw URL of the external content +- `client` (optional): Client application identifier + +### Content + +The content is a JSON object with the following fields: + +- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed) +- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.) +- `ts` (optional): Unix timestamp (seconds) when the progress was recorded. This is for display purposes only; event ordering MUST use `created_at` +- `ver` (optional): Schema version string (e.g., "1") + +### Semantics + +- The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics) +- Clients SHOULD implement rate limiting to avoid excessive relay traffic: + - Debounce writes (recommended: 5 seconds) + - Only save when progress changes significantly (recommended: ≥1% delta) + - Skip saving very early progress (recommended: <5%) + - Always save on completion (progress = 1) and when unmounting/closing content +- The `created_at` timestamp SHOULD match the time the progress was observed +- Event ordering and replaceability MUST use `created_at`, not the optional `ts` field in content + +## Examples + +### Nostr Article Progress + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635012, + "content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}", + "tags": [ + ["d", "30023::"], + ["a", "30023::"], + ["client", "boris"] + ], + "id": "", + "sig": "" +} +``` + +### External URL Progress + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635999, + "content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}", + "tags": [ + ["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"], + ["r", "https://example.com/post"], + ["client", "boris"] + ], + "id": "", + "sig": "" +} +``` + +## Querying + +### All progress for a user + +```json +{ + "kinds": [39802], + "authors": [""] +} +``` + +### Progress for a specific Nostr article + +```json +{ + "kinds": [39802], + "authors": [""], + "#d": ["30023::"] +} +``` + +Or using the `a` tag: + +```json +{ + "kinds": [39802], + "authors": [""], + "#a": ["30023::"] +} +``` + +### Progress for a specific URL + +```json +{ + "kinds": [39802], + "authors": [""], + "#r": ["https://example.com/post"] +} +``` + +## Privacy Considerations + +Reading progress events are public by default to enable interoperability between clients. Users concerned about privacy should: + +- Use clients that allow disabling progress sync +- Use clients that allow selective relay publishing +- Be aware that reading progress reveals their reading habits + +A future extension could define an encrypted variant for private progress tracking, but that is out of scope for this NIP. + +## Rationale + +### Why a dedicated kind instead of NIP-78 application data? + +While NIP-78 (kind 30078) can store arbitrary application data, a dedicated kind offers several advantages: + +1. **Discoverability**: Other clients can easily find and display reading progress without knowing application-specific `d` tag conventions +2. **Interoperability**: Standard schema enables cross-client compatibility +3. **Indexing**: Relays can efficiently index and query reading progress separately from other app data +4. **Semantics**: Clear, well-defined meaning for the event kind + +### Why parameterized replaceable (NIP-33)? + +- Each article/URL needs exactly one current progress value per user +- Automatic deduplication by relays reduces storage and bandwidth +- Simple last-write-wins semantics based on `created_at` +- Efficient querying by `d` tag + +### Why include both `d` and `a`/`r` tags? + +- `d` provides the unique key for replaceability +- `a` and `r` enable efficient filtering without parsing `d` values +- Redundancy improves relay compatibility and query flexibility + +## Implementation Notes + +- Clients SHOULD use the event's `created_at` as the authoritative timestamp for sorting and merging progress +- The optional `ts` field in content is for display purposes only (e.g., "Last read 2 hours ago") +- For URLs, the base64url encoding in the `d` tag MUST use URL-safe characters (replace `+` with `-`, `/` with `_`, remove padding `=`) +- Clients SHOULD validate that `progress` is between 0 and 1 + +## Migration from NIP-78 + +Clients currently using NIP-78 (kind 30078) for reading progress can migrate by: + +1. **Dual-write phase**: Publish both kind 39802 and legacy kind 30078 events +2. **Dual-read phase**: Read from kind 39802 first, fall back to kind 30078 +3. **Cleanup phase**: After a grace period, stop writing kind 30078 but continue reading for backward compatibility + +## References + +- [NIP-01: Basic protocol flow](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [NIP-78: Application Data](https://github.com/nostr-protocol/nips/blob/master/78.md) + diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 28e08ad2..19013861 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -177,12 +177,16 @@ const ContentPanel: React.FC = ({ position, timestamp: Math.floor(Date.now() / 1000), scrollTop: window.pageYOffset || document.documentElement.scrollTop + }, + { + useProgressKind: settings?.useReadingProgressKind !== false, + writeLegacy: settings?.writeLegacyReadingPosition !== false } ) } catch (error) { console.error('❌ [ContentPanel] Failed to save reading position:', error) } - }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) + }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.useReadingProgressKind, settings?.writeLegacyReadingPosition, selectedUrl]) const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, @@ -221,7 +225,10 @@ const ContentPanel: React.FC = ({ relayPool, eventStore, activeAccount.pubkey, - articleIdentifier + articleIdentifier, + { + useProgressKind: settings?.useReadingProgressKind !== false + } ) if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { diff --git a/src/config/kinds.ts b/src/config/kinds.ts index 4221a07d..7f567f8d 100644 --- a/src/config/kinds.ts +++ b/src/config/kinds.ts @@ -2,7 +2,8 @@ export const KINDS = { Highlights: 9802, // NIP-?? user highlights BlogPost: 30023, // NIP-23 long-form article - AppData: 30078, // NIP-78 application data (reading positions) + AppData: 30078, // NIP-78 application data (legacy reading positions) + ReadingProgress: 39802, // NIP-39802 reading progress List: 30001, // NIP-51 list (addressable) ListReplaceable: 30003, // NIP-51 replaceable list ListSimple: 10003, // NIP-51 simple list diff --git a/src/services/linksService.ts b/src/services/linksService.ts index 401cec12..d1a940f8 100644 --- a/src/services/linksService.ts +++ b/src/services/linksService.ts @@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { ReadItem } from './readsService' -import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' /** * Fetches external URL links with reading progress from: - * - URLs with reading progress (kind:30078) + * - URLs with reading progress (kind:39802 and legacy kind:30078) * - Manually marked as read URLs (kind:7, kind:17) */ export async function fetchLinks( @@ -32,18 +32,32 @@ export async function fetchLinks( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ + // Query both new kind 39802 and legacy kind 30078 + const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Links] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, + legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length }) - // Process reading positions and emit external items - processReadingPositions(readingPositionEvents, linksMap) + // Process new reading progress events (kind 39802) first + processReadingProgress(progressEvents, linksMap) + if (onItem) { + linksMap.forEach(item => { + if (item.type === 'external') { + const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead + if (hasProgress) emitItem(item) + } + }) + } + + // Process legacy reading positions (kind 30078) - won't override newer 39802 data + processReadingPositions(legacyPositionEvents, linksMap) if (onItem) { linksMap.forEach(item => { if (item.type === 'external') { diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index a61c54ef..b6797361 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -1,8 +1,10 @@ -import { NostrEvent } from 'nostr-tools' +import { NostrEvent, nip19 } from 'nostr-tools' import { ReadItem } from './readsService' import { fallbackTitleFromUrl } from '../utils/readItemMerge' +import { KINDS } from '../config/kinds' const READING_POSITION_PREFIX = 'boris:reading-position:' +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 interface ReadArticle { id: string @@ -13,7 +15,86 @@ interface ReadArticle { } /** - * Processes reading position events into ReadItems + * Processes reading progress events (kind 39802) into ReadItems + */ +export function processReadingProgress( + events: NostrEvent[], + readsMap: Map +): void { + for (const event of events) { + if (event.kind !== READING_PROGRESS_KIND) continue + + const dTag = event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag) continue + + try { + const content = JSON.parse(event.content) + const position = content.progress || content.position || 0 + // Use event.created_at as authoritative timestamp (NIP-39802 spec) + const timestamp = event.created_at + + let itemId: string + let itemUrl: string | undefined + let itemType: 'article' | 'external' = 'external' + + // Check if d tag is a coordinate (30023:pubkey:identifier) + if (dTag.startsWith('30023:')) { + // It's a nostr article coordinate + const parts = dTag.split(':') + if (parts.length === 3) { + // Convert to naddr for consistency with the rest of the app + try { + const naddr = nip19.naddrEncode({ + kind: parseInt(parts[0]), + pubkey: parts[1], + identifier: parts[2] + }) + itemId = naddr + itemType = 'article' + } catch (e) { + console.warn('Failed to encode naddr from coordinate:', dTag) + continue + } + } else { + continue + } + } else if (dTag.startsWith('url:')) { + // It's a URL with base64url encoding + const encoded = dTag.replace('url:', '') + try { + itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/')) + itemId = itemUrl + itemType = 'external' + } catch (e) { + console.warn('Failed to decode URL from d tag:', dTag) + continue + } + } else { + // Unknown format, skip + continue + } + + // Add or update the item, preferring newer timestamps + const existing = readsMap.get(itemId) + if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { + readsMap.set(itemId, { + ...existing, + id: itemId, + source: 'reading-progress', + type: itemType, + url: itemUrl, + readingProgress: position, + readingTimestamp: timestamp + }) + } + } catch (error) { + console.warn('Failed to parse reading progress event:', error) + } + } +} + +/** + * Processes legacy reading position events (kind 30078) into ReadItems */ export function processReadingPositions( events: NostrEvent[], @@ -28,7 +109,8 @@ export function processReadingPositions( try { const positionData = JSON.parse(event.content) const position = positionData.position - const timestamp = positionData.timestamp + // For legacy events, use content timestamp if available, otherwise created_at + const timestamp = positionData.timestamp || event.created_at let itemId: string let itemUrl: string | undefined diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 1d645c13..4a57c6be 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -1,13 +1,15 @@ import { IEventStore, mapEventsToStore } from 'applesauce-core' import { EventFactory } from 'applesauce-factory' import { RelayPool, onlyEvents } from 'applesauce-relay' -import { NostrEvent } from 'nostr-tools' +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 APP_DATA_KIND = 30078 // NIP-78 Application Data -const READING_POSITION_PREFIX = 'boris:reading-position:' +const APP_DATA_KIND = KINDS.AppData // 30078 - Legacy NIP-78 Application Data +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-39802 Reading Progress +const READING_POSITION_PREFIX = 'boris:reading-position:' // Legacy prefix export interface ReadingPosition { position: number // 0-1 scroll progress @@ -15,7 +17,14 @@ export interface ReadingPosition { scrollTop?: number // Optional: pixel position } -// Helper to extract and parse reading position from an event +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 position from legacy event (kind 30078) function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined { if (!event.content || event.content.length === 0) return undefined try { @@ -25,6 +34,67 @@ function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefin } } +// Helper to extract and parse reading progress from new 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 +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], ['client', 'boris']] + + // 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 @@ -43,80 +113,174 @@ export function generateArticleIdentifier(naddrOrUrl: string): string { } /** - * Save reading position to Nostr (Kind 30078) + * Save reading position to Nostr + * Supports both new kind 39802 and legacy kind 30078 (dual-write during migration) */ export async function saveReadingPosition( relayPool: RelayPool, eventStore: IEventStore, factory: EventFactory, articleIdentifier: string, - position: ReadingPosition + position: ReadingPosition, + options?: { + useProgressKind?: boolean // Default: true + writeLegacy?: boolean // Default: true (dual-write) + } ): Promise { + const useProgressKind = options?.useProgressKind !== false + const writeLegacy = options?.writeLegacy !== false + console.log('💾 [ReadingPosition] Saving position:', { identifier: articleIdentifier.slice(0, 32) + '...', position: position.position, positionPercent: Math.round(position.position * 100) + '%', timestamp: position.timestamp, - scrollTop: position.scrollTop + scrollTop: position.scrollTop, + useProgressKind, + writeLegacy }) - const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const now = Math.floor(Date.now() / 1000) - const draft = await factory.create(async () => ({ - kind: APP_DATA_KIND, - content: JSON.stringify(position), - tags: [ - ['d', dTag], - ['client', 'boris'] - ], - created_at: Math.floor(Date.now() / 1000) - })) + // Write new kind 39802 (preferred) + if (useProgressKind) { + const progressContent: ReadingProgressContent = { + progress: position.position, + ts: position.timestamp, + loc: position.scrollTop, + ver: '1' + } + + const tags = generateProgressTags(articleIdentifier) + + const draft = await factory.create(async () => ({ + kind: READING_PROGRESS_KIND, + content: JSON.stringify(progressContent), + tags, + created_at: now + })) - const signed = await factory.sign(draft) + const signed = await factory.sign(draft) + await publishEvent(relayPool, eventStore, signed) + + console.log('✅ [ReadingProgress] Saved kind 39802, event ID:', signed.id.slice(0, 8)) + } - // Use unified write service - await publishEvent(relayPool, eventStore, signed) + // Write legacy kind 30078 (for backward compatibility) + if (writeLegacy) { + const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + + const legacyDraft = await factory.create(async () => ({ + kind: APP_DATA_KIND, + content: JSON.stringify(position), + tags: [ + ['d', legacyDTag], + ['client', 'boris'] + ], + created_at: now + })) - console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8)) + const legacySigned = await factory.sign(legacyDraft) + await publishEvent(relayPool, eventStore, legacySigned) + + console.log('✅ [ReadingPosition] Saved legacy kind 30078, event ID:', legacySigned.id.slice(0, 8)) + } } /** * Load reading position from Nostr + * Tries new kind 39802 first, falls back to legacy kind 30078 */ export async function loadReadingPosition( relayPool: RelayPool, eventStore: IEventStore, pubkey: string, - articleIdentifier: string + articleIdentifier: string, + options?: { + useProgressKind?: boolean // Default: true + } ): Promise { - const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const useProgressKind = options?.useProgressKind !== false + const progressDTag = generateDTag(articleIdentifier) + const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` console.log('📖 [ReadingPosition] Loading position:', { pubkey: pubkey.slice(0, 8) + '...', identifier: articleIdentifier.slice(0, 32) + '...', - dTag: dTag.slice(0, 50) + '...' + progressDTag: progressDTag.slice(0, 50) + '...', + legacyDTag: legacyDTag.slice(0, 50) + '...' }) - // First, check if we already have the position in the local event store + // Try new kind 39802 first (if enabled) + if (useProgressKind) { + try { + const localEvent = await firstValueFrom( + eventStore.replaceable(READING_PROGRESS_KIND, pubkey, progressDTag) + ) + if (localEvent) { + const content = getReadingProgressContent(localEvent) + if (content) { + console.log('✅ [ReadingProgress] Loaded kind 39802 from local store:', { + position: content.position, + positionPercent: Math.round(content.position * 100) + '%', + timestamp: content.timestamp + }) + + // Fetch from relays in background + relayPool + .subscription(RELAYS, { + kinds: [READING_PROGRESS_KIND], + authors: [pubkey], + '#d': [progressDTag] + }) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe() + + return content + } + } + } catch (err) { + console.log('📭 No cached kind 39802 found, trying relays...') + } + + // Try fetching kind 39802 from relays + const progressResult = await fetchFromRelays( + relayPool, + eventStore, + pubkey, + READING_PROGRESS_KIND, + progressDTag, + getReadingProgressContent + ) + + if (progressResult) { + console.log('✅ [ReadingProgress] Loaded kind 39802 from relays') + return progressResult + } + } + + // Fall back to legacy kind 30078 + console.log('📭 No kind 39802 found, trying legacy kind 30078...') + try { const localEvent = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + eventStore.replaceable(APP_DATA_KIND, pubkey, legacyDTag) ) if (localEvent) { const content = getReadingPositionContent(localEvent) if (content) { - console.log('✅ [ReadingPosition] Loaded from local store:', { + console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from local store:', { position: content.position, positionPercent: Math.round(content.position * 100) + '%', timestamp: content.timestamp }) - // Still fetch from relays in the background to get any updates + // Fetch from relays in background relayPool .subscription(RELAYS, { kinds: [APP_DATA_KIND], authors: [pubkey], - '#d': [dTag] + '#d': [legacyDTag] }) .pipe(onlyEvents(), mapEventsToStore(eventStore)) .subscribe() @@ -125,23 +289,49 @@ export async function loadReadingPosition( } } } catch (err) { - console.log('📭 No cached reading position found, fetching from relays...') + console.log('📭 No cached legacy position found, trying relays...') } - // If not in local store, fetch from relays + // Try fetching legacy from relays + const legacyResult = await fetchFromRelays( + relayPool, + eventStore, + pubkey, + APP_DATA_KIND, + legacyDTag, + getReadingPositionContent + ) + + if (legacyResult) { + console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from relays') + return legacyResult + } + + console.log('📭 No reading position 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 { return new Promise((resolve) => { let hasResolved = false const timeout = setTimeout(() => { if (!hasResolved) { - console.log('⏱️ Reading position load timeout - no position found') hasResolved = true resolve(null) } - }, 3000) // Shorter timeout for reading positions + }, 3000) const sub = relayPool .subscription(RELAYS, { - kinds: [APP_DATA_KIND], + kinds: [kind], authors: [pubkey], '#d': [dTag] }) @@ -153,33 +343,20 @@ export async function loadReadingPosition( hasResolved = true try { const event = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + eventStore.replaceable(kind, pubkey, dTag) ) if (event) { - const content = getReadingPositionContent(event) - if (content) { - console.log('✅ [ReadingPosition] Loaded from relays:', { - position: content.position, - positionPercent: Math.round(content.position * 100) + '%', - timestamp: content.timestamp - }) - resolve(content) - } else { - console.log('⚠️ [ReadingPosition] Event found but no valid content') - resolve(null) - } + const content = parser(event) + resolve(content || null) } else { - console.log('📭 [ReadingPosition] No position found on relays') resolve(null) } } catch (err) { - console.error('❌ Error loading reading position:', err) resolve(null) } } }, - error: (err) => { - console.error('❌ Reading position subscription error:', err) + error: () => { clearTimeout(timeout) if (!hasResolved) { hasResolved = true diff --git a/src/services/readsService.ts b/src/services/readsService.ts index f54adc9b..65d03df2 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { nip19 } from 'nostr-tools' -import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -61,19 +61,30 @@ export async function fetchAllReads( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ + // Query both new kind 39802 and legacy kind 30078 + const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Reads] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, + legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length, bookmarks: bookmarks.length }) - // Process reading positions and emit items - processReadingPositions(readingPositionEvents, readsMap) + // Process new reading progress events (kind 39802) first + processReadingProgress(progressEvents, readsMap) + if (onItem) { + readsMap.forEach(item => { + if (item.type === 'article') onItem(item) + }) + } + + // Process legacy reading positions (kind 30078) - won't override newer 39802 data + processReadingPositions(legacyPositionEvents, readsMap) if (onItem) { readsMap.forEach(item => { if (item.type === 'article') onItem(item) diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 54c278a6..62cf8920 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -60,6 +60,9 @@ export interface UserSettings { paragraphAlignment?: 'left' | 'justify' // default: justify // Reading position sync syncReadingPosition?: boolean // default: false (opt-in) + // Reading progress migration (internal flag) + useReadingProgressKind?: boolean // default: true (use kind 39802) + writeLegacyReadingPosition?: boolean // default: true (dual-write during migration) } export async function loadSettings(