diff --git a/.cursor/plans/rename-archive-to-reads-658dc3b5.plan.md b/.cursor/plans/rename-archive-to-reads-658dc3b5.plan.md new file mode 100644 index 00000000..09fe96a7 --- /dev/null +++ b/.cursor/plans/rename-archive-to-reads-658dc3b5.plan.md @@ -0,0 +1,136 @@ + +# Lazy Load Me Component Tabs + +## Overview + +Currently, the Me component loads all data for all tabs upfront, causing 30+ second load times even when viewing a single tab. This plan implements lazy loading where only the active tab's data is fetched on demand. + +## Implementation Strategy + +Based on user requirements: + +- Load only the active tab's data (pure lazy loading) +- No background prefetching +- Show cached data immediately, refresh in background when revisiting tabs +- Works for both `/me` (own profile) and `/p/` (other profiles) using the same code + +## Key Insight + +The Me component already handles both own profile and other profiles via the `isOwnProfile` flag. The lazy loading will naturally work for both cases: + +- Own profile (`/me`): Loads all tabs including private data (bookmarks, reads) +- Other profiles (`/p/npub...`): Only loads public tabs (highlights, writings) + +## Changes Required + +### 1. Update Me.tsx Loading Logic + +**Current behavior**: Single `useEffect` loads all data (highlights, writings, bookmarks, reads) regardless of active tab. + +**New behavior**: + +- Create separate loading functions per tab +- Load only active tab's data on mount and tab switches +- Show cached data immediately if available +- Refresh cached data in background when tab is revisited + +**Key changes**: + +- Remove the monolithic `loadData()` function +- Add `loadedTabs` state to track which tabs have been fetched +- Create tab-specific loaders: `loadHighlights()`, `loadWritings()`, `loadBookmarks()`, `loadReads()` +- Add `useEffect` that watches `activeTab` and loads data for current tab only +- Check cache first, display cached data, then refresh in background + +**Code location**: Lines 64-123 in `src/components/Me.tsx` + +### 2. Per-Tab Loading State + +Add tab-specific loading tracking: + +```typescript +const [loadedTabs, setLoadedTabs] = useState>(new Set()) +``` + +This prevents unnecessary reloads and allows showing cached data instantly. + +### 3. Tab-Specific Load Functions + +Create individual functions: + +- `loadHighlightsTab()` - fetch highlights +- `loadWritingsTab()` - fetch writings +- `loadReadingListTab()` - fetch bookmarks +- `loadReadsTab()` - fetch bookmarks first, then reads + +Each function: + +1. Checks cache, displays if available +2. Sets loading state +3. Fetches fresh data +4. Updates state and cache +5. Marks tab as loaded + +### 4. Tab Switch Effect + +Replace the current useEffect with: + +```typescript +useEffect(() => { + if (!activeTab || !viewingPubkey) return + + // Check if we have cached data + const cached = getCachedMeData(viewingPubkey) + if (cached) { + // Show cached data immediately + setHighlights(cached.highlights) + setBookmarks(cached.bookmarks) + setReads(cached.reads) + // Continue to refresh in background + } + + // Load data for active tab + switch (activeTab) { + case 'highlights': + loadHighlightsTab() + break + case 'writings': + loadWritingsTab() + break + case 'reading-list': + loadReadingListTab() + break + case 'reads': + loadReadsTab() + break + } +}, [activeTab, viewingPubkey, refreshTrigger]) +``` + +### 5. Handle Pull-to-Refresh + +Update pull-to-refresh logic to only reload the active tab instead of all tabs. + +## Benefits + +- Initial load: ~2-5s instead of 30+ seconds (only loads one tab) +- Tab switching: Instant with cached data, refreshes in background +- Network efficiency: Only fetches what the user views +- Better UX: Users see content immediately from cache + +## Testing Checklist + +- Verify each tab loads independently +- Confirm cached data shows immediately on tab switch +- Ensure background refresh works without flickering +- Test pull-to-refresh only reloads active tab +- Verify loading states per tab work correctly + +### To-dos + +- [ ] Create src/services/readsService.ts with fetchAllReads function +- [ ] Update Me.tsx to use reads instead of archive +- [ ] Update routes from /me/archive to /me/reads +- [ ] Update meCache.ts to use reads field +- [ ] Update filter logic to handle actual reading progress +- [ ] Test all 5 filters and data sources work correctly \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 942b8f20..52c1bfbe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,7 +112,16 @@ function AppRoutes({ } /> + } + /> + void viewMode?: ViewMode + readingProgress?: number // 0-1 reading progress (optional) } -export const BookmarkItem: React.FC = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => { +export const BookmarkItem: React.FC = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => { const [ogImage, setOgImage] = useState(null) const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}` @@ -150,7 +151,7 @@ export const BookmarkItem: React.FC = ({ bookmark, index, onS if (viewMode === 'large') { const previewImage = articleImage || instantPreview || ogImage - return + return } return diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 74e6adc5..f4c9bfea 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -21,6 +21,7 @@ import { RELAYS } from '../config/relays' import { Hooks } from 'applesauce-react' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' +import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' interface BookmarkListProps { bookmarks: Bookmark[] @@ -39,6 +40,8 @@ interface BookmarkListProps { relayPool: RelayPool | null isMobile?: boolean settings?: UserSettings + readingPositions?: Map + markedAsReadIds?: Set } export const BookmarkList: React.FC = ({ @@ -57,13 +60,16 @@ export const BookmarkList: React.FC = ({ loading = false, relayPool, isMobile = false, - settings + settings, + readingPositions, + markedAsReadIds }) => { const navigate = useNavigate() const bookmarksListRef = useRef(null) const friendsColor = settings?.highlightColorFriends || '#f97316' const [showAddModal, setShowAddModal] = useState(false) const [selectedFilter, setSelectedFilter] = useState('all') + const [readingProgressFilter, setReadingProgressFilter] = useState('all') const activeAccount = Hooks.useActiveAccount() const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => { @@ -90,8 +96,42 @@ export const BookmarkList: React.FC = ({ const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) .filter(hasContent) - // Apply filter - const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter) + // Apply type filter + const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter) + + // Apply reading progress filter (only affects kind:30023 articles) + const filteredBookmarks = typeFilteredBookmarks.filter(bookmark => { + // Only apply reading progress filter to kind:30023 articles + if (bookmark.kind !== 30023) return true + + // If reading progress filter is 'all', show all articles + if (readingProgressFilter === 'all') return true + + const isMarkedAsRead = markedAsReadIds?.has(bookmark.id) + const position = readingPositions?.get(bookmark.id) + + // Marked-as-read articles are always treated as 100% complete + if (isMarkedAsRead) { + return readingProgressFilter === 'completed' + } + + switch (readingProgressFilter) { + case 'unopened': + // No reading progress - never opened + return !position || position === 0 + case 'started': + // 0-10% reading progress - opened but not read far + return position !== undefined && position > 0 && position <= 0.10 + case 'reading': + // Has some progress but not completed (11% - 94%) + return position !== undefined && position > 0.10 && position <= 0.94 + case 'completed': + // 95% or more read + return position !== undefined && position >= 0.95 + default: + return true + } + }) // Separate bookmarks with setName (kind 30003) from regular bookmarks const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks) @@ -204,6 +244,7 @@ export const BookmarkList: React.FC = ({ index={index} onSelectUrl={onSelectUrl} viewMode={viewMode} + readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)} /> ))} @@ -211,6 +252,17 @@ export const BookmarkList: React.FC = ({ ))} )} + + {/* Reading progress filters - only show if there are kind:30023 articles */} + {typeFilteredBookmarks.some(b => b.kind === 30023) && ( +
+ +
+ )} +
) => void articleSummary?: string contentTypeIcon: IconDefinition + readingProgress?: number // 0-1 reading progress (optional) } export const LargeView: React.FC = ({ @@ -38,11 +39,19 @@ export const LargeView: React.FC = ({ getAuthorDisplayName, handleReadNow, articleSummary, - contentTypeIcon + contentTypeIcon, + readingProgress }) => { const cachedImage = useImageCache(previewImage || undefined) const isArticle = bookmark.kind === 30023 + // Calculate progress display + const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0 + const progressColor = + progressPercent >= 95 ? '#10b981' : // green for completed + progressPercent > 5 ? '#f97316' : // orange for in-progress + 'var(--color-border)' // default for not started + const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent) const handleKeyDown: React.KeyboardEventHandler = (e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -92,6 +101,28 @@ export const LargeView: React.FC = ({
)} + {/* Reading progress indicator for articles - shown only if there's progress */} + {isArticle && readingProgress !== undefined && readingProgress > 0 && ( +
+
+
+ )} +
diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 8aceb36c..c91978ae 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -52,7 +52,8 @@ const Bookmarks: React.FC = ({ 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/links' ? 'links' : location.pathname === '/me/writings' ? 'writings' : 'highlights' // Extract tab from profile routes @@ -161,7 +162,9 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { isRefreshing, lastFetchTime, handleFetchHighlights, - handleRefreshAll + handleRefreshAll, + readingPositions, + markedAsReadIds } = useBookmarksData({ relayPool, activeAccount, @@ -170,7 +173,8 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { externalUrl, currentArticleCoordinate, currentArticleEventId, - settings + settings, + eventStore }) const { @@ -312,6 +316,8 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { highlightButtonRef={highlightButtonRef} onCreateHighlight={handleCreateHighlight} hasActiveAccount={!!(activeAccount && relayPool)} + readingPositions={readingPositions} + markedAsReadIds={markedAsReadIds} explore={showExplore ? ( relayPool ? : null ) : undefined} diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 28e08ad2..05f64ae3 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -187,14 +187,76 @@ const ContentPanel: React.FC = ({ const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, syncEnabled: settings?.syncReadingPosition, - onSave: handleSavePosition, - onReadingComplete: () => { - // Optional: Auto-mark as read when reading is complete - if (activeAccount && !isMarkedAsRead) { - // Could trigger auto-mark as read here if desired + onSave: handleSavePosition + }) + + // Determine if we're on a nostr-native article (/a/) or external URL (/r/) + const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:') + + // Define handleMarkAsRead with useCallback to use in auto-mark effect + const handleMarkAsRead = useCallback(() => { + if (!activeAccount || !relayPool || isMarkedAsRead) { + return + } + + // Instantly update UI with checkmark animation + setIsMarkedAsRead(true) + setShowCheckAnimation(true) + + // Reset animation after it completes (2.5s for full fancy animation) + setTimeout(() => { + setShowCheckAnimation(false) + }, 2500) + + // Fire-and-forget: publish in background without blocking UI + ;(async () => { + try { + if (isNostrArticle && currentArticle) { + await createEventReaction( + currentArticle.id, + currentArticle.pubkey, + currentArticle.kind, + activeAccount, + relayPool + ) + console.log('✅ Marked nostr article as read') + } else if (selectedUrl) { + await createWebsiteReaction( + selectedUrl, + activeAccount, + relayPool + ) + console.log('✅ Marked website as read') + } + } catch (error) { + console.error('Failed to mark as read:', error) + // Revert UI state on error + setIsMarkedAsRead(false) + } + })() + }, [activeAccount, relayPool, isMarkedAsRead, isNostrArticle, currentArticle, selectedUrl]) + + // Auto-mark as read when reaching 100% for 2 seconds + useEffect(() => { + if (!settings?.autoMarkAsReadAt100 || isMarkedAsRead || !activeAccount || !relayPool) { + return + } + + // Only trigger when progress is exactly 100% + if (progressPercentage === 100) { + console.log('📍 [ContentPanel] Progress at 100%, starting 2-second timer for auto-mark') + + const timer = setTimeout(() => { + console.log('✅ [ContentPanel] Auto-marking as read after 2 seconds at 100%') + handleMarkAsRead() + }, 2000) + + return () => { + console.log('âšī¸ [ContentPanel] Canceling auto-mark timer (progress changed or unmounting)') + clearTimeout(timer) } } - }) + }, [progressPercentage, settings?.autoMarkAsReadAt100, isMarkedAsRead, activeAccount, relayPool, handleMarkAsRead]) // Load saved reading position when article loads useEffect(() => { @@ -226,19 +288,25 @@ const ContentPanel: React.FC = ({ if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { console.log('đŸŽ¯ [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%') - // Wait for content to be fully rendered before scrolling - setTimeout(() => { - const documentHeight = document.documentElement.scrollHeight - const windowHeight = window.innerHeight - const scrollTop = savedPosition.position * (documentHeight - windowHeight) - - window.scrollTo({ - top: scrollTop, - behavior: 'smooth' - }) - - console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop) - }, 500) // Give content time to render + + // Only auto-scroll if the setting is enabled (default: true) + if (settings?.autoScrollToPosition !== false) { + // Wait for content to be fully rendered before scrolling + setTimeout(() => { + const documentHeight = document.documentElement.scrollHeight + const windowHeight = window.innerHeight + const scrollTop = savedPosition.position * (documentHeight - windowHeight) + + window.scrollTo({ + top: scrollTop, + behavior: 'smooth' + }) + + console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop) + }, 500) // Give content time to render + } else { + console.log('â­ī¸ [ContentPanel] Auto-scroll disabled in settings') + } } else if (savedPosition) { if (savedPosition.position === 1) { console.log('✅ [ContentPanel] Article completed (100%), starting from top') @@ -252,7 +320,7 @@ const ContentPanel: React.FC = ({ } loadPosition() - }, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) + }, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl]) // Save position before unmounting or changing article useEffect(() => { @@ -324,8 +392,6 @@ const ContentPanel: React.FC = ({ const hasHighlights = relevantHighlights.length > 0 - // Determine if we're on a nostr-native article (/a/) or external URL (/r/) - const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:') const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type) // Track external video duration (in seconds) for display in header @@ -594,48 +660,6 @@ const ContentPanel: React.FC = ({ checkReadStatus() }, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle]) - - const handleMarkAsRead = () => { - if (!activeAccount || !relayPool || isMarkedAsRead) { - return - } - - // Instantly update UI with checkmark animation - setIsMarkedAsRead(true) - setShowCheckAnimation(true) - - // Reset animation after it completes - setTimeout(() => { - setShowCheckAnimation(false) - }, 600) - - // Fire-and-forget: publish in background without blocking UI - ;(async () => { - try { - if (isNostrArticle && currentArticle) { - await createEventReaction( - currentArticle.id, - currentArticle.pubkey, - currentArticle.kind, - activeAccount, - relayPool - ) - console.log('✅ Marked nostr article as read') - } else if (selectedUrl) { - await createWebsiteReaction( - selectedUrl, - activeAccount, - relayPool - ) - console.log('✅ Marked website as read') - } - } catch (error) { - console.error('Failed to mark as read:', error) - // Revert UI state on error - setIsMarkedAsRead(false) - } - })() - } if (!selectedUrl) { return ( diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index fae4c2ad..8a5cd483 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -22,6 +22,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' +import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService' +import { fetchReadArticles } from '../services/libraryService' interface ExploreProps { relayPool: RelayPool @@ -41,6 +43,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) const [refreshTrigger, setRefreshTrigger] = useState(0) + const [readingPositions, setReadingPositions] = useState>(new Map()) + const [markedAsReadIds, setMarkedAsReadIds] = useState>(new Set()) // Visibility filters (defaults from settings, or friends only) const [visibility, setVisibility] = useState({ @@ -213,6 +217,88 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti loadData() }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) + // Fetch marked-as-read articles + useEffect(() => { + const loadMarkedAsRead = async () => { + if (!activeAccount || !eventStore) { + return + } + + try { + const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey) + + // Create a set of article IDs that are marked as read + const markedArticleIds = new Set() + + // For each read article, add both event ID and coordinate format + for (const readArticle of readArticles) { + // Add the event ID directly + markedArticleIds.add(readArticle.id) + + // For nostr-native articles (kind:7 reactions), also add the coordinate format + if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) { + // Try to get the event from the eventStore to find the 'd' tag + const event = eventStore.getEvent(readArticle.eventId) + if (event) { + const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${event.kind}:${event.pubkey}:${dTag}` + markedArticleIds.add(coordinate) + } + } + } + + setMarkedAsReadIds(markedArticleIds) + } catch (error) { + console.warn('âš ī¸ [Explore] Failed to load marked-as-read articles:', error) + } + } + + loadMarkedAsRead() + }, [relayPool, activeAccount, eventStore]) + + // Load reading positions for blog posts + useEffect(() => { + const loadPositions = async () => { + if (!activeAccount || !eventStore || blogPosts.length === 0 || !settings?.syncReadingPosition) { + return + } + + const positions = new Map() + + await Promise.all( + blogPosts.map(async (post) => { + try { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + const articleUrl = `nostr:${naddr}` + const identifier = generateArticleIdentifier(articleUrl) + + const savedPosition = await loadReadingPosition( + relayPool, + eventStore, + activeAccount.pubkey, + identifier + ) + + if (savedPosition && savedPosition.position > 0) { + positions.set(post.event.id, savedPosition.position) + } + } catch (error) { + console.warn('âš ī¸ [Explore] Failed to load reading position for post:', error) + } + }) + ) + + setReadingPositions(positions) + } + + loadPositions() + }, [blogPosts, activeAccount, relayPool, eventStore, settings?.syncReadingPosition]) + // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { @@ -302,6 +388,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti post={post} href={getPostUrl(post)} level={post.level} + readingProgress={markedAsReadIds.has(post.event.id) ? 1.0 : readingPositions.get(post.event.id)} /> ))}
diff --git a/src/components/Me.tsx b/src/components/Me.tsx index d3502cea..2d987e9d 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons' +import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' @@ -10,7 +10,8 @@ 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 { fetchLinks } from '../services/linksService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { RELAYS } from '../config/relays' import { Bookmark, IndividualBookmark } from '../types/bookmarks' @@ -19,15 +20,15 @@ import BlogPostCard from './BlogPostCard' import { BookmarkItem } from './BookmarkItem' import IconButton from './IconButton' import { ViewMode } from './Bookmarks' -import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache' +import { getCachedMeData, updateCachedHighlights } from '../services/meCache' import { faBooks } from '../icons/customIcons' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' -import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService' -import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters' +import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' +import { filterByReadingProgress } from '../utils/readingProgressUtils' interface MeProps { relayPool: RelayPool @@ -35,11 +36,10 @@ interface MeProps { pubkey?: string // Optional pubkey for viewing other users' profiles } -type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings' +type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings' const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const activeAccount = Hooks.useActiveAccount() - const eventStore = Hooks.useEventStore() const navigate = useNavigate() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') @@ -48,14 +48,15 @@ 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 [links, setLinks] = useState([]) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) + const [loadedTabs, setLoadedTabs] = useState>(new Set()) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') - const [archiveFilter, setArchiveFilter] = useState('all') - const [readingPositions, setReadingPositions] = useState>(new Map()) + const [readingProgressFilter, setReadingProgressFilter] = useState('all') // Update local state when prop changes useEffect(() => { @@ -64,131 +65,164 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } }, [propActiveTab]) - useEffect(() => { - const loadData = async () => { - if (!viewingPubkey) { - setLoading(false) - return - } + // Tab-specific loading functions + const loadHighlightsTab = async () => { + if (!viewingPubkey) return + + // Only show loading skeleton if tab hasn't been loaded yet + const hasBeenLoaded = loadedTabs.has('highlights') + + try { + if (!hasBeenLoaded) setLoading(true) + const userHighlights = await fetchHighlights(relayPool, viewingPubkey) + setHighlights(userHighlights) + setLoadedTabs(prev => new Set(prev).add('highlights')) + } catch (err) { + console.error('Failed to load highlights:', err) + } finally { + if (!hasBeenLoaded) setLoading(false) + } + } + const loadWritingsTab = async () => { + if (!viewingPubkey) return + + const hasBeenLoaded = loadedTabs.has('writings') + + try { + if (!hasBeenLoaded) setLoading(true) + const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) + setWritings(userWritings) + setLoadedTabs(prev => new Set(prev).add('writings')) + } catch (err) { + console.error('Failed to load writings:', err) + } finally { + if (!hasBeenLoaded) setLoading(false) + } + } + + const loadReadingListTab = async () => { + if (!viewingPubkey || !isOwnProfile || !activeAccount) return + + const hasBeenLoaded = loadedTabs.has('reading-list') + + try { + if (!hasBeenLoaded) setLoading(true) try { - setLoading(true) - - // Seed from cache if available to avoid empty flash (own profile only) - if (isOwnProfile) { - const cached = getCachedMeData(viewingPubkey) - if (cached) { - setHighlights(cached.highlights) - setBookmarks(cached.bookmarks) - setReadArticles(cached.readArticles) - } - } - - // Fetch highlights and writings (public data) - const [userHighlights, userWritings] = await Promise.all([ - fetchHighlights(relayPool, viewingPubkey), - fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) - ]) - - setHighlights(userHighlights) - setWritings(userWritings) - - // 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 { - await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { - fetchedBookmarks = newBookmarks - setBookmarks(newBookmarks) - }) - } catch (err) { - console.warn('Failed to load bookmarks:', err) - setBookmarks([]) - } - - // Update cache with all fetched data - setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles) - } else { - setBookmarks([]) - setReadArticles([]) - } + await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { + setBookmarks(newBookmarks) + }) } catch (err) { - console.error('Failed to load data:', err) - // No blocking error - user can pull-to-refresh - } finally { - setLoading(false) + console.warn('Failed to load bookmarks:', err) + setBookmarks([]) } + setLoadedTabs(prev => new Set(prev).add('reading-list')) + } catch (err) { + console.error('Failed to load reading list:', err) + } finally { + if (!hasBeenLoaded) setLoading(false) } + } - loadData() - }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) + const loadReadsTab = async () => { + if (!viewingPubkey || !isOwnProfile || !activeAccount) return + + const hasBeenLoaded = loadedTabs.has('reads') + + try { + if (!hasBeenLoaded) setLoading(true) + + // Fetch bookmarks first (needed for reads) + let fetchedBookmarks: Bookmark[] = [] + try { + await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { + fetchedBookmarks = newBookmarks + setBookmarks(newBookmarks) + }) + } catch (err) { + console.warn('Failed to load bookmarks:', err) + fetchedBookmarks = [] + } - // Load reading positions for read articles (only for own profile) + // Fetch all reads + const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks) + setReads(userReads) + setLoadedTabs(prev => new Set(prev).add('reads')) + } catch (err) { + console.error('Failed to load reads:', err) + } finally { + if (!hasBeenLoaded) setLoading(false) + } + } + + const loadLinksTab = async () => { + if (!viewingPubkey || !isOwnProfile || !activeAccount) return + + const hasBeenLoaded = loadedTabs.has('links') + + try { + if (!hasBeenLoaded) setLoading(true) + + // Fetch links (external URLs with reading progress) + const userLinks = await fetchLinks(relayPool, viewingPubkey) + setLinks(userLinks) + setLoadedTabs(prev => new Set(prev).add('links')) + } catch (err) { + console.error('Failed to load links:', err) + } finally { + if (!hasBeenLoaded) setLoading(false) + } + } + + // Load active tab data useEffect(() => { - const loadPositions = async () => { - if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) { - console.log('🔍 [Archive] Skipping position load:', { - isOwnProfile, - hasAccount: !!activeAccount, - hasRelayPool: !!relayPool, - hasEventStore: !!eventStore, - articlesCount: readArticles.length - }) - return - } - - console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles') - - const positions = new Map() - - // Load positions for all read articles - await Promise.all( - readArticles.map(async (post) => { - try { - const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' - const naddr = nip19.naddrEncode({ - kind: 30023, - pubkey: post.author, - identifier: dTag - }) - const articleUrl = `nostr:${naddr}` - const identifier = generateArticleIdentifier(articleUrl) - - console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32)) - - const savedPosition = await loadReadingPosition( - relayPool, - eventStore, - activeAccount.pubkey, - identifier - ) - - if (savedPosition && savedPosition.position > 0) { - console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50)) - positions.set(post.event.id, savedPosition.position) - } else { - console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50)) - } - } catch (error) { - console.warn('âš ī¸ [Archive] Failed to load reading position for article:', error) - } - }) - ) - - console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles') - setReadingPositions(positions) + if (!viewingPubkey || !activeTab) { + setLoading(false) + return } - loadPositions() - }, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore]) + // Load cached data immediately if available + if (isOwnProfile) { + const cached = getCachedMeData(viewingPubkey) + if (cached) { + setHighlights(cached.highlights) + setBookmarks(cached.bookmarks) + setReads(cached.reads || []) + setLinks(cached.links || []) + } + } - // Pull-to-refresh + // Load data for active tab (refresh in background if already loaded) + switch (activeTab) { + case 'highlights': + loadHighlightsTab() + break + case 'writings': + loadWritingsTab() + break + case 'reading-list': + loadReadingListTab() + break + case 'reads': + loadReadsTab() + break + case 'links': + loadLinksTab() + break + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, viewingPubkey, refreshTrigger]) + + + // Pull-to-refresh - only reload active tab const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { + // Clear the loaded state for current tab to force refresh + setLoadedTabs(prev => { + const newSet = new Set(prev) + newSet.delete(activeTab) + return newSet + }) setRefreshTrigger(prev => prev + 1) }, maximumPullLength: 240, @@ -217,6 +251,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 @@ -245,29 +327,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const groups = groupIndividualBookmarks(filteredBookmarks) - // Apply archive filter - const filteredReadArticles = readArticles.filter(post => { - const position = readingPositions.get(post.event.id) - - switch (archiveFilter) { - case 'to-read': - // No position or 0% progress - return !position || position === 0 - case 'reading': - // Has some progress but not completed (0 < position < 1) - return position !== undefined && position > 0 && position < 0.95 - case 'completed': - // 95% or more read (we consider 95%+ as completed) - return position !== undefined && position >= 0.95 - case 'marked': - // Manually marked as read (in archive but no reading position data) - // These are articles that were marked via the emoji reaction - return !position || position === 0 - case 'all': - default: - return true - } - }) + // Apply reading progress filter + const filteredReads = filterByReadingProgress(reads, readingProgressFilter) + const filteredLinks = filterByReadingProgress(links, readingProgressFilter) const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ { key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, @@ -276,7 +338,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 || links.length > 0 || writings.length > 0 const showSkeletons = loading && !hasData const renderTabContent = () => { @@ -291,9 +353,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) } - return highlights.length === 0 ? ( + return highlights.length === 0 && !loading ? (
- + No highlights yet.
) : (
@@ -320,9 +382,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) } - return allIndividualBookmarks.length === 0 ? ( + return allIndividualBookmarks.length === 0 && !loading ? (
- + No bookmarks yet.
) : (
@@ -386,8 +448,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) - case 'archive': - if (showSkeletons) { + case 'reads': + // Show loading skeletons while fetching or if no data + if (reads.length === 0 || (loading && !loadedTabs.has('reads'))) { return (
{Array.from({ length: 6 }).map((_, i) => ( @@ -396,32 +459,66 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) } - return readArticles.length === 0 ? ( -
- -
- ) : ( + + // Show reads with filters + return ( <> - {readArticles.length > 0 && ( - - )} - {filteredReadArticles.length === 0 ? ( + + {filteredReads.length === 0 ? (
No articles match this filter.
) : (
- {filteredReadArticles.map((post) => ( - - ))} + {filteredReads.map((item) => ( + + ))} +
+ )} + + ) + + case 'links': + // Show loading skeletons while fetching or if no data + if (links.length === 0 || (loading && !loadedTabs.has('links'))) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } + + // Show links with filters + return ( + <> + + {filteredLinks.length === 0 ? ( +
+ No links match this filter. +
+ ) : ( +
+ {filteredLinks.map((item) => ( + + ))}
)} @@ -437,9 +534,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) } - return writings.length === 0 ? ( + return writings.length === 0 && !loading ? (
- + No articles written yet.
) : (
@@ -487,12 +584,20 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr Bookmarks + )} diff --git a/src/components/ArchiveFilters.tsx b/src/components/ReadingProgressFilters.tsx similarity index 51% rename from src/components/ArchiveFilters.tsx rename to src/components/ReadingProgressFilters.tsx index 1d4c9cac..6931ef21 100644 --- a/src/components/ArchiveFilters.tsx +++ b/src/components/ReadingProgressFilters.tsx @@ -1,22 +1,22 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons' -import { faBooks } from '../icons/customIcons' +import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons' +import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons' -export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked' +export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' -interface ArchiveFiltersProps { - selectedFilter: ArchiveFilterType - onFilterChange: (filter: ArchiveFilterType) => void +interface ReadingProgressFiltersProps { + selectedFilter: ReadingProgressFilterType + onFilterChange: (filter: ReadingProgressFilterType) => void } -const ArchiveFilters: React.FC = ({ selectedFilter, onFilterChange }) => { +const ReadingProgressFilters: React.FC = ({ selectedFilter, onFilterChange }) => { const filters = [ { type: 'all' as const, icon: faAsterisk, label: 'All' }, - { type: 'to-read' as const, icon: faBookmark, label: 'To Read' }, + { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' }, + { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' }, { type: 'reading' as const, icon: faBookOpen, label: 'Reading' }, - { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }, - { type: 'marked' as const, icon: faBooks, label: 'Marked as Read' } + { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' } ] return ( @@ -36,5 +36,5 @@ const ArchiveFilters: React.FC = ({ selectedFilter, onFilte ) } -export default ArchiveFilters +export default ReadingProgressFilters diff --git a/src/components/Settings/LayoutBehaviorSettings.tsx b/src/components/Settings/LayoutBehaviorSettings.tsx index efc17384..847ed9d2 100644 --- a/src/components/Settings/LayoutBehaviorSettings.tsx +++ b/src/components/Settings/LayoutBehaviorSettings.tsx @@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC = ({ setting Sync reading position across devices
+ +
+ +
+ +
+ +
) } diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index b3912f90..113b4396 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -47,6 +47,8 @@ interface ThreePaneLayoutProps { onRefresh: () => void relayPool: RelayPool | null eventStore: IEventStore | null + readingPositions?: Map + markedAsReadIds?: Set // Content pane readerLoading: boolean @@ -324,6 +326,8 @@ const ThreePaneLayout: React.FC = (props) => { loading={props.bookmarksLoading} relayPool={props.relayPool} isMobile={isMobile} + readingPositions={props.readingPositions} + markedAsReadIds={props.markedAsReadIds} settings={props.settings} /> diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index e7b5a1c8..f9f07f0d 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -1,12 +1,16 @@ import { useState, useEffect, useCallback } from 'react' import { RelayPool } from 'applesauce-relay' import { IAccount, AccountManager } from 'applesauce-accounts' +import { IEventStore } from 'applesauce-core' import { Bookmark } from '../types/bookmarks' import { Highlight } from '../types/highlights' import { fetchBookmarks } from '../services/bookmarkService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' import { fetchContacts } from '../services/contactService' import { UserSettings } from '../services/settingsService' +import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService' +import { fetchReadArticles } from '../services/libraryService' +import { nip19 } from 'nostr-tools' interface UseBookmarksDataParams { relayPool: RelayPool | null @@ -17,6 +21,7 @@ interface UseBookmarksDataParams { currentArticleCoordinate?: string currentArticleEventId?: string settings?: UserSettings + eventStore?: IEventStore } export const useBookmarksData = ({ @@ -27,7 +32,8 @@ export const useBookmarksData = ({ externalUrl, currentArticleCoordinate, currentArticleEventId, - settings + settings, + eventStore }: UseBookmarksDataParams) => { const [bookmarks, setBookmarks] = useState([]) const [bookmarksLoading, setBookmarksLoading] = useState(true) @@ -36,6 +42,8 @@ export const useBookmarksData = ({ const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [isRefreshing, setIsRefreshing] = useState(false) const [lastFetchTime, setLastFetchTime] = useState(null) + const [readingPositions, setReadingPositions] = useState>(new Map()) + const [markedAsReadIds, setMarkedAsReadIds] = useState>(new Set()) const handleFetchContacts = useCallback(async () => { if (!relayPool || !activeAccount) return @@ -125,6 +133,93 @@ export const useBookmarksData = ({ handleFetchContacts() }, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts]) + // Fetch marked-as-read articles + useEffect(() => { + const loadMarkedAsRead = async () => { + if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0) { + return + } + + try { + const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey) + + // Create a set of bookmark IDs that are marked as read + const markedBookmarkIds = new Set() + + // For each read article, we need to match it to bookmark IDs + for (const readArticle of readArticles) { + // Add the event ID directly (for web bookmarks and legacy compatibility) + markedBookmarkIds.add(readArticle.id) + + // For nostr-native articles (kind:7 reactions), also add the coordinate format + if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) { + // Try to get the event from the eventStore to find the 'd' tag + const event = eventStore.getEvent(readArticle.eventId) + if (event) { + const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${event.kind}:${event.pubkey}:${dTag}` + markedBookmarkIds.add(coordinate) + } + } + } + + setMarkedAsReadIds(markedBookmarkIds) + } catch (error) { + console.warn('âš ī¸ [Bookmarks] Failed to load marked-as-read articles:', error) + } + } + + loadMarkedAsRead() + }, [relayPool, activeAccount, eventStore, bookmarks]) + + // Load reading positions for bookmarked articles (kind:30023) + useEffect(() => { + const loadPositions = async () => { + if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0 || !settings?.syncReadingPosition) { + return + } + + const positions = new Map() + + // Extract all kind:30023 articles from bookmarks + const articles = bookmarks.flatMap(bookmark => + (bookmark.individualBookmarks || []).filter(item => item.kind === 30023) + ) + + await Promise.all( + articles.map(async (article) => { + try { + const dTag = article.tags.find(t => t[0] === 'd')?.[1] || '' + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: article.pubkey, + identifier: dTag + }) + const articleUrl = `nostr:${naddr}` + const identifier = generateArticleIdentifier(articleUrl) + + const savedPosition = await loadReadingPosition( + relayPool, + eventStore, + activeAccount.pubkey, + identifier + ) + + if (savedPosition && savedPosition.position > 0) { + positions.set(article.id, savedPosition.position) + } + } catch (error) { + console.warn('âš ī¸ [Bookmarks] Failed to load reading position for article:', error) + } + }) + ) + + setReadingPositions(positions) + } + + loadPositions() + }, [bookmarks, activeAccount, relayPool, eventStore, settings?.syncReadingPosition]) + return { bookmarks, bookmarksLoading, @@ -137,7 +232,9 @@ export const useBookmarksData = ({ lastFetchTime, handleFetchBookmarks, handleFetchHighlights, - handleRefreshAll + handleRefreshAll, + readingPositions, + markedAsReadIds } } diff --git a/src/services/linksService.ts b/src/services/linksService.ts new file mode 100644 index 00000000..ec7620cc --- /dev/null +++ b/src/services/linksService.ts @@ -0,0 +1,61 @@ +import { RelayPool } from 'applesauce-relay' +import { fetchReadArticles } from './libraryService' +import { queryEvents } from './dataFetch' +import { RELAYS } from '../config/relays' +import { ReadItem } from './readsService' +import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' + +const APP_DATA_KIND = 30078 // NIP-78 Application Data + +/** + * Fetches external URL links with reading progress from: + * - URLs with reading progress (kind:30078) + * - Manually marked as read URLs (kind:7, kind:17) + */ +export async function fetchLinks( + relayPool: RelayPool, + userPubkey: string +): Promise { + console.log('🔗 [Links] Fetching external links 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('📊 [Links] Data fetched:', { + readingPositions: readingPositionEvents.length, + markedAsRead: markedAsReadArticles.length + }) + + // Process data using shared utilities + const linksMap = new Map() + processReadingPositions(readingPositionEvents, linksMap) + processMarkedAsRead(markedAsReadArticles, linksMap) + + // Filter for external URLs only with reading progress + const links = Array.from(linksMap.values()) + .filter(item => { + // Only external URLs + if (item.type !== 'external') return false + + // Only include if there's reading progress or marked as read + const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead + return hasProgress + }) + + // Apply common validation and sorting + const validLinks = filterValidItems(links) + const sortedLinks = sortByReadingActivity(validLinks) + + console.log('✅ [Links] Processed', sortedLinks.length, 'total links') + return sortedLinks + + } catch (error) { + console.error('Failed to fetch links:', error) + return [] + } +} + diff --git a/src/services/meCache.ts b/src/services/meCache.ts index 53b59a6a..b7b3bcd4 100644 --- a/src/services/meCache.ts +++ b/src/services/meCache.ts @@ -1,11 +1,12 @@ 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[] + links: ReadItem[] timestamp: number } @@ -21,12 +22,14 @@ export function setCachedMeData( pubkey: string, highlights: Highlight[], bookmarks: Bookmark[], - readArticles: BlogPostPreview[] + reads: ReadItem[], + links: ReadItem[] = [] ): void { meCache.set(pubkey, { highlights, bookmarks, - readArticles, + reads, + links, timestamp: Date.now() }) } @@ -45,10 +48,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/readingDataProcessor.ts b/src/services/readingDataProcessor.ts new file mode 100644 index 00000000..81ebcc98 --- /dev/null +++ b/src/services/readingDataProcessor.ts @@ -0,0 +1,140 @@ +import { NostrEvent } from 'nostr-tools' +import { ReadItem } from './readsService' + +const READING_POSITION_PREFIX = 'boris:reading-position:' + +interface ReadArticle { + id: string + url?: string + eventId?: string + eventKind?: number + markedAt: number +} + +/** + * Processes reading position events into ReadItems + */ +export function processReadingPositions( + events: NostrEvent[], + readsMap: Map +): void { + for (const event of events) { + 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 + + 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) + } + } +} + +/** + * Processes marked-as-read articles into ReadItems + */ +export function processMarkedAsRead( + articles: ReadArticle[], + readsMap: Map +): 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 or proper 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 + + // Filter out items without titles + if (!item.title || item.title === 'Untitled') { + // For Nostr articles, we need the title from the event + if (item.type === 'article' && !item.event) return false + // For external URLs, we need a proper title + if (item.type === 'external' && !item.title) return false + } + + return true + }) +} + diff --git a/src/services/readsService.ts b/src/services/readsService.ts new file mode 100644 index 00000000..eb631cc7 --- /dev/null +++ b/src/services/readsService.ts @@ -0,0 +1,212 @@ +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' +import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers + +const APP_DATA_KIND = 30078 // NIP-78 Application Data + +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 + }) + + // Process data using shared utilities + const readsMap = new Map() + processReadingPositions(readingPositionEvents, readsMap) + processMarkedAsRead(markedAsReadArticles, readsMap) + + // 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. Filter for Nostr articles only and apply common validation/sorting + const articles = Array.from(readsMap.values()) + .filter(item => item.type === 'article') + + const validArticles = filterValidItems(articles) + const sortedReads = sortByReadingActivity(validArticles) + + 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 +} + diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index a36c7879..ed5edcff 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -56,6 +56,8 @@ export interface UserSettings { paragraphAlignment?: 'left' | 'justify' // default: justify // Reading position sync syncReadingPosition?: boolean // default: false (opt-in) + autoScrollToPosition?: boolean // default: true (auto-scroll to last reading position) + autoMarkAsReadAt100?: boolean // default: false (auto-mark as read when reaching 100% for 2 seconds) } export async function loadSettings( diff --git a/src/styles/components/reader.css b/src/styles/components/reader.css index 7cd148a6..04e5f6cd 100644 --- a/src/styles/components/reader.css +++ b/src/styles/components/reader.css @@ -216,7 +216,72 @@ .mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); } .mark-as-read-btn:active:not(:disabled) { transform: translateY(0); } .mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; } -.mark-as-read-btn svg { font-size: 1.1rem; } +.mark-as-read-btn svg { font-size: 1.1rem; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); } + +/* Fancy Mark as Read animation */ +@keyframes markAsReadSuccess { + 0% { + background: var(--color-bg-elevated); + border-color: var(--color-border-subtle); + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } + 10% { + transform: scale(1.05); + box-shadow: 0 0 0 8px rgba(16, 185, 129, 0.3); + } + 25% { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: white; + transform: scale(1.02); + box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4); + } + 65% { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: white; + transform: scale(1.02); + box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4); + } + 100% { + background: #6b7280; + border-color: #6b7280; + color: white; + transform: scale(1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } +} + +@keyframes iconSpin { + 0% { + transform: rotate(0deg) scale(1); + } + 15% { + transform: rotate(0deg) scale(1.2); + } + 50% { + transform: rotate(360deg) scale(1.2); + } + 100% { + transform: rotate(360deg) scale(1); + } +} + +.mark-as-read-btn.animating { + animation: markAsReadSuccess 2.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + pointer-events: none; +} + +.mark-as-read-btn.animating svg { + animation: iconSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.mark-as-read-btn.marked { + background: #6b7280; + border-color: #6b7280; + color: white; +} @media (max-width: 768px) { .reader { max-width: 100%; diff --git a/src/styles/layout/sidebar.css b/src/styles/layout/sidebar.css index 192c4eb5..d199a9b5 100644 --- a/src/styles/layout/sidebar.css +++ b/src/styles/layout/sidebar.css @@ -211,3 +211,12 @@ background: transparent; } +/* Reading progress filters in bookmarks sidebar - add top border, remove bottom border to avoid double border with view-mode-controls */ +.reading-progress-filters-wrapper { + border-top: 1px solid var(--color-border); +} + +.reading-progress-filters-wrapper .bookmark-filters { + border-bottom: none; +} + diff --git a/src/utils/readingProgressUtils.ts b/src/utils/readingProgressUtils.ts new file mode 100644 index 00000000..99b6cfda --- /dev/null +++ b/src/utils/readingProgressUtils.ts @@ -0,0 +1,30 @@ +import { ReadItem } from '../services/readsService' +import { ReadingProgressFilterType } from '../components/ReadingProgressFilters' + +/** + * Filters ReadItems by reading progress + */ +export function filterByReadingProgress( + items: ReadItem[], + filter: ReadingProgressFilterType +): ReadItem[] { + return items.filter((item) => { + const progress = item.readingProgress || 0 + const isMarked = item.markedAsRead || false + + switch (filter) { + case 'unopened': + return progress === 0 && !isMarked + case 'started': + return progress > 0 && progress <= 0.10 && !isMarked + case 'reading': + return progress > 0.10 && progress <= 0.94 && !isMarked + case 'completed': + return progress >= 0.95 || isMarked + case 'all': + default: + return true + } + }) +} +