From e383356af199dbd0b653f0049ee6ab9a3d2f84b6 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 00:45:16 +0200 Subject: [PATCH] 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 --- src/App.tsx | 2 +- src/components/Bookmarks.tsx | 2 +- src/components/Me.tsx | 126 ++++++++++----- src/services/meCache.ts | 12 +- src/services/readsService.ts | 292 +++++++++++++++++++++++++++++++++++ 5 files changed, 387 insertions(+), 47 deletions(-) create mode 100644 src/services/readsService.ts diff --git a/src/App.tsx b/src/App.tsx index 942b8f20..3350cc39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,7 +112,7 @@ function AppRoutes({ } /> = ({ relayPool, onLogout }) => { const meTab = location.pathname === '/me' ? 'highlights' : location.pathname === '/me/highlights' ? 'highlights' : location.pathname === '/me/reading-list' ? 'reading-list' : - location.pathname === '/me/archive' ? 'archive' : + location.pathname === '/me/reads' ? 'reads' : location.pathname === '/me/writings' ? 'writings' : 'highlights' // Extract tab from profile routes diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 66fe5a35..72dd9e20 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -10,7 +10,7 @@ import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' import { fetchBookmarks } from '../services/bookmarkService' -import { fetchReadArticlesWithData } from '../services/libraryService' +import { fetchAllReads, ReadItem } from '../services/readsService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { RELAYS } from '../config/relays' import { Bookmark, IndividualBookmark } from '../types/bookmarks' @@ -34,7 +34,7 @@ interface MeProps { 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 = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const activeAccount = Hooks.useActiveAccount() @@ -46,7 +46,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey) const [highlights, setHighlights] = useState([]) const [bookmarks, setBookmarks] = useState([]) - const [readArticles, setReadArticles] = useState([]) + const [reads, setReads] = useState([]) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) const [viewMode, setViewMode] = useState('cards') @@ -77,7 +77,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr if (cached) { setHighlights(cached.highlights) setBookmarks(cached.bookmarks) - setReadArticles(cached.readArticles) + setReads(cached.reads || []) } } @@ -92,9 +92,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Only fetch private data for own profile if (isOwnProfile && activeAccount) { - const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey) - setReadArticles(userReadArticles) - // Fetch bookmarks using callback pattern let fetchedBookmarks: Bookmark[] = [] try { @@ -107,11 +104,15 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr setBookmarks([]) } + // Fetch all reads + const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks) + setReads(userReads) + // Update cache with all fetched data - setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles) + setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReads) } else { setBookmarks([]) - setReadArticles([]) + setReads([]) } } catch (err) { console.error('Failed to load data:', err) @@ -156,6 +157,54 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr 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 }) => { if (bookmark && bookmark.kind === 30023) { // For kind:30023 articles, navigate to the article route @@ -185,24 +234,23 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const groups = groupIndividualBookmarks(filteredBookmarks) // Apply reading progress filter - const filteredReadArticles = readArticles.filter(() => { - // All articles in readArticles are marked as read, so they're treated as 100% complete - // The filters are only useful for distinguishing between different completion states - // but since these are all marked as read, we only care about the 'all' and 'completed' filters + const filteredReads = reads.filter((item) => { + const progress = item.readingProgress || 0 + const isMarked = item.markedAsRead || false switch (readingProgressFilter) { case 'unopened': - // Marked articles are never "unopened" - return false + // No reading progress + return progress === 0 && !isMarked case 'started': - // Marked articles are never "started" - return false + // 0-10% reading progress + return progress > 0 && progress <= 0.10 && !isMarked case 'reading': - // Marked articles are never "in progress" - return false + // 11-94% reading progress + return progress > 0.10 && progress <= 0.94 && !isMarked case 'completed': - // All marked articles are considered completed - return true + // 95%+ or marked as read + return progress >= 0.95 || isMarked case 'all': default: return true @@ -216,7 +264,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ] // 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 renderTabContent = () => { @@ -326,7 +374,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) - case 'archive': + case 'reads': if (showSkeletons) { return (
@@ -336,32 +384,32 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) } - return readArticles.length === 0 && !loading ? ( + return reads.length === 0 && !loading ? (
- No articles in your archive. + No articles in your reads.
) : ( <> - {readArticles.length > 0 && ( + {reads.length > 0 && ( )} - {filteredReadArticles.length === 0 ? ( + {filteredReads.length === 0 ? (
No articles match this filter.
) : (
- {filteredReadArticles.map((post) => ( - - ))} + {filteredReads.map((item) => ( + + ))}
)} @@ -427,12 +475,12 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr Bookmarks )} diff --git a/src/services/meCache.ts b/src/services/meCache.ts index 53b59a6a..085d6ce4 100644 --- a/src/services/meCache.ts +++ b/src/services/meCache.ts @@ -1,11 +1,11 @@ import { Highlight } from '../types/highlights' import { Bookmark } from '../types/bookmarks' -import { BlogPostPreview } from './exploreService' +import { ReadItem } from './readsService' export interface MeCache { highlights: Highlight[] bookmarks: Bookmark[] - readArticles: BlogPostPreview[] + reads: ReadItem[] timestamp: number } @@ -21,12 +21,12 @@ export function setCachedMeData( pubkey: string, highlights: Highlight[], bookmarks: Bookmark[], - readArticles: BlogPostPreview[] + reads: ReadItem[] ): void { meCache.set(pubkey, { highlights, bookmarks, - readArticles, + reads, 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) if (existing) { - meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() }) + meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() }) } } diff --git a/src/services/readsService.ts b/src/services/readsService.ts new file mode 100644 index 00000000..ee9fab1d --- /dev/null +++ b/src/services/readsService.ts @@ -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 { + 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() + + // 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 +} +