mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 07:54:59 +01:00
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:
179
public/md/NIP-39802.md
Normal file
179
public/md/NIP-39802.md
Normal 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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user