feat: show reading progress in Explore and Bookmarks sidebar

- Add reading position loading to Explore component
- Add reading position loading to useBookmarksData hook
- Display progress bars in Explore tab blog posts
- Display progress bars in Bookmarks large preview view
- Progress shown as colored bar (green for completed, orange for in-progress)
- Only shown for kind:30023 articles with saved reading positions
- Requires syncReadingPosition setting to be enabled
This commit is contained in:
Gigi
2025-10-15 23:07:18 +02:00
parent 8800791723
commit 02eaa1c8f8
7 changed files with 149 additions and 8 deletions

View File

@@ -19,9 +19,10 @@ 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' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -150,7 +151,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} />
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
}
return <CardView {...sharedProps} articleImage={articleImage} />

View File

@@ -39,6 +39,7 @@ interface BookmarkListProps {
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
readingPositions?: Map<string, number>
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -57,7 +58,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
loading = false,
relayPool,
isMobile = false,
settings
settings,
readingPositions
}) => {
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
@@ -204,6 +206,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={readingPositions?.get(individualBookmark.id)}
/>
))}
</div>

View File

@@ -23,6 +23,7 @@ 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> = ({
@@ -38,11 +39,19 @@ export const LargeView: React.FC<LargeViewProps> = ({
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<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -92,6 +101,28 @@ 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

@@ -161,7 +161,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
handleRefreshAll,
readingPositions
} = useBookmarksData({
relayPool,
activeAccount,
@@ -170,7 +171,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
settings,
eventStore
})
const {
@@ -312,6 +314,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
readingPositions={readingPositions}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}

View File

@@ -22,6 +22,7 @@ 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'
interface ExploreProps {
relayPool: RelayPool
@@ -41,6 +42,7 @@ 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())
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
@@ -213,6 +215,49 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// 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: () => {
@@ -302,6 +347,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={readingPositions.get(post.event.id)}
/>
))}
</div>

View File

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

View File

@@ -1,12 +1,15 @@
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 { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -17,6 +20,7 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore
}
export const useBookmarksData = ({
@@ -27,7 +31,8 @@ export const useBookmarksData = ({
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
settings,
eventStore
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -36,6 +41,7 @@ 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 handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -125,6 +131,54 @@ export const useBookmarksData = ({
handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
// 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,
@@ -137,7 +191,8 @@ export const useBookmarksData = ({
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll
handleRefreshAll,
readingPositions
}
}