mirror of
https://github.com/dergigi/boris.git
synced 2025-12-19 07:34:28 +01:00
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:
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user