mirror of
https://github.com/dergigi/boris.git
synced 2026-01-18 06:14:27 +01:00
186 lines
5.2 KiB
TypeScript
186 lines
5.2 KiB
TypeScript
import { NostrEvent, nip19 } from 'nostr-tools'
|
|
import { ReadItem } from './readsService'
|
|
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
|
import { KINDS } from '../config/kinds'
|
|
|
|
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
|
|
|
|
interface ReadArticle {
|
|
id: string
|
|
url?: string
|
|
eventId?: string
|
|
eventKind?: number
|
|
markedAt: number
|
|
}
|
|
|
|
/**
|
|
* Processes reading progress events (kind 39802) into ReadItems
|
|
*
|
|
* Test scenarios:
|
|
* - Kind 39802 with d="30023:..." → article ReadItem with naddr id
|
|
* - Kind 39802 with d="url:..." → external ReadItem with decoded URL
|
|
* - Newer event.created_at overwrites older timestamp
|
|
* - Invalid d tag format → skip event
|
|
* - Malformed JSON content → skip event
|
|
*/
|
|
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 || 0
|
|
|
|
// Validate progress is between 0 and 1 (NIP-85 requirement)
|
|
if (position < 0 || position > 1) {
|
|
continue
|
|
}
|
|
|
|
// Use event.created_at as authoritative timestamp (NIP-85 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) {
|
|
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) {
|
|
continue
|
|
}
|
|
} else {
|
|
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) {
|
|
// Silently fail
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes marked-as-read articles into ReadItems
|
|
*/
|
|
export function processMarkedAsRead(
|
|
articles: ReadArticle[],
|
|
readsMap: Map<string, ReadItem>
|
|
): void {
|
|
for (const article of articles) {
|
|
const existing = readsMap.get(article.id)
|
|
|
|
if (article.eventId && article.eventKind === 30023) {
|
|
// Nostr article
|
|
readsMap.set(article.id, {
|
|
...existing,
|
|
id: article.id,
|
|
source: 'marked-as-read',
|
|
type: 'article',
|
|
markedAsRead: true,
|
|
markedAt: article.markedAt,
|
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
|
})
|
|
} else if (article.url) {
|
|
// External URL
|
|
readsMap.set(article.id, {
|
|
...existing,
|
|
id: article.id,
|
|
source: 'marked-as-read',
|
|
type: 'external',
|
|
url: article.url,
|
|
markedAsRead: true,
|
|
markedAt: article.markedAt,
|
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sorts ReadItems by most recent reading activity
|
|
*/
|
|
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
|
|
return items.sort((a, b) => {
|
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
|
const timeB = b.readingTimestamp || b.markedAt || 0
|
|
return timeB - timeA
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Filters out items without timestamps and enriches external items with fallback titles
|
|
*/
|
|
export function filterValidItems(items: ReadItem[]): ReadItem[] {
|
|
return items
|
|
.filter(item => {
|
|
// Only include items that have a timestamp
|
|
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
|
|
(item.markedAt && item.markedAt > 0)
|
|
if (!hasTimestamp) return false
|
|
|
|
// For Nostr articles, we need the event to be valid
|
|
if (item.type === 'article' && !item.event) return false
|
|
|
|
// For external URLs, we need at least a URL
|
|
if (item.type === 'external' && !item.url) return false
|
|
|
|
return true
|
|
})
|
|
.map(item => {
|
|
// Add fallback title for external URLs without titles
|
|
if (item.type === 'external' && !item.title && item.url) {
|
|
return { ...item, title: fallbackTitleFromUrl(item.url) }
|
|
}
|
|
return item
|
|
})
|
|
}
|
|
|