mirror of
https://github.com/dergigi/boris.git
synced 2025-12-20 08:04:30 +01:00
feat: rename Archive to Reads and expand functionality
- Create new readsService to aggregate all read content from multiple sources - Include bookmarked articles, reading progress tracked articles, and manually marked-as-read items - Update Me component to use new reads service - Update routes from /me/archive to /me/reads - Update meCache to use ReadItem[] instead of BlogPostPreview[] - Update filter logic to use actual reading progress data - Support both Nostr-native articles and external URLs in reads - Fetch and display article metadata from multiple sources - Sort by most recent reading activity
This commit is contained in:
@@ -112,7 +112,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/me/reads"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/archive' ? 'archive' :
|
location.pathname === '/me/reads' ? 'reads' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -34,7 +34,7 @@ interface MeProps {
|
|||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'writings'
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
@@ -46,7 +46,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
@@ -77,7 +77,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
setHighlights(cached.highlights)
|
setHighlights(cached.highlights)
|
||||||
setBookmarks(cached.bookmarks)
|
setBookmarks(cached.bookmarks)
|
||||||
setReadArticles(cached.readArticles)
|
setReads(cached.reads || [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +92,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
// Only fetch private data for own profile
|
// Only fetch private data for own profile
|
||||||
if (isOwnProfile && activeAccount) {
|
if (isOwnProfile && activeAccount) {
|
||||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
|
||||||
setReadArticles(userReadArticles)
|
|
||||||
|
|
||||||
// Fetch bookmarks using callback pattern
|
// Fetch bookmarks using callback pattern
|
||||||
let fetchedBookmarks: Bookmark[] = []
|
let fetchedBookmarks: Bookmark[] = []
|
||||||
try {
|
try {
|
||||||
@@ -107,11 +104,15 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
setBookmarks([])
|
setBookmarks([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all reads
|
||||||
|
const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks)
|
||||||
|
setReads(userReads)
|
||||||
|
|
||||||
// Update cache with all fetched data
|
// Update cache with all fetched data
|
||||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReads)
|
||||||
} else {
|
} else {
|
||||||
setBookmarks([])
|
setBookmarks([])
|
||||||
setReadArticles([])
|
setReads([])
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
@@ -156,6 +157,54 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
|
if (item.type === 'article' && item.event) {
|
||||||
|
const dTag = item.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: item.event.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
} else if (item.url) {
|
||||||
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||||
|
if (item.event) {
|
||||||
|
return {
|
||||||
|
event: item.event,
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || item.event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock event for external URLs
|
||||||
|
const mockEvent = {
|
||||||
|
id: item.id,
|
||||||
|
pubkey: item.author || '',
|
||||||
|
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [] as string[][],
|
||||||
|
content: item.title || item.url || 'Untitled',
|
||||||
|
sig: ''
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||||
|
title: item.title || item.url || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
// For kind:30023 articles, navigate to the article route
|
||||||
@@ -185,24 +234,23 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Apply reading progress filter
|
// Apply reading progress filter
|
||||||
const filteredReadArticles = readArticles.filter(() => {
|
const filteredReads = reads.filter((item) => {
|
||||||
// All articles in readArticles are marked as read, so they're treated as 100% complete
|
const progress = item.readingProgress || 0
|
||||||
// The filters are only useful for distinguishing between different completion states
|
const isMarked = item.markedAsRead || false
|
||||||
// but since these are all marked as read, we only care about the 'all' and 'completed' filters
|
|
||||||
|
|
||||||
switch (readingProgressFilter) {
|
switch (readingProgressFilter) {
|
||||||
case 'unopened':
|
case 'unopened':
|
||||||
// Marked articles are never "unopened"
|
// No reading progress
|
||||||
return false
|
return progress === 0 && !isMarked
|
||||||
case 'started':
|
case 'started':
|
||||||
// Marked articles are never "started"
|
// 0-10% reading progress
|
||||||
return false
|
return progress > 0 && progress <= 0.10 && !isMarked
|
||||||
case 'reading':
|
case 'reading':
|
||||||
// Marked articles are never "in progress"
|
// 11-94% reading progress
|
||||||
return false
|
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||||
case 'completed':
|
case 'completed':
|
||||||
// All marked articles are considered completed
|
// 95%+ or marked as read
|
||||||
return true
|
return progress >= 0.95 || isMarked
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -216,7 +264,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -326,7 +374,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'archive':
|
case 'reads':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -336,32 +384,32 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readArticles.length === 0 && !loading ? (
|
return reads.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles in your archive.
|
No articles in your reads.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{readArticles.length > 0 && (
|
{reads.length > 0 && (
|
||||||
<ReadingProgressFilters
|
<ReadingProgressFilters
|
||||||
selectedFilter={readingProgressFilter}
|
selectedFilter={readingProgressFilter}
|
||||||
onFilterChange={setReadingProgressFilter}
|
onFilterChange={setReadingProgressFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filteredReadArticles.length === 0 ? (
|
{filteredReads.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles match this filter.
|
No articles match this filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{filteredReadArticles.map((post) => (
|
{filteredReads.map((item) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={item.id}
|
||||||
post={post}
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
href={getPostUrl(post)}
|
href={getReadItemUrl(item)}
|
||||||
readingProgress={1.0}
|
readingProgress={item.readingProgress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -427,12 +475,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
data-tab="archive"
|
data-tab="reads"
|
||||||
onClick={() => navigate('/me/archive')}
|
onClick={() => navigate('/me/reads')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Archive</span>
|
<span className="tab-label">Reads</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
readArticles: BlogPostPreview[]
|
reads: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ export function setCachedMeData(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
highlights: Highlight[],
|
highlights: Highlight[],
|
||||||
bookmarks: Bookmark[],
|
bookmarks: Bookmark[],
|
||||||
readArticles: BlogPostPreview[]
|
reads: ReadItem[]
|
||||||
): void {
|
): void {
|
||||||
meCache.set(pubkey, {
|
meCache.set(pubkey, {
|
||||||
highlights,
|
highlights,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
readArticles,
|
reads,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
|
||||||
const existing = meCache.get(pubkey)
|
const existing = meCache.get(pubkey)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
292
src/services/readsService.ts
Normal file
292
src/services/readsService.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
export interface ReadItem {
|
||||||
|
id: string // event ID or URL or coordinate
|
||||||
|
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||||
|
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||||
|
|
||||||
|
// Article data
|
||||||
|
event?: NostrEvent
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author?: string
|
||||||
|
|
||||||
|
// Reading metadata
|
||||||
|
readingProgress?: number // 0-1
|
||||||
|
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||||
|
markedAsRead?: boolean
|
||||||
|
markedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all reads from multiple sources:
|
||||||
|
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||||
|
* - Articles/URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchAllReads(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Reads] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length,
|
||||||
|
bookmarks: bookmarks.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map to deduplicate items by ID
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// 1. Process reading position events
|
||||||
|
for (const event of readingPositionEvents) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
||||||
|
|
||||||
|
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const positionData = JSON.parse(event.content)
|
||||||
|
const position = positionData.position
|
||||||
|
const timestamp = positionData.timestamp
|
||||||
|
|
||||||
|
// Decode identifier to get original URL or naddr
|
||||||
|
let itemId: string
|
||||||
|
let itemUrl: string | undefined
|
||||||
|
let itemType: 'article' | 'external' = 'external'
|
||||||
|
|
||||||
|
// Check if it's a nostr article (naddr format)
|
||||||
|
if (identifier.startsWith('naddr1')) {
|
||||||
|
itemId = identifier
|
||||||
|
itemType = 'article'
|
||||||
|
} else {
|
||||||
|
// It's a base64url-encoded URL
|
||||||
|
try {
|
||||||
|
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
itemId = itemUrl
|
||||||
|
itemType = 'external'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode URL identifier:', identifier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update the item
|
||||||
|
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 position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process marked-as-read articles
|
||||||
|
for (const article of markedAsReadArticles) {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process bookmarked articles and article/website URLs
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles and external article/website bookmarks
|
||||||
|
if (bookmarkType === 'article') {
|
||||||
|
// Kind:30023 nostr article
|
||||||
|
const coordinate = bookmark.id // Already in coordinate format
|
||||||
|
const existing = readsMap.get(coordinate)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
readsMap.set(coordinate, {
|
||||||
|
id: coordinate,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (bookmarkType === 'external') {
|
||||||
|
// External article URL
|
||||||
|
const urls = extractUrlFromBookmark(bookmark)
|
||||||
|
if (urls.length > 0) {
|
||||||
|
const url = urls[0]
|
||||||
|
const existing = readsMap.get(url)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
readsMap.set(url, {
|
||||||
|
id: url,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'external',
|
||||||
|
url,
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch full event data for nostr articles
|
||||||
|
const articleCoordinates = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article' && !item.event)
|
||||||
|
.map(item => item.id)
|
||||||
|
|
||||||
|
if (articleCoordinates.length > 0) {
|
||||||
|
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||||
|
|
||||||
|
// Parse coordinates and fetch events
|
||||||
|
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||||
|
|
||||||
|
for (const coord of articleCoordinates) {
|
||||||
|
try {
|
||||||
|
// Try to decode as naddr
|
||||||
|
if (coord.startsWith('naddr1')) {
|
||||||
|
const decoded = nip19.decode(coord)
|
||||||
|
if (decoded.type === 'naddr' && decoded.data.kind === 30023) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
identifier: decoded.data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try coordinate format (kind:pubkey:identifier)
|
||||||
|
const parts = coord.split(':')
|
||||||
|
if (parts.length === 3 && parts[0] === '30023') {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode article coordinate:', coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articlesToFetch.length > 0) {
|
||||||
|
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
|
||||||
|
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [30023], authors, '#d': identifiers },
|
||||||
|
{ relayUrls: RELAYS }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Merge event data into ReadItems
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `30023:${event.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const item = readsMap.get(coordinate) || readsMap.get(event.id)
|
||||||
|
if (item) {
|
||||||
|
item.event = event
|
||||||
|
item.title = getArticleTitle(event) || 'Untitled'
|
||||||
|
item.summary = getArticleSummary(event)
|
||||||
|
item.image = getArticleImage(event)
|
||||||
|
item.published = getArticlePublished(event)
|
||||||
|
item.author = event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sort by most recent reading activity
|
||||||
|
const sortedReads = Array.from(readsMap.values())
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||||
|
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||||
|
return sortedReads
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all reads:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract URL from bookmark content
|
||||||
|
function extractUrlFromBookmark(bookmark: IndividualBookmark): string[] {
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
// Check for web bookmark (kind 39701) with 'd' tag
|
||||||
|
if (bookmark.kind === 39701) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
urls.push(dTag.startsWith('http') ? dTag : `https://${dTag}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract URLs from content
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||||
|
const matches = bookmark.content.match(urlRegex)
|
||||||
|
if (matches) {
|
||||||
|
urls.push(...matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user