mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
63 Commits
v0.6.20
...
reading-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
63f58e010f | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e |
136
.cursor/plans/rename-archive-to-reads-658dc3b5.plan.md
Normal file
136
.cursor/plans/rename-archive-to-reads-658dc3b5.plan.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<!-- 658dc3b5-4b0b-4d30-8cfa-a9326f1d467e f1d78d5b-786d-4658-ae4b-56278aba318e -->
|
||||
# Lazy Load Me Component Tabs
|
||||
|
||||
## Overview
|
||||
|
||||
Currently, the Me component loads all data for all tabs upfront, causing 30+ second load times even when viewing a single tab. This plan implements lazy loading where only the active tab's data is fetched on demand.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Based on user requirements:
|
||||
|
||||
- Load only the active tab's data (pure lazy loading)
|
||||
- No background prefetching
|
||||
- Show cached data immediately, refresh in background when revisiting tabs
|
||||
- Works for both `/me` (own profile) and `/p/` (other profiles) using the same code
|
||||
|
||||
## Key Insight
|
||||
|
||||
The Me component already handles both own profile and other profiles via the `isOwnProfile` flag. The lazy loading will naturally work for both cases:
|
||||
|
||||
- Own profile (`/me`): Loads all tabs including private data (bookmarks, reads)
|
||||
- Other profiles (`/p/npub...`): Only loads public tabs (highlights, writings)
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Update Me.tsx Loading Logic
|
||||
|
||||
**Current behavior**: Single `useEffect` loads all data (highlights, writings, bookmarks, reads) regardless of active tab.
|
||||
|
||||
**New behavior**:
|
||||
|
||||
- Create separate loading functions per tab
|
||||
- Load only active tab's data on mount and tab switches
|
||||
- Show cached data immediately if available
|
||||
- Refresh cached data in background when tab is revisited
|
||||
|
||||
**Key changes**:
|
||||
|
||||
- Remove the monolithic `loadData()` function
|
||||
- Add `loadedTabs` state to track which tabs have been fetched
|
||||
- Create tab-specific loaders: `loadHighlights()`, `loadWritings()`, `loadBookmarks()`, `loadReads()`
|
||||
- Add `useEffect` that watches `activeTab` and loads data for current tab only
|
||||
- Check cache first, display cached data, then refresh in background
|
||||
|
||||
**Code location**: Lines 64-123 in `src/components/Me.tsx`
|
||||
|
||||
### 2. Per-Tab Loading State
|
||||
|
||||
Add tab-specific loading tracking:
|
||||
|
||||
```typescript
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||
```
|
||||
|
||||
This prevents unnecessary reloads and allows showing cached data instantly.
|
||||
|
||||
### 3. Tab-Specific Load Functions
|
||||
|
||||
Create individual functions:
|
||||
|
||||
- `loadHighlightsTab()` - fetch highlights
|
||||
- `loadWritingsTab()` - fetch writings
|
||||
- `loadReadingListTab()` - fetch bookmarks
|
||||
- `loadReadsTab()` - fetch bookmarks first, then reads
|
||||
|
||||
Each function:
|
||||
|
||||
1. Checks cache, displays if available
|
||||
2. Sets loading state
|
||||
3. Fetches fresh data
|
||||
4. Updates state and cache
|
||||
5. Marks tab as loaded
|
||||
|
||||
### 4. Tab Switch Effect
|
||||
|
||||
Replace the current useEffect with:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!activeTab || !viewingPubkey) return
|
||||
|
||||
// Check if we have cached data
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
// Show cached data immediately
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReads(cached.reads)
|
||||
// Continue to refresh in background
|
||||
}
|
||||
|
||||
// Load data for active tab
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
loadHighlightsTab()
|
||||
break
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
loadReadsTab()
|
||||
break
|
||||
}
|
||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||
```
|
||||
|
||||
### 5. Handle Pull-to-Refresh
|
||||
|
||||
Update pull-to-refresh logic to only reload the active tab instead of all tabs.
|
||||
|
||||
## Benefits
|
||||
|
||||
- Initial load: ~2-5s instead of 30+ seconds (only loads one tab)
|
||||
- Tab switching: Instant with cached data, refreshes in background
|
||||
- Network efficiency: Only fetches what the user views
|
||||
- Better UX: Users see content immediately from cache
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- Verify each tab loads independently
|
||||
- Confirm cached data shows immediately on tab switch
|
||||
- Ensure background refresh works without flickering
|
||||
- Test pull-to-refresh only reloads active tab
|
||||
- Verify loading states per tab work correctly
|
||||
|
||||
### To-dos
|
||||
|
||||
- [ ] Create src/services/readsService.ts with fetchAllReads function
|
||||
- [ ] Update Me.tsx to use reads instead of archive
|
||||
- [ ] Update routes from /me/archive to /me/reads
|
||||
- [ ] Update meCache.ts to use reads field
|
||||
- [ ] Update filter logic to handle actual reading progress
|
||||
- [ ] Test all 5 filters and data sources work correctly
|
||||
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]
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
- 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.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
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -112,7 +112,25 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
path="/me/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
|
||||
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
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 displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
@@ -23,6 +24,16 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
@@ -47,7 +58,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
{post.summary && (
|
||||
<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">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
|
||||
@@ -19,9 +19,10 @@ interface BookmarkItemProps {
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -150,7 +151,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
|
||||
@@ -21,6 +21,7 @@ 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[]
|
||||
@@ -39,6 +40,8 @@ interface BookmarkListProps {
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
settings?: UserSettings
|
||||
readingPositions?: Map<string, number>
|
||||
markedAsReadIds?: Set<string>
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -57,13 +60,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false,
|
||||
settings
|
||||
settings,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
}) => {
|
||||
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[]) => {
|
||||
@@ -90,8 +96,42 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
// 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
|
||||
}
|
||||
})
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
@@ -204,6 +244,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -211,6 +252,17 @@ 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
|
||||
|
||||
@@ -23,6 +23,7 @@ interface LargeViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
|
||||
@@ -52,7 +52,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/archive' ? 'archive' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname === '/me/links' ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
@@ -161,7 +162,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
handleRefreshAll,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
@@ -170,7 +173,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
eventStore
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -312,6 +316,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
highlightButtonRef={highlightButtonRef}
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
readingPositions={readingPositions}
|
||||
markedAsReadIds={markedAsReadIds}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
|
||||
@@ -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 ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -129,17 +136,200 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
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,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
syncEnabled: settings?.syncReadingPosition,
|
||||
onSave: handleSavePosition
|
||||
})
|
||||
|
||||
// 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) {
|
||||
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) + '%')
|
||||
|
||||
// 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')
|
||||
}
|
||||
} 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, settings?.autoScrollToPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNow) {
|
||||
saveNow()
|
||||
}
|
||||
}
|
||||
}, [saveNow, selectedUrl])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -202,8 +392,6 @@ 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
|
||||
@@ -472,48 +660,6 @@ 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,6 +22,8 @@ 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
|
||||
@@ -41,6 +43,8 @@ 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>({
|
||||
@@ -213,6 +217,88 @@ 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: () => {
|
||||
@@ -237,35 +323,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
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
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
@@ -331,6 +388,7 @@ 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>
|
||||
@@ -357,7 +415,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
@@ -206,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -274,8 +276,34 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}, [showMenu, showDeleteConfirm])
|
||||
|
||||
const handleItemClick = () => {
|
||||
// If onHighlightClick is provided, use it (legacy behavior)
|
||||
if (onHighlightClick) {
|
||||
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}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -19,13 +20,18 @@ import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -33,11 +39,15 @@ interface MeProps {
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
@@ -45,12 +55,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [reads, setReads] = useState<ReadItem[]>([])
|
||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
const [links, setLinks] = useState<ReadItem[]>([])
|
||||
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||
|
||||
// Initialize reading progress filter from URL param
|
||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
@@ -59,72 +79,246 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
// Sync filter state with URL changes
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setLoading(false)
|
||||
return
|
||||
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
setReadingProgressFilter(filterFromUrl)
|
||||
}, [urlFilter])
|
||||
|
||||
// Handler to change reading progress filter and update URL
|
||||
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
|
||||
setReadingProgressFilter(filter)
|
||||
if (activeTab === 'reads') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/reads', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/reads/${filter}`, { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab-specific loading functions
|
||||
const loadHighlightsTab = async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
// Only show loading skeleton if tab hasn't been loaded yet
|
||||
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||
setHighlights(userHighlights)
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load highlights:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadWritingsTab = async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('writings')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
setWritings(userWritings)
|
||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load writings:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReadingListTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading list:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReadsTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reads')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Ensure bookmarks are loaded
|
||||
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||
if (bookmarks.length === 0) {
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
fetchedBookmarks = []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
// Derive reads from bookmarks immediately
|
||||
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
|
||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||
setReadsMap(initialMap)
|
||||
setReads(initialReads)
|
||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// Only update items that are already in our map
|
||||
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
|
||||
console.log('📈 [Reads] Enrichment item received:', {
|
||||
id: item.id.slice(0, 20) + '...',
|
||||
progress: item.readingProgress,
|
||||
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
||||
})
|
||||
|
||||
setReadsMap(prevMap => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) {
|
||||
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||
return prevMap
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights and writings (public data)
|
||||
const [userHighlights, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, viewingPubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setWritings(userWritings)
|
||||
|
||||
// Only fetch private data for own profile
|
||||
if (isOwnProfile && activeAccount) {
|
||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
const merged = mergeReadItem(newMap, item)
|
||||
if (merged) {
|
||||
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||
// Update reads array after map is updated
|
||||
setReads(Array.from(newMap.values()))
|
||||
return newMap
|
||||
}
|
||||
return prevMap
|
||||
})
|
||||
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load reads:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} else {
|
||||
setBookmarks([])
|
||||
setReadArticles([])
|
||||
const loadLinksTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('links')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Ensure bookmarks are loaded
|
||||
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||
if (bookmarks.length === 0) {
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
fetchedBookmarks = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Derive links from bookmarks immediately
|
||||
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
|
||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||
setLinksMap(initialMap)
|
||||
setLinks(initialLinks)
|
||||
setLoadedTabs(prev => new Set(prev).add('links'))
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// Only update items that are already in our map
|
||||
fetchLinks(relayPool, viewingPubkey, (item) => {
|
||||
setLinksMap(prevMap => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) return prevMap
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
if (mergeReadItem(newMap, item)) {
|
||||
// Update links array after map is updated
|
||||
setLinks(Array.from(newMap.values()))
|
||||
return newMap
|
||||
}
|
||||
return prevMap
|
||||
})
|
||||
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load links:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load active tab data
|
||||
useEffect(() => {
|
||||
if (!viewingPubkey || !activeTab) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Load cached data immediately if available
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReads(cached.reads || [])
|
||||
setLinks(cached.links || [])
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
// Load data for active tab (refresh in background if already loaded)
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
loadHighlightsTab()
|
||||
break
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
loadReadsTab()
|
||||
break
|
||||
case 'links':
|
||||
loadLinksTab()
|
||||
break
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
|
||||
// Pull-to-refresh - reload active tab without clearing state
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
// Just trigger refresh - loaders will merge new data
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
maximumPullLength: 240,
|
||||
@@ -153,6 +347,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
const getReadItemUrl = (item: ReadItem) => {
|
||||
if (item.type === 'article') {
|
||||
// ID is already in naddr format
|
||||
return `/a/${item.id}`
|
||||
} else if (item.url) {
|
||||
return `/r/${encodeURIComponent(item.url)}`
|
||||
}
|
||||
return '#'
|
||||
}
|
||||
|
||||
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||
if (item.event) {
|
||||
return {
|
||||
event: item.event,
|
||||
title: item.title || 'Untitled',
|
||||
summary: item.summary,
|
||||
image: item.image,
|
||||
published: item.published,
|
||||
author: item.author || item.event.pubkey
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock event for external URLs
|
||||
const mockEvent = {
|
||||
id: item.id,
|
||||
pubkey: item.author || '',
|
||||
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [] as string[][],
|
||||
content: item.title || item.url || 'Untitled',
|
||||
sig: ''
|
||||
} as const
|
||||
|
||||
return {
|
||||
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||
title: item.title || item.url || 'Untitled',
|
||||
summary: item.summary,
|
||||
image: item.image,
|
||||
published: item.published,
|
||||
author: item.author || ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
@@ -176,10 +413,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
|
||||
// Apply filter
|
||||
// Apply bookmark filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||
|
||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||
|
||||
// Apply reading progress filter
|
||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||
@@ -188,7 +429,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
]
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
const renderTabContent = () => {
|
||||
@@ -203,9 +444,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 ? (
|
||||
return highlights.length === 0 && !loading ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
No highlights yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
@@ -232,9 +473,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
return allIndividualBookmarks.length === 0 && !loading ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
No bookmarks yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
@@ -298,8 +539,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
if (showSkeletons) {
|
||||
case 'reads':
|
||||
// Show loading skeletons only while initially loading
|
||||
if (loading && !loadedTabs.has('reads')) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -308,20 +550,87 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
// Show empty state if loaded but no reads
|
||||
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||
return (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles read yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show reads with filters
|
||||
return (
|
||||
<>
|
||||
<ReadingProgressFilters
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={handleReadingProgressFilterChange}
|
||||
/>
|
||||
{filteredReads.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">
|
||||
{filteredReads.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case 'links':
|
||||
// Show loading skeletons only while initially loading
|
||||
if (loading && !loadedTabs.has('links')) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show empty state if loaded but no links
|
||||
if (links.length === 0 && loadedTabs.has('links')) {
|
||||
return (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No links with reading progress yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show links with filters
|
||||
return (
|
||||
<>
|
||||
<ReadingProgressFilters
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={handleReadingProgressFilterChange}
|
||||
/>
|
||||
{filteredLinks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No links match this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredLinks.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
readingProgress={item.readingProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
@@ -334,9 +643,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return writings.length === 0 ? (
|
||||
return writings.length === 0 && !loading ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
No articles written yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -384,12 +693,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
<span className="tab-label">Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||
data-tab="links"
|
||||
onClick={() => navigate('/me/links')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span className="tab-label">Links</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
47
src/components/ReadingProgressFilters.tsx
Normal file
47
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
||||
|
||||
interface ReadingProgressFiltersProps {
|
||||
selectedFilter: ReadingProgressFilterType
|
||||
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||
}
|
||||
|
||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||
]
|
||||
|
||||
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 ReadingProgressFilters
|
||||
|
||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
}) => {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||
|
||||
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||
const progressDecimal = clampedProgress / 100
|
||||
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||
|
||||
// Determine bar color based on state
|
||||
let barColorClass = ''
|
||||
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||
|
||||
if (isComplete) {
|
||||
barColorClass = 'bg-green-500'
|
||||
barColorStyle = undefined
|
||||
} else if (isStarted) {
|
||||
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||
}
|
||||
|
||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||
const leftOffset = isSidebarCollapsed
|
||||
? 'var(--sidebar-collapsed-width)'
|
||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: ''
|
||||
}`}
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||
style={{
|
||||
width: `${clampedProgress}%`,
|
||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
||||
backgroundColor: barColorStyle
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : ''
|
||||
}`}
|
||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
||||
style={{
|
||||
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||
}}
|
||||
>
|
||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
|
||||
@@ -104,6 +104,45 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</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 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,6 +47,8 @@ interface ThreePaneLayoutProps {
|
||||
onRefresh: () => void
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
readingPositions?: Map<string, number>
|
||||
markedAsReadIds?: Set<string>
|
||||
|
||||
// Content pane
|
||||
readerLoading: boolean
|
||||
@@ -324,6 +326,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
isMobile={isMobile}
|
||||
readingPositions={props.readingPositions}
|
||||
markedAsReadIds={props.markedAsReadIds}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
15
src/config/kinds.ts
Normal file
15
src/config/kinds.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Nostr event kinds used throughout the application
|
||||
export const KINDS = {
|
||||
Highlights: 9802, // NIP-?? user highlights
|
||||
BlogPost: 30023, // NIP-23 long-form article
|
||||
AppData: 30078, // NIP-78 application data (reading positions)
|
||||
List: 30001, // NIP-51 list (addressable)
|
||||
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||
ListSimple: 10003, // NIP-51 simple list
|
||||
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||
} as const
|
||||
|
||||
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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
|
||||
@@ -17,6 +21,7 @@ interface UseBookmarksDataParams {
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
eventStore?: IEventStore
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
@@ -27,7 +32,8 @@ export const useBookmarksData = ({
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
eventStore
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
@@ -36,6 +42,8 @@ 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
|
||||
@@ -125,6 +133,93 @@ 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,
|
||||
@@ -137,7 +232,9 @@ export const useBookmarksData = ({
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
handleRefreshAll,
|
||||
readingPositions,
|
||||
markedAsReadIds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
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 = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
readingCompleteThreshold = 0.9,
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(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(() => {
|
||||
if (!enabled) return
|
||||
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||
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)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
|
||||
return () => {
|
||||
window.removeEventListener('scroll', 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
|
||||
useEffect(() => {
|
||||
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +35,7 @@ export const fetchBookmarks = async (
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
|
||||
{}
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
@@ -71,7 +72,7 @@ export const fetchBookmarks = async (
|
||||
})
|
||||
|
||||
// Check specifically for Primal's "reads" list
|
||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||
const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||
if (primalReads) {
|
||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
import { KINDS } from '../../config/kinds'
|
||||
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
@@ -21,7 +22,7 @@ export const fetchHighlights = async (
|
||||
const seenIds = new Set<string>()
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
@@ -36,7 +37,7 @@ export const fetchHighlights = async (
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
|
||||
try {
|
||||
// Fetch kind:7 and kind:17 reactions in parallel
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
])
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
|
||||
|
||||
// Filter to only nostr-native articles (kind 30023)
|
||||
const nostrArticles = readArticles.filter(
|
||||
article => article.eventKind === 30023 && article.eventId
|
||||
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||
)
|
||||
|
||||
if (nostrArticles.length === 0) {
|
||||
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
|
||||
|
||||
const articleEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], ids: eventIds },
|
||||
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||
{ relayUrls: RELAYS }
|
||||
)
|
||||
|
||||
|
||||
90
src/services/linksService.ts
Normal file
90
src/services/linksService.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchReadArticles } from './libraryService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { ReadItem } from './readsService'
|
||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
/**
|
||||
* Fetches external URL links with reading progress from:
|
||||
* - URLs with reading progress (kind:30078)
|
||||
* - Manually marked as read URLs (kind:7, kind:17)
|
||||
*/
|
||||
export async function fetchLinks(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string,
|
||||
onItem?: (item: ReadItem) => void
|
||||
): Promise<ReadItem[]> {
|
||||
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
||||
|
||||
const linksMap = new Map<string, ReadItem>()
|
||||
|
||||
// Helper to emit items as they're added/updated
|
||||
const emitItem = (item: ReadItem) => {
|
||||
if (onItem && mergeReadItem(linksMap, item)) {
|
||||
onItem(linksMap.get(item.id)!)
|
||||
} else if (!onItem) {
|
||||
linksMap.set(item.id, item)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all data sources in parallel
|
||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Links] Data fetched:', {
|
||||
readingPositions: readingPositionEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length
|
||||
})
|
||||
|
||||
// Process reading positions and emit external items
|
||||
processReadingPositions(readingPositionEvents, linksMap)
|
||||
if (onItem) {
|
||||
linksMap.forEach(item => {
|
||||
if (item.type === 'external') {
|
||||
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||
if (hasProgress) emitItem(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process marked-as-read and emit external items
|
||||
processMarkedAsRead(markedAsReadArticles, linksMap)
|
||||
if (onItem) {
|
||||
linksMap.forEach(item => {
|
||||
if (item.type === 'external') {
|
||||
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||
if (hasProgress) emitItem(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Filter for external URLs only with reading progress
|
||||
const links = Array.from(linksMap.values())
|
||||
.filter(item => {
|
||||
// Only external URLs
|
||||
if (item.type !== 'external') return false
|
||||
|
||||
// Only include if there's reading progress or marked as read
|
||||
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||
return hasProgress
|
||||
})
|
||||
|
||||
// Apply common validation and sorting
|
||||
const validLinks = filterValidItems(links)
|
||||
const sortedLinks = sortByReadingActivity(validLinks)
|
||||
|
||||
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
||||
return sortedLinks
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch links:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { ReadItem } from './readsService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
readArticles: BlogPostPreview[]
|
||||
reads: ReadItem[]
|
||||
links: ReadItem[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -21,12 +22,14 @@ export function setCachedMeData(
|
||||
pubkey: string,
|
||||
highlights: Highlight[],
|
||||
bookmarks: Bookmark[],
|
||||
readArticles: BlogPostPreview[]
|
||||
reads: ReadItem[],
|
||||
links: ReadItem[] = []
|
||||
): void {
|
||||
meCache.set(pubkey, {
|
||||
highlights,
|
||||
bookmarks,
|
||||
readArticles,
|
||||
reads,
|
||||
links,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -45,10 +48,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
147
src/services/readingDataProcessor.ts
Normal file
147
src/services/readingDataProcessor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { ReadItem } from './readsService'
|
||||
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||
|
||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||
|
||||
interface ReadArticle {
|
||||
id: string
|
||||
url?: string
|
||||
eventId?: string
|
||||
eventKind?: number
|
||||
markedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes reading position events into ReadItems
|
||||
*/
|
||||
export function processReadingPositions(
|
||||
events: NostrEvent[],
|
||||
readsMap: Map<string, ReadItem>
|
||||
): void {
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
||||
|
||||
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
||||
|
||||
try {
|
||||
const positionData = JSON.parse(event.content)
|
||||
const position = positionData.position
|
||||
const timestamp = positionData.timestamp
|
||||
|
||||
let itemId: string
|
||||
let itemUrl: string | undefined
|
||||
let itemType: 'article' | 'external' = 'external'
|
||||
|
||||
// Check if it's a nostr article (naddr format)
|
||||
if (identifier.startsWith('naddr1')) {
|
||||
itemId = identifier
|
||||
itemType = 'article'
|
||||
} else {
|
||||
// It's a base64url-encoded URL
|
||||
try {
|
||||
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode URL identifier:', identifier)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update the item
|
||||
const existing = readsMap.get(itemId)
|
||||
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||
readsMap.set(itemId, {
|
||||
...existing,
|
||||
id: itemId,
|
||||
source: 'reading-progress',
|
||||
type: itemType,
|
||||
url: itemUrl,
|
||||
readingProgress: position,
|
||||
readingTimestamp: timestamp
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse reading position:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes marked-as-read articles into ReadItems
|
||||
*/
|
||||
export function processMarkedAsRead(
|
||||
articles: ReadArticle[],
|
||||
readsMap: Map<string, ReadItem>
|
||||
): void {
|
||||
for (const article of articles) {
|
||||
const existing = readsMap.get(article.id)
|
||||
|
||||
if (article.eventId && article.eventKind === 30023) {
|
||||
// Nostr article
|
||||
readsMap.set(article.id, {
|
||||
...existing,
|
||||
id: article.id,
|
||||
source: 'marked-as-read',
|
||||
type: 'article',
|
||||
markedAsRead: true,
|
||||
markedAt: article.markedAt,
|
||||
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||
})
|
||||
} else if (article.url) {
|
||||
// External URL
|
||||
readsMap.set(article.id, {
|
||||
...existing,
|
||||
id: article.id,
|
||||
source: 'marked-as-read',
|
||||
type: 'external',
|
||||
url: article.url,
|
||||
markedAsRead: true,
|
||||
markedAt: article.markedAt,
|
||||
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts ReadItems by most recent reading activity
|
||||
*/
|
||||
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
|
||||
return items.sort((a, b) => {
|
||||
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out items without timestamps and enriches external items with fallback titles
|
||||
*/
|
||||
export function filterValidItems(items: ReadItem[]): ReadItem[] {
|
||||
return items
|
||||
.filter(item => {
|
||||
// Only include items that have a timestamp
|
||||
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
|
||||
(item.markedAt && item.markedAt > 0)
|
||||
if (!hasTimestamp) return false
|
||||
|
||||
// For Nostr articles, we need the event to be valid
|
||||
if (item.type === 'article' && !item.event) return false
|
||||
|
||||
// For external URLs, we need at least a URL
|
||||
if (item.type === 'external' && !item.url) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.map(item => {
|
||||
// Add fallback title for external URLs without titles
|
||||
if (item.type === 'external' && !item.title && item.url) {
|
||||
return { ...item, title: fallbackTitleFromUrl(item.url) }
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
197
src/services/readsService.ts
Normal file
197
src/services/readsService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { fetchReadArticles } from './libraryService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface ReadItem {
|
||||
id: string // event ID or URL or coordinate
|
||||
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||
|
||||
// Article data
|
||||
event?: NostrEvent
|
||||
url?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author?: string
|
||||
|
||||
// Reading metadata
|
||||
readingProgress?: number // 0-1
|
||||
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||
markedAsRead?: boolean
|
||||
markedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all reads from multiple sources:
|
||||
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||
* - Articles/URLs with reading progress (kind:30078)
|
||||
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||
*/
|
||||
export async function fetchAllReads(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string,
|
||||
bookmarks: Bookmark[],
|
||||
onItem?: (item: ReadItem) => void
|
||||
): Promise<ReadItem[]> {
|
||||
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
// Helper to emit items as they're added/updated
|
||||
const emitItem = (item: ReadItem) => {
|
||||
if (onItem && mergeReadItem(readsMap, item)) {
|
||||
onItem(readsMap.get(item.id)!)
|
||||
} else if (!onItem) {
|
||||
readsMap.set(item.id, item)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all data sources in parallel
|
||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Reads] Data fetched:', {
|
||||
readingPositions: readingPositionEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length,
|
||||
bookmarks: bookmarks.length
|
||||
})
|
||||
|
||||
// Process reading positions and emit items
|
||||
processReadingPositions(readingPositionEvents, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
// Process marked-as-read and emit items
|
||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Process bookmarked articles and article/website URLs
|
||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
|
||||
for (const bookmark of allBookmarks) {
|
||||
const bookmarkType = classifyBookmarkType(bookmark)
|
||||
|
||||
// Only include articles
|
||||
if (bookmarkType === 'article') {
|
||||
// Kind:30023 nostr article
|
||||
const coordinate = bookmark.id // Already in coordinate format
|
||||
const existing = readsMap.get(coordinate)
|
||||
|
||||
if (!existing) {
|
||||
const item: ReadItem = {
|
||||
id: coordinate,
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
}
|
||||
readsMap.set(coordinate, item)
|
||||
if (onItem) emitItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch full event data for nostr articles
|
||||
const articleCoordinates = Array.from(readsMap.values())
|
||||
.filter(item => item.type === 'article' && !item.event)
|
||||
.map(item => item.id)
|
||||
|
||||
if (articleCoordinates.length > 0) {
|
||||
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||
|
||||
// Parse coordinates and fetch events
|
||||
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||
|
||||
for (const coord of articleCoordinates) {
|
||||
try {
|
||||
// Try to decode as naddr
|
||||
if (coord.startsWith('naddr1')) {
|
||||
const decoded = nip19.decode(coord)
|
||||
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||
articlesToFetch.push({
|
||||
pubkey: decoded.data.pubkey,
|
||||
identifier: decoded.data.identifier || ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Try coordinate format (kind:pubkey:identifier)
|
||||
const parts = coord.split(':')
|
||||
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
|
||||
articlesToFetch.push({
|
||||
pubkey: parts[1],
|
||||
identifier: parts[2]
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode article coordinate:', coord)
|
||||
}
|
||||
}
|
||||
|
||||
if (articlesToFetch.length > 0) {
|
||||
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
|
||||
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
||||
{ relayUrls: RELAYS }
|
||||
)
|
||||
|
||||
// Merge event data into ReadItems and emit
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
|
||||
|
||||
const item = readsMap.get(coordinate) || readsMap.get(event.id)
|
||||
if (item) {
|
||||
item.event = event
|
||||
item.title = getArticleTitle(event) || 'Untitled'
|
||||
item.summary = getArticleSummary(event)
|
||||
item.image = getArticleImage(event)
|
||||
item.published = getArticlePublished(event)
|
||||
item.author = event.pubkey
|
||||
if (onItem) emitItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Filter for Nostr articles only and apply common validation/sorting
|
||||
const articles = Array.from(readsMap.values())
|
||||
.filter(item => item.type === 'article')
|
||||
|
||||
const validArticles = filterValidItems(articles)
|
||||
const sortedReads = sortByReadingActivity(validArticles)
|
||||
|
||||
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||
return sortedReads
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all reads:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ export interface UserSettings {
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
// Reading settings
|
||||
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,7 +216,72 @@
|
||||
.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; }
|
||||
.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;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.reader {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -211,3 +211,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
69
src/utils/linksFromBookmarks.ts
Normal file
69
src/utils/linksFromBookmarks.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { ReadItem } from '../services/readsService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||
|
||||
/**
|
||||
* Derives ReadItems from bookmarks for external URLs:
|
||||
* - Web bookmarks (kind:39701)
|
||||
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||
*/
|
||||
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
const linksMap = new Map<string, ReadItem>()
|
||||
|
||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
|
||||
for (const bookmark of allBookmarks) {
|
||||
const urls: string[] = []
|
||||
|
||||
// Web bookmarks (kind:39701) - extract from 'd' tag
|
||||
if (bookmark.kind === KINDS.WebBookmark) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract URLs from content if not already captured
|
||||
if (bookmark.content) {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
const matches = bookmark.content.match(urlRegex)
|
||||
if (matches) {
|
||||
urls.push(...matches)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata from tags (for web bookmarks and other types)
|
||||
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||
|
||||
// Create ReadItem for each unique URL
|
||||
for (const url of [...new Set(urls)]) {
|
||||
if (!linksMap.has(url)) {
|
||||
const item: ReadItem = {
|
||||
id: url,
|
||||
source: 'bookmark',
|
||||
type: 'external',
|
||||
url,
|
||||
title: title || fallbackTitleFromUrl(url),
|
||||
summary,
|
||||
image,
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
}
|
||||
|
||||
linksMap.set(url, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent bookmark activity
|
||||
return Array.from(linksMap.values()).sort((a, b) => {
|
||||
const timeA = a.readingTimestamp || 0
|
||||
const timeB = b.readingTimestamp || 0
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
83
src/utils/readItemMerge.ts
Normal file
83
src/utils/readItemMerge.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ReadItem } from '../services/readsService'
|
||||
|
||||
/**
|
||||
* Merges a ReadItem into a state map, returning whether the state changed.
|
||||
* Uses most recent reading activity to determine precedence.
|
||||
*/
|
||||
export function mergeReadItem(
|
||||
stateMap: Map<string, ReadItem>,
|
||||
incoming: ReadItem
|
||||
): boolean {
|
||||
const existing = stateMap.get(incoming.id)
|
||||
|
||||
if (!existing) {
|
||||
stateMap.set(incoming.id, incoming)
|
||||
return true
|
||||
}
|
||||
|
||||
// Always merge if incoming has reading progress data
|
||||
const hasNewProgress = incoming.readingProgress !== undefined &&
|
||||
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
|
||||
|
||||
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
|
||||
|
||||
// Merge by taking the most recent reading activity
|
||||
const existingTime = existing.readingTimestamp || existing.markedAt || 0
|
||||
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
|
||||
|
||||
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
|
||||
// Keep existing data, but update with newer reading metadata
|
||||
stateMap.set(incoming.id, {
|
||||
...existing,
|
||||
...incoming,
|
||||
// Preserve event data if incoming doesn't have it
|
||||
event: incoming.event || existing.event,
|
||||
title: incoming.title || existing.title,
|
||||
summary: incoming.summary || existing.summary,
|
||||
image: incoming.image || existing.image,
|
||||
published: incoming.published || existing.published,
|
||||
author: incoming.author || existing.author,
|
||||
// Always take reading progress if available
|
||||
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
|
||||
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// If timestamps are equal but incoming has additional data, merge it
|
||||
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
|
||||
stateMap.set(incoming.id, {
|
||||
...existing,
|
||||
...incoming,
|
||||
event: incoming.event || existing.event,
|
||||
title: incoming.title || existing.title,
|
||||
summary: incoming.summary || existing.summary,
|
||||
image: incoming.image || existing.image,
|
||||
published: incoming.published || existing.published,
|
||||
author: incoming.author || existing.author
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a readable title from a URL when no title is available.
|
||||
* Removes protocol, www, and shows domain + path.
|
||||
*/
|
||||
export function fallbackTitleFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
let title = parsed.hostname.replace(/^www\./, '')
|
||||
if (parsed.pathname && parsed.pathname !== '/') {
|
||||
const path = parsed.pathname.slice(0, 40)
|
||||
title += path.length < parsed.pathname.length ? path + '...' : path
|
||||
}
|
||||
return title
|
||||
} catch {
|
||||
// If URL parsing fails, just return the URL truncated
|
||||
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||
}
|
||||
}
|
||||
|
||||
30
src/utils/readingProgressUtils.ts
Normal file
30
src/utils/readingProgressUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ReadItem } from '../services/readsService'
|
||||
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
||||
|
||||
/**
|
||||
* Filters ReadItems by reading progress
|
||||
*/
|
||||
export function filterByReadingProgress(
|
||||
items: ReadItem[],
|
||||
filter: ReadingProgressFilterType
|
||||
): ReadItem[] {
|
||||
return items.filter((item) => {
|
||||
const progress = item.readingProgress || 0
|
||||
const isMarked = item.markedAsRead || false
|
||||
|
||||
switch (filter) {
|
||||
case 'unopened':
|
||||
return progress === 0 && !isMarked
|
||||
case 'started':
|
||||
return progress > 0 && progress <= 0.10 && !isMarked
|
||||
case 'reading':
|
||||
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||
case 'completed':
|
||||
return progress >= 0.95 || isMarked
|
||||
case 'all':
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
71
src/utils/readsFromBookmarks.ts
Normal file
71
src/utils/readsFromBookmarks.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { ReadItem } from '../services/readsService'
|
||||
import { classifyBookmarkType } from './bookmarkTypeClassifier'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
|
||||
* Returns items with type='article', using hydrated data when available.
|
||||
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
|
||||
*/
|
||||
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
|
||||
for (const bookmark of allBookmarks) {
|
||||
const bookmarkType = classifyBookmarkType(bookmark)
|
||||
|
||||
// Only include articles (kind:30023)
|
||||
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
|
||||
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
|
||||
|
||||
// Extract identifier from coordinate
|
||||
const parts = coordinate.split(':')
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
// Convert to naddr format (reading positions use naddr as ID)
|
||||
let naddr: string
|
||||
try {
|
||||
naddr = nip19.naddrEncode({
|
||||
kind: KINDS.BlogPost,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Failed to encode naddr for bookmark:', coordinate)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract metadata from tags (same as BookmarkItem does)
|
||||
const title = bookmark.content || 'Untitled'
|
||||
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
|
||||
|
||||
const item: ReadItem = {
|
||||
id: naddr, // Use naddr format to match reading positions
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
published: published ? parseInt(published) : undefined,
|
||||
author: bookmark.pubkey
|
||||
}
|
||||
|
||||
readsMap.set(naddr, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent bookmark activity
|
||||
return Array.from(readsMap.values()).sort((a, b) => {
|
||||
const timeA = a.readingTimestamp || 0
|
||||
const timeB = b.readingTimestamp || 0
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user