mirror of
https://github.com/dergigi/boris.git
synced 2025-12-25 02:24:25 +01:00
chore: merge master into reading-progress-filters-part-two
Resolved conflicts by keeping feature branch changes: - Kept /me/reads and /me/links routes (not /me/archive) - Kept ReadingProgressFilters component and readingProgressUtils - Kept readsService, linksService, and readingDataProcessor - Restored files that were renamed/deleted in master
This commit is contained in:
47
src/components/ArchiveFilters.tsx
Normal file
47
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||
|
||||
interface ArchiveFiltersProps {
|
||||
selectedFilter: ArchiveFilterType
|
||||
onFilterChange: (filter: ArchiveFilterType) => void
|
||||
}
|
||||
|
||||
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ 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: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => {
|
||||
const isActive = selectedFilter === filter.type
|
||||
// Only "completed" gets green color, everything else uses default blue
|
||||
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
style={activeStyle}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchiveFilters
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,25 +226,19 @@ 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
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else {
|
||||
console.log('⏭️ [ContentPanel] Auto-scroll disabled in settings')
|
||||
}
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else 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
|
||||
@@ -660,6 +594,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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user