Revert "Add reading progress filters and split Reads/Links tabs"

This commit is contained in:
Gigi
2025-10-16 08:05:20 +02:00
committed by GitHub
parent 7bfd7fdf6c
commit 5568437663
21 changed files with 276 additions and 1376 deletions

View File

@@ -1,136 +0,0 @@
<!-- 658dc3b5-4b0b-4d30-8cfa-a9326f1d467e f1d78d5b-786d-4658-ae4b-56278aba318e -->
# 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<Set<TabType>>(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

View File

@@ -112,16 +112,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reads"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/links"
path="/me/archive"
element={
<Bookmarks
relayPool={relayPool}

View File

@@ -1,22 +1,22 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
onFilterChange: (filter: ReadingProgressFilterType) => void
interface ArchiveFiltersProps {
selectedFilter: ArchiveFilterType
onFilterChange: (filter: ArchiveFilterType) => void
}
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
]
return (
@@ -36,5 +36,5 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
)
}
export default ReadingProgressFilters
export default ArchiveFilters

View File

@@ -19,10 +19,9 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number // 0-1 reading progress (optional)
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -151,7 +150,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
if (viewMode === 'large') {
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />

View File

@@ -21,7 +21,6 @@ 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[]
@@ -40,8 +39,6 @@ interface BookmarkListProps {
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -60,16 +57,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
loading = false,
relayPool,
isMobile = false,
settings,
readingPositions,
markedAsReadIds
settings
}) => {
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
@@ -96,42 +90,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
// 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
}
})
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
@@ -244,7 +204,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
/>
))}
</div>
@@ -252,17 +211,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
))}
</div>
)}
{/* Reading progress filters - only show if there are kind:30023 articles */}
{typeFilteredBookmarks.some(b => b.kind === 30023) && (
<div className="reading-progress-filters-wrapper">
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
/>
</div>
)}
<div className="view-mode-controls">
<div className="view-mode-left">
<IconButton

View File

@@ -23,7 +23,6 @@ interface LargeViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number // 0-1 reading progress (optional)
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -39,19 +38,11 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName,
handleReadNow,
articleSummary,
contentTypeIcon,
readingProgress
contentTypeIcon
}) => {
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<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -101,28 +92,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
</div>
)}
{/* Reading progress indicator for articles - shown only if there's progress */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="large-footer">
<span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />

View File

@@ -52,8 +52,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/reads' ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
@@ -162,9 +161,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
} = useBookmarksData({
relayPool,
activeAccount,
@@ -173,8 +170,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
})
const {
@@ -316,8 +312,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
readingPositions={readingPositions}
markedAsReadIds={markedAsReadIds}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}

View File

@@ -187,77 +187,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
}
}
})
// 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(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
@@ -288,9 +226,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// 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
@@ -304,9 +239,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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')
@@ -320,7 +252,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl])
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
useEffect(() => {
@@ -392,6 +324,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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
@@ -661,6 +595,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
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 (
<div className="reader empty">

View File

@@ -22,8 +22,6 @@ 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
@@ -43,8 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
@@ -217,88 +213,6 @@ const Explore: React.FC<ExploreProps> = ({ 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<string>()
// 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<string, number>()
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: () => {
@@ -388,7 +302,6 @@ const Explore: React.FC<ExploreProps> = ({ 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)}
/>
))}
</div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
@@ -10,8 +10,7 @@ import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -20,15 +19,15 @@ import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { getCachedMeData, setCachedMeData, 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 ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
interface MeProps {
relayPool: RelayPool
@@ -36,10 +35,11 @@ interface MeProps {
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
const eventStore = Hooks.useEventStore()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
@@ -48,15 +48,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [links, setLinks] = useState<ReadItem[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
// Update local state when prop changes
useEffect(() => {
@@ -65,74 +64,41 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}
}, [propActiveTab])
// 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')
useEffect(() => {
const loadData = async () => {
if (!viewingPubkey) {
setLoading(false)
return
}
try {
if (!hasBeenLoaded) setLoading(true)
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
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)
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
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
if (!hasBeenLoaded) setLoading(true)
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
setBookmarks(newBookmarks)
})
} catch (err) {
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)
}
}
const loadReadsTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
try {
if (!hasBeenLoaded) setLoading(true)
// Fetch bookmarks first (needed for reads)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
@@ -141,88 +107,88 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
setBookmarks([])
}
// Fetch all reads
const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks)
setReads(userReads)
setLoadedTabs(prev => new Set(prev).add('reads'))
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) {
console.error('Failed to load reads:', err)
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} 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(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
}
}
loadData()
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Load reading positions for read articles (only for own profile)
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
}
// 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 || [])
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
const positions = new Map<string, number>()
// 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)
}
// 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])
loadPositions()
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
// Pull-to-refresh - only reload active tab
// Pull-to-refresh
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,
@@ -251,54 +217,6 @@ const Me: React.FC<MeProps> = ({ 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
@@ -327,9 +245,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const groups = groupIndividualBookmarks(filteredBookmarks)
// Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
// 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
}
})
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 },
@@ -338,7 +276,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
]
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const renderTabContent = () => {
@@ -353,9 +291,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return highlights.length === 0 && !loading ? (
return highlights.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="highlights-list me-highlights-list">
@@ -382,9 +320,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return allIndividualBookmarks.length === 0 && !loading ? (
return allIndividualBookmarks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No bookmarks yet.
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="bookmarks-list">
@@ -448,9 +386,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
case 'reads':
// Show loading skeletons while fetching or if no data
if (reads.length === 0 || (loading && !loadedTabs.has('reads'))) {
case 'archive':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
@@ -459,64 +396,30 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
// Show reads with filters
return (
return readArticles.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
{readArticles.length > 0 && (
<ArchiveFilters
selectedFilter={archiveFilter}
onFilterChange={setArchiveFilter}
/>
{filteredReads.length === 0 ? (
)}
{filteredReadArticles.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
{filteredReadArticles.map((post) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
)
case 'links':
// Show loading skeletons while fetching or if no data
if (links.length === 0 || (loading && !loadedTabs.has('links'))) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
// Show links with filters
return (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={readingPositions.get(post.event.id)}
/>
))}
</div>
@@ -534,9 +437,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return writings.length === 0 && !loading ? (
return writings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
@@ -584,20 +487,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
<span className="tab-label">Archive</span>
</button>
</>
)}

View File

@@ -117,32 +117,6 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToPosition" className="checkbox-label">
<input
id="autoScrollToPosition"
type="checkbox"
checked={settings.autoScrollToPosition !== false}
onChange={(e) => onUpdate({ autoScrollToPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to last reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadAt100" className="checkbox-label">
<input
id="autoMarkAsReadAt100"
type="checkbox"
checked={settings.autoMarkAsReadAt100 ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadAt100: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read when reading progress is 100%</span>
</label>
</div>
</div>
)
}

View File

@@ -47,8 +47,6 @@ interface ThreePaneLayoutProps {
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
// Content pane
readerLoading: boolean
@@ -326,8 +324,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
loading={props.bookmarksLoading}
relayPool={props.relayPool}
isMobile={isMobile}
readingPositions={props.readingPositions}
markedAsReadIds={props.markedAsReadIds}
settings={props.settings}
/>
</div>

View File

@@ -1,16 +1,12 @@
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
@@ -21,7 +17,6 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore
}
export const useBookmarksData = ({
@@ -32,8 +27,7 @@ export const useBookmarksData = ({
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -42,8 +36,6 @@ export const useBookmarksData = ({
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -133,93 +125,6 @@ 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<string>()
// 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<string, number>()
// 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,
@@ -232,9 +137,7 @@ export const useBookmarksData = ({
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
}
}

View File

@@ -1,61 +0,0 @@
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<ReadItem[]> {
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<string, ReadItem>()
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 []
}
}

View File

@@ -1,12 +1,11 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { ReadItem } from './readsService'
import { BlogPostPreview } from './exploreService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
reads: ReadItem[]
links: ReadItem[]
readArticles: BlogPostPreview[]
timestamp: number
}
@@ -22,14 +21,12 @@ export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
reads: ReadItem[],
links: ReadItem[] = []
readArticles: BlogPostPreview[]
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
reads,
links,
readArticles,
timestamp: Date.now()
})
}
@@ -48,10 +45,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
}
}
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
}
}

View File

@@ -1,140 +0,0 @@
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<string, ReadItem>
): 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<string, ReadItem>
): void {
for (const article of articles) {
const existing = readsMap.get(article.id)
if (article.eventId && article.eventKind === 30023) {
// Nostr article
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
} else if (article.url) {
// External URL
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'external',
url: article.url,
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
}
}
}
/**
* Sorts ReadItems by most recent reading activity
*/
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
return items.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0
const timeB = b.readingTimestamp || b.markedAt || 0
return timeB - timeA
})
}
/**
* Filters out items without timestamps 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
})
}

View File

@@ -1,212 +0,0 @@
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<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
})
// Process data using shared utilities
const readsMap = new Map<string, ReadItem>()
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
}

View File

@@ -56,8 +56,6 @@ 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(

View File

@@ -216,72 +216,7 @@
.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; 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;
}
.mark-as-read-btn svg { font-size: 1.1rem; }
@media (max-width: 768px) {
.reader {
max-width: 100%;

View File

@@ -211,12 +211,3 @@
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;
}

View File

@@ -1,30 +0,0 @@
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
}
})
}