mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
13 Commits
v0.6.20
...
sync-readi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f58e010f | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Bookmark filter buttons by content type (articles, videos, images, web links)
|
||||||
|
- Filter bookmarks by their content type on bookmarks sidebar
|
||||||
|
- Filters also available on `/me` page bookmarks tab
|
||||||
|
- Separate filter for external articles with link icon
|
||||||
|
- Multiple filters can be active simultaneously
|
||||||
|
- Private Bookmarks section for encrypted legacy bookmarks
|
||||||
|
- Encrypted legacy bookmarks now grouped in separate section
|
||||||
|
- Better organization and clarity for different bookmark types
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Bookmark section labels improved for clarity
|
||||||
|
- More descriptive section headings throughout
|
||||||
|
- Better categorization of bookmark types
|
||||||
|
- Bookmark filter button styling refined
|
||||||
|
- Reduced whitespace around bookmark filters for cleaner layout
|
||||||
|
- Dramatically reduced whitespace on both sidebar and `/me` page
|
||||||
|
- Lock icon removed from individual bookmarks
|
||||||
|
- Encryption status now indicated by section grouping
|
||||||
|
- Cleaner bookmark item appearance
|
||||||
|
- External article icon changed to link icon (`faLink`)
|
||||||
|
- More intuitive icon for external content
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Highlight button positioning and visibility
|
||||||
|
- Fixed to viewport for consistent placement
|
||||||
|
- Sticky and always visible when needed
|
||||||
|
- Properly positioned inside reader pane
|
||||||
|
|
||||||
## [0.6.19] - 2025-10-15
|
## [0.6.19] - 2025-10-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1607,7 +1641,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.19...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||||
|
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
|
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
|
||||||
|
|||||||
41
src/components/ArchiveFilters.tsx
Normal file
41
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBookmark } from '@fortawesome/free-regular-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 => (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveFilters
|
||||||
|
|
||||||
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
|||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
@@ -23,6 +24,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage and determine color
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
@@ -47,7 +52,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
{post.summary && (
|
{post.summary && (
|
||||||
<p className="blog-post-card-summary">{post.summary}</p>
|
<p className="blog-post-card-summary">{post.summary}</p>
|
||||||
)}
|
)}
|
||||||
<div className="blog-post-card-meta">
|
|
||||||
|
{/* Reading progress indicator - replaces the dividing line */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||||
|
<div
|
||||||
|
className="blog-post-reading-progress"
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||||
<span className="blog-post-card-author">
|
<span className="blog-post-card-author">
|
||||||
<FontAwesomeIcon icon={faUser} />
|
<FontAwesomeIcon icon={faUser} />
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
import ReactPlayer from 'react-player'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
|
|||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import {
|
||||||
|
generateArticleIdentifier,
|
||||||
|
loadReadingPosition,
|
||||||
|
saveReadingPosition
|
||||||
|
} from '../services/readingPositionService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -129,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get event store for reading position service
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
// Reading position tracking - only for text content, not videos
|
// Reading position tracking - only for text content, not videos
|
||||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
|
||||||
|
// Generate article identifier for saving/loading position
|
||||||
|
const articleIdentifier = useMemo(() => {
|
||||||
|
if (!selectedUrl) return null
|
||||||
|
return generateArticleIdentifier(selectedUrl)
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
// Callback to save reading position
|
||||||
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
|
await saveReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
factory,
|
||||||
|
articleIdentifier,
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
|
syncEnabled: settings?.syncReadingPosition,
|
||||||
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Optional: Auto-mark as read when reading is complete
|
// Optional: Auto-mark as read when reading is complete
|
||||||
if (activeAccount && !isMarkedAsRead) {
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
@@ -141,6 +196,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Load saved reading position when article loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||||
|
isTextContent,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
const loadPosition = async () => {
|
||||||
|
try {
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
articleIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
|
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
// 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')
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosition()
|
||||||
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
// Save position before unmounting or changing article
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveNow) {
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [saveNow, selectedUrl])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -237,35 +237,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHighlightClick = (highlightId: string) => {
|
|
||||||
const highlight = highlights.find(h => h.id === highlightId)
|
|
||||||
if (!highlight) return
|
|
||||||
|
|
||||||
// For nostr-native articles
|
|
||||||
if (highlight.eventReference) {
|
|
||||||
// Convert eventReference to naddr
|
|
||||||
if (highlight.eventReference.includes(':')) {
|
|
||||||
const parts = highlight.eventReference.split(':')
|
|
||||||
const kind = parseInt(parts[0])
|
|
||||||
const pubkey = parts[1]
|
|
||||||
const identifier = parts[2] || ''
|
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind,
|
|
||||||
pubkey,
|
|
||||||
identifier
|
|
||||||
})
|
|
||||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
} else {
|
|
||||||
// Already an naddr
|
|
||||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For web URLs
|
|
||||||
else if (highlight.urlReference) {
|
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify highlights with levels based on user context and apply visibility filters
|
// Classify highlights with levels based on user context and apply visibility filters
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
@@ -357,7 +328,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onHighlightClick={handleHighlightClick}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
|||||||
import { getNostrUrl } from '../config/nostrGateways'
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
import CompactButton from './CompactButton'
|
import CompactButton from './CompactButton'
|
||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -206,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Resolve the profile of the user who made the highlight
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
@@ -274,8 +276,34 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}, [showMenu, showDeleteConfirm])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
if (onHighlightClick) {
|
if (onHighlightClick) {
|
||||||
onHighlightClick(highlight.id)
|
onHighlightClick(highlight.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, navigate to the article that this highlight references
|
||||||
|
if (highlight.eventReference) {
|
||||||
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
|
||||||
|
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [kind, pubkey, identifier] = parts
|
||||||
|
|
||||||
|
if (kind === '30023') {
|
||||||
|
// Encode as naddr and navigate
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (highlight.urlReference) {
|
||||||
|
// Navigate to external URL
|
||||||
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
data-highlight-id={highlight.id}
|
data-highlight-id={highlight.id}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div className="highlight-header">
|
<div className="highlight-header">
|
||||||
<CompactButton
|
<CompactButton
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import RefreshIndicator from './RefreshIndicator'
|
|||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
||||||
|
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -37,6 +39,7 @@ type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
|||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
@@ -51,6 +54,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
||||||
|
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,6 +127,65 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
}, [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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPositions()
|
||||||
|
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
@@ -176,10 +240,34 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
|
||||||
// Apply filter
|
// Apply bookmark filter
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
|
// 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[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
@@ -313,15 +401,30 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<>
|
||||||
{readArticles.map((post) => (
|
{readArticles.length > 0 && (
|
||||||
<BlogPostCard
|
<ArchiveFilters
|
||||||
key={post.event.id}
|
selectedFilter={archiveFilter}
|
||||||
post={post}
|
onFilterChange={setArchiveFilter}
|
||||||
href={getPostUrl(post)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
{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">
|
||||||
|
{filteredReadArticles.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={readingPositions.get(post.event.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'writings':
|
case 'writings':
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
|
syncReadingPosition: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Auto-collapse sidebar on small screens</span>
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="syncReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.syncReadingPosition ?? false}
|
||||||
|
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Sync reading position across devices</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,72 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
interface UseReadingPositionOptions {
|
interface UseReadingPositionOptions {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
onPositionChange?: (position: number) => void
|
onPositionChange?: (position: number) => void
|
||||||
onReadingComplete?: () => void
|
onReadingComplete?: () => void
|
||||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadingPosition = ({
|
export const useReadingPosition = ({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onReadingComplete,
|
onReadingComplete,
|
||||||
readingCompleteThreshold = 0.9
|
readingCompleteThreshold = 0.9,
|
||||||
|
syncEnabled = false,
|
||||||
|
onSave,
|
||||||
|
autoSaveInterval = 5000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
const lastSavedPosition = useRef(0)
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Debounced save function
|
||||||
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Don't save if position is too low (< 5%)
|
||||||
|
if (currentPosition < 0.05) return
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1%)
|
||||||
|
// But always save if we've reached 100% (completion)
|
||||||
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||||
|
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||||
|
|
||||||
|
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new save
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, autoSaveInterval)
|
||||||
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
|
// Immediate save function
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Cancel any pending saves
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save if position is meaningful (>= 5%)
|
||||||
|
if (position >= 0.05) {
|
||||||
|
lastSavedPosition.current = position
|
||||||
|
onSave(position)
|
||||||
|
}
|
||||||
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
|
|||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
|
||||||
// Calculate position based on how much of the content has been scrolled through
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
const maxScroll = documentHeight - windowHeight
|
||||||
|
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||||
|
|
||||||
|
// If we're within 5px of the bottom, consider it 100%
|
||||||
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||||
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
|
// Schedule auto-save if sync is enabled
|
||||||
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
// Check if reading is complete
|
// Check if reading is complete
|
||||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', handleScroll)
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
|
// Clear save timer on unmount
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
|
|||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
isReadingComplete,
|
isReadingComplete,
|
||||||
progressPercentage: Math.round(position * 100)
|
progressPercentage: Math.round(position * 100),
|
||||||
|
saveNow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
src/services/readingPositionService.ts
Normal file
196
src/services/readingPositionService.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { firstValueFrom } from 'rxjs'
|
||||||
|
import { publishEvent } from './writeService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
|
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
export interface ReadingPosition {
|
||||||
|
position: number // 0-1 scroll progress
|
||||||
|
timestamp: number // Unix timestamp
|
||||||
|
scrollTop?: number // Optional: pixel position
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract and parse reading position from an event
|
||||||
|
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||||
|
if (!event.content || event.content.length === 0) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.content) as ReadingPosition
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique identifier for an article
|
||||||
|
* For Nostr articles: use the naddr directly
|
||||||
|
* For external URLs: use base64url encoding of the URL
|
||||||
|
*/
|
||||||
|
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||||
|
// If it starts with "nostr:", extract the naddr
|
||||||
|
if (naddrOrUrl.startsWith('nostr:')) {
|
||||||
|
return naddrOrUrl.replace('nostr:', '')
|
||||||
|
}
|
||||||
|
// For URLs, use base64url encoding (URL-safe)
|
||||||
|
return btoa(naddrOrUrl)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save reading position to Nostr (Kind 30078)
|
||||||
|
*/
|
||||||
|
export async function saveReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
factory: EventFactory,
|
||||||
|
articleIdentifier: string,
|
||||||
|
position: ReadingPosition
|
||||||
|
): Promise<void> {
|
||||||
|
console.log('💾 [ReadingPosition] Saving position:', {
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||||
|
position: position.position,
|
||||||
|
positionPercent: Math.round(position.position * 100) + '%',
|
||||||
|
timestamp: position.timestamp,
|
||||||
|
scrollTop: position.scrollTop
|
||||||
|
})
|
||||||
|
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
const draft = await factory.create(async () => ({
|
||||||
|
kind: APP_DATA_KIND,
|
||||||
|
content: JSON.stringify(position),
|
||||||
|
tags: [
|
||||||
|
['d', dTag],
|
||||||
|
['client', 'boris']
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
|
// Use unified write service
|
||||||
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
|
|
||||||
|
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load reading position from Nostr
|
||||||
|
*/
|
||||||
|
export async function loadReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
articleIdentifier: string
|
||||||
|
): Promise<ReadingPosition | null> {
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
console.log('📖 [ReadingPosition] Loading position:', {
|
||||||
|
pubkey: pubkey.slice(0, 8) + '...',
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||||
|
dTag: dTag.slice(0, 50) + '...'
|
||||||
|
})
|
||||||
|
|
||||||
|
// First, check if we already have the position in the local event store
|
||||||
|
try {
|
||||||
|
const localEvent = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (localEvent) {
|
||||||
|
const content = getReadingPositionContent(localEvent)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
||||||
|
position: content.position,
|
||||||
|
positionPercent: Math.round(content.position * 100) + '%',
|
||||||
|
timestamp: content.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Still fetch from relays in the background to get any updates
|
||||||
|
relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('📭 No cached reading position found, fetching from relays...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in local store, fetch from relays
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let hasResolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!hasResolved) {
|
||||||
|
console.log('⏱️ Reading position load timeout - no position found')
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, 3000) // Shorter timeout for reading positions
|
||||||
|
|
||||||
|
const sub = relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe({
|
||||||
|
complete: async () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
try {
|
||||||
|
const event = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (event) {
|
||||||
|
const content = getReadingPositionContent(event)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ [ReadingPosition] Loaded from relays:', {
|
||||||
|
position: content.position,
|
||||||
|
positionPercent: Math.round(content.position * 100) + '%',
|
||||||
|
timestamp: content.timestamp
|
||||||
|
})
|
||||||
|
resolve(content)
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [ReadingPosition] Event found but no valid content')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📭 [ReadingPosition] No position found on relays')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error loading reading position:', err)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('❌ Reading position subscription error:', err)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sub.unsubscribe()
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ export interface UserSettings {
|
|||||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||||
// Reading settings
|
// Reading settings
|
||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
|
// Reading position sync
|
||||||
|
syncReadingPosition?: boolean // default: false (opt-in)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user