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 index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode 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 [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}` 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') { if (viewMode === 'large') {
const previewImage = articleImage || instantPreview || ogImage 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} /> return <CardView {...sharedProps} articleImage={articleImage} />

View File

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

View File

@@ -23,6 +23,7 @@ interface LargeViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string articleSummary?: string
contentTypeIcon: IconDefinition contentTypeIcon: IconDefinition
readingProgress?: number // 0-1 reading progress (optional)
} }
export const LargeView: React.FC<LargeViewProps> = ({ export const LargeView: React.FC<LargeViewProps> = ({
@@ -38,11 +39,19 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleSummary, articleSummary,
contentTypeIcon contentTypeIcon,
readingProgress
}) => { }) => {
const cachedImage = useImageCache(previewImage || undefined) const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023 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 triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => { const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -92,6 +101,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
</div> </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"> <div className="large-footer">
<span className="bookmark-type-large"> <span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />

View File

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

View File

@@ -22,6 +22,7 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel' import { HighlightVisibility } from './HighlightsPanel'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
interface ExploreProps { interface ExploreProps {
relayPool: RelayPool relayPool: RelayPool
@@ -41,6 +42,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
// Visibility filters (defaults from settings, or friends only) // Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({ const [visibility, setVisibility] = useState<HighlightVisibility>({
@@ -213,6 +215,49 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
loadData() loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) }, [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 // Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => { onRefresh: () => {
@@ -302,6 +347,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post} post={post}
href={getPostUrl(post)} href={getPostUrl(post)}
level={post.level} level={post.level}
readingProgress={readingPositions.get(post.event.id)}
/> />
))} ))}
</div> </div>

View File

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

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IAccount, AccountManager } from 'applesauce-accounts' import { IAccount, AccountManager } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams { interface UseBookmarksDataParams {
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -17,6 +20,7 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string currentArticleCoordinate?: string
currentArticleEventId?: string currentArticleEventId?: string
settings?: UserSettings settings?: UserSettings
eventStore?: IEventStore
} }
export const useBookmarksData = ({ export const useBookmarksData = ({
@@ -27,7 +31,8 @@ export const useBookmarksData = ({
externalUrl, externalUrl,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
settings settings,
eventStore
}: UseBookmarksDataParams) => { }: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true) const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -36,6 +41,7 @@ export const useBookmarksData = ({
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null) const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const handleFetchContacts = useCallback(async () => { const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return if (!relayPool || !activeAccount) return
@@ -125,6 +131,54 @@ export const useBookmarksData = ({
handleFetchContacts() handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, 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 { return {
bookmarks, bookmarks,
bookmarksLoading, bookmarksLoading,
@@ -137,7 +191,8 @@ export const useBookmarksData = ({
lastFetchTime, lastFetchTime,
handleFetchBookmarks, handleFetchBookmarks,
handleFetchHighlights, handleFetchHighlights,
handleRefreshAll handleRefreshAll,
readingPositions
} }
} }