feat: implement NIP-39802 reading progress with dual-write migration

- Add kind 39802 (ReadingProgress) as dedicated parameterized replaceable event
- Create NIP-39802 specification document in public/md/
- Implement dual-write: publish both kind 39802 and legacy kind 30078
- Implement dual-read: prefer kind 39802, fall back to kind 30078
- Add migration flags to settings (useReadingProgressKind, writeLegacyReadingPosition)
- Update readingPositionService with new d-tag generation and tag helpers
- Add processReadingProgress() for kind 39802 events in readingDataProcessor
- Update readsService and linksService to query and process both kinds
- Use event.created_at as authoritative timestamp per NIP-39802 spec
- ContentPanel respects migration flags from settings
- Maintain backward compatibility during migration phase
This commit is contained in:
Gigi
2025-10-19 10:09:09 +02:00
parent 32b1286079
commit 7d373015b4
8 changed files with 542 additions and 68 deletions

179
public/md/NIP-39802.md Normal file
View File

@@ -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:<pubkey>:<identifier>` (matching the article's coordinate)
- For external URLs: `url:<base64url-encoded-url>`
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
- `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": "<user-pubkey>",
"created_at": 1734635012,
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
"tags": [
["d", "30023:<author-pubkey>:<article-identifier>"],
["a", "30023:<author-pubkey>:<article-identifier>"],
["client", "boris"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### External URL Progress
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635999,
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
"tags": [
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
["r", "https://example.com/post"],
["client", "boris"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
## Querying
### All progress for a user
```json
{
"kinds": [39802],
"authors": ["<user-pubkey>"]
}
```
### Progress for a specific Nostr article
```json
{
"kinds": [39802],
"authors": ["<user-pubkey>"],
"#d": ["30023:<author-pubkey>:<article-identifier>"]
}
```
Or using the `a` tag:
```json
{
"kinds": [39802],
"authors": ["<user-pubkey>"],
"#a": ["30023:<author-pubkey>:<article-identifier>"]
}
```
### Progress for a specific URL
```json
{
"kinds": [39802],
"authors": ["<user-pubkey>"],
"#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)

View File

@@ -177,12 +177,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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<ContentPanelProps> = ({
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier
articleIdentifier,
{
useProgressKind: settings?.useReadingProgressKind !== false
}
)
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {

View File

@@ -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

View File

@@ -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') {

View File

@@ -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<string, ReadItem>
): 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

View File

@@ -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<void> {
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<ReadingPosition | null> {
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<ReadingPosition | null> {
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

View File

@@ -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)

View File

@@ -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(