mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
Revert "Add reading progress filters and split Reads/Links tabs"
This commit is contained in:
@@ -1,136 +0,0 @@
|
|||||||
<!-- 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
|
|
||||||
11
src/App.tsx
11
src/App.tsx
@@ -112,16 +112,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reads"
|
path="/me/archive"
|
||||||
element={
|
|
||||||
<Bookmarks
|
|
||||||
relayPool={relayPool}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/me/links"
|
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
|
|
||||||
interface ReadingProgressFiltersProps {
|
interface ArchiveFiltersProps {
|
||||||
selectedFilter: ReadingProgressFilterType
|
selectedFilter: ArchiveFilterType
|
||||||
onFilterChange: (filter: ReadingProgressFilterType) => void
|
onFilterChange: (filter: ArchiveFilterType) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
const filters = [
|
const filters = [
|
||||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,5 +36,5 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ReadingProgressFilters
|
export default ArchiveFilters
|
||||||
|
|
||||||
@@ -19,10 +19,9 @@ interface BookmarkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
readingProgress?: number // 0-1 reading progress (optional)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -151,7 +150,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
|
|
||||||
if (viewMode === 'large') {
|
if (viewMode === 'large') {
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
|
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { RELAYS } from '../config/relays'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -40,8 +39,6 @@ interface BookmarkListProps {
|
|||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
readingPositions?: Map<string, number>
|
|
||||||
markedAsReadIds?: Set<string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -60,16 +57,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
loading = false,
|
loading = false,
|
||||||
relayPool,
|
relayPool,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
settings,
|
settings
|
||||||
readingPositions,
|
|
||||||
markedAsReadIds
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
@@ -96,42 +90,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
|
||||||
// Apply type filter
|
// Apply filter
|
||||||
const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
const filteredBookmarks = 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
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
@@ -244,7 +204,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelectUrl={onSelectUrl}
|
onSelectUrl={onSelectUrl}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,17 +211,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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-controls">
|
||||||
<div className="view-mode-left">
|
<div className="view-mode-left">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ interface LargeViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
readingProgress?: number // 0-1 reading progress (optional)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -39,19 +38,11 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon,
|
contentTypeIcon
|
||||||
readingProgress
|
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
// Calculate progress display
|
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
|
||||||
const progressColor =
|
|
||||||
progressPercent >= 95 ? '#10b981' : // green for completed
|
|
||||||
progressPercent > 5 ? '#f97316' : // orange for in-progress
|
|
||||||
'var(--color-border)' // default for not started
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -101,28 +92,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
|
||||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '3px',
|
|
||||||
width: '100%',
|
|
||||||
background: 'var(--color-border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '0.75rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/reads' ? 'reads' :
|
location.pathname === '/me/archive' ? 'archive' :
|
||||||
location.pathname === '/me/links' ? 'links' :
|
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
@@ -162,9 +161,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll,
|
handleRefreshAll
|
||||||
readingPositions,
|
|
||||||
markedAsReadIds
|
|
||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
@@ -173,8 +170,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings,
|
settings
|
||||||
eventStore
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -316,8 +312,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
highlightButtonRef={highlightButtonRef}
|
highlightButtonRef={highlightButtonRef}
|
||||||
onCreateHighlight={handleCreateHighlight}
|
onCreateHighlight={handleCreateHighlight}
|
||||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||||
readingPositions={readingPositions}
|
|
||||||
markedAsReadIds={markedAsReadIds}
|
|
||||||
explore={showExplore ? (
|
explore={showExplore ? (
|
||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -187,77 +187,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
syncEnabled: settings?.syncReadingPosition,
|
syncEnabled: settings?.syncReadingPosition,
|
||||||
onSave: handleSavePosition
|
onSave: handleSavePosition,
|
||||||
|
onReadingComplete: () => {
|
||||||
|
// Optional: Auto-mark as read when reading is complete
|
||||||
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
|
// Could trigger auto-mark as read here if desired
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
|
||||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
|
||||||
|
|
||||||
// Define handleMarkAsRead with useCallback to use in auto-mark effect
|
|
||||||
const handleMarkAsRead = useCallback(() => {
|
|
||||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instantly update UI with checkmark animation
|
|
||||||
setIsMarkedAsRead(true)
|
|
||||||
setShowCheckAnimation(true)
|
|
||||||
|
|
||||||
// Reset animation after it completes (2.5s for full fancy animation)
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowCheckAnimation(false)
|
|
||||||
}, 2500)
|
|
||||||
|
|
||||||
// Fire-and-forget: publish in background without blocking UI
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
if (isNostrArticle && currentArticle) {
|
|
||||||
await createEventReaction(
|
|
||||||
currentArticle.id,
|
|
||||||
currentArticle.pubkey,
|
|
||||||
currentArticle.kind,
|
|
||||||
activeAccount,
|
|
||||||
relayPool
|
|
||||||
)
|
|
||||||
console.log('✅ Marked nostr article as read')
|
|
||||||
} else if (selectedUrl) {
|
|
||||||
await createWebsiteReaction(
|
|
||||||
selectedUrl,
|
|
||||||
activeAccount,
|
|
||||||
relayPool
|
|
||||||
)
|
|
||||||
console.log('✅ Marked website as read')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to mark as read:', error)
|
|
||||||
// Revert UI state on error
|
|
||||||
setIsMarkedAsRead(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [activeAccount, relayPool, isMarkedAsRead, isNostrArticle, currentArticle, selectedUrl])
|
|
||||||
|
|
||||||
// Auto-mark as read when reaching 100% for 2 seconds
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settings?.autoMarkAsReadAt100 || isMarkedAsRead || !activeAccount || !relayPool) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only trigger when progress is exactly 100%
|
|
||||||
if (progressPercentage === 100) {
|
|
||||||
console.log('📍 [ContentPanel] Progress at 100%, starting 2-second timer for auto-mark')
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
console.log('✅ [ContentPanel] Auto-marking as read after 2 seconds at 100%')
|
|
||||||
handleMarkAsRead()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log('⏹️ [ContentPanel] Canceling auto-mark timer (progress changed or unmounting)')
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [progressPercentage, settings?.autoMarkAsReadAt100, isMarkedAsRead, activeAccount, relayPool, handleMarkAsRead])
|
|
||||||
|
|
||||||
// Load saved reading position when article loads
|
// Load saved reading position when article loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
@@ -288,9 +226,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
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
|
// Wait for content to be fully rendered before scrolling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
@@ -304,9 +239,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||||
}, 500) // Give content time to render
|
}, 500) // Give content time to render
|
||||||
} else {
|
|
||||||
console.log('⏭️ [ContentPanel] Auto-scroll disabled in settings')
|
|
||||||
}
|
|
||||||
} else if (savedPosition) {
|
} else if (savedPosition) {
|
||||||
if (savedPosition.position === 1) {
|
if (savedPosition.position === 1) {
|
||||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||||
@@ -320,7 +252,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadPosition()
|
loadPosition()
|
||||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl])
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
// Save position before unmounting or changing article
|
// Save position before unmounting or changing article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -392,6 +324,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
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)
|
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||||
|
|
||||||
// Track external video duration (in seconds) for display in header
|
// Track external video duration (in seconds) for display in header
|
||||||
@@ -661,6 +595,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
checkReadStatus()
|
checkReadStatus()
|
||||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
}, [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) {
|
if (!selectedUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
|||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
|
|
||||||
import { fetchReadArticles } from '../services/libraryService'
|
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -43,8 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
|
||||||
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// Visibility filters (defaults from settings, or friends only)
|
// Visibility filters (defaults from settings, or friends only)
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
@@ -217,88 +213,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
}, [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
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
@@ -388,7 +302,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
readingProgress={markedAsReadIds.has(post.event.id) ? 1.0 : readingPositions.get(post.event.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
@@ -10,8 +10,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||||
import { fetchLinks } from '../services/linksService'
|
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -20,15 +19,15 @@ import BlogPostCard from './BlogPostCard'
|
|||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
||||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -36,10 +35,11 @@ interface MeProps {
|
|||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
@@ -48,15 +48,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||||
const [links, setLinks] = useState<ReadItem[]>([])
|
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
|
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
||||||
|
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,74 +64,41 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
// Tab-specific loading functions
|
useEffect(() => {
|
||||||
const loadHighlightsTab = async () => {
|
const loadData = async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) {
|
||||||
|
setLoading(false)
|
||||||
// Only show loading skeleton if tab hasn't been loaded yet
|
return
|
||||||
const hasBeenLoaded = loadedTabs.has('highlights')
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
setLoading(true)
|
||||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights and writings (public data)
|
||||||
|
const [userHighlights, userWritings] = await Promise.all([
|
||||||
|
fetchHighlights(relayPool, viewingPubkey),
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
])
|
||||||
|
|
||||||
setHighlights(userHighlights)
|
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)
|
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 () => {
|
// Only fetch private data for own profile
|
||||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
if (isOwnProfile && activeAccount) {
|
||||||
|
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||||
|
setReadArticles(userReadArticles)
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
// Fetch bookmarks using callback pattern
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Fetch bookmarks first (needed for reads)
|
|
||||||
let fetchedBookmarks: Bookmark[] = []
|
let fetchedBookmarks: Bookmark[] = []
|
||||||
try {
|
try {
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
@@ -141,88 +107,88 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load bookmarks:', err)
|
console.warn('Failed to load bookmarks:', err)
|
||||||
fetchedBookmarks = []
|
setBookmarks([])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all reads
|
// Update cache with all fetched data
|
||||||
const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks)
|
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||||
setReads(userReads)
|
} else {
|
||||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
setBookmarks([])
|
||||||
|
setReadArticles([])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load reads:', err)
|
console.error('Failed to load data:', err)
|
||||||
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadLinksTab = async () => {
|
|
||||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('links')
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
|
||||||
|
|
||||||
// Fetch links (external URLs with reading progress)
|
|
||||||
const userLinks = await fetchLinks(relayPool, viewingPubkey)
|
|
||||||
setLinks(userLinks)
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('links'))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load links:', err)
|
|
||||||
} finally {
|
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load active tab data
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewingPubkey || !activeTab) {
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||||
|
|
||||||
|
// Load reading positions for read articles (only for own profile)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPositions = async () => {
|
||||||
|
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
||||||
|
console.log('🔍 [Archive] Skipping position load:', {
|
||||||
|
isOwnProfile,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
articlesCount: readArticles.length
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load cached data immediately if available
|
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
||||||
if (isOwnProfile) {
|
|
||||||
const cached = getCachedMeData(viewingPubkey)
|
const positions = new Map<string, number>()
|
||||||
if (cached) {
|
|
||||||
setHighlights(cached.highlights)
|
// Load positions for all read articles
|
||||||
setBookmarks(cached.bookmarks)
|
await Promise.all(
|
||||||
setReads(cached.reads || [])
|
readArticles.map(async (post) => {
|
||||||
setLinks(cached.links || [])
|
try {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const articleUrl = `nostr:${naddr}`
|
||||||
|
const identifier = generateArticleIdentifier(articleUrl)
|
||||||
|
|
||||||
|
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
||||||
|
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0) {
|
||||||
|
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
||||||
|
positions.set(post.event.id, savedPosition.position)
|
||||||
|
} else {
|
||||||
|
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
||||||
|
setReadingPositions(positions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data for active tab (refresh in background if already loaded)
|
loadPositions()
|
||||||
switch (activeTab) {
|
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
||||||
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 - only reload active tab
|
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
// Clear the loaded state for current tab to force refresh
|
|
||||||
setLoadedTabs(prev => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
newSet.delete(activeTab)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
setRefreshTrigger(prev => prev + 1)
|
setRefreshTrigger(prev => prev + 1)
|
||||||
},
|
},
|
||||||
maximumPullLength: 240,
|
maximumPullLength: 240,
|
||||||
@@ -251,54 +217,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReadItemUrl = (item: ReadItem) => {
|
|
||||||
if (item.type === 'article' && item.event) {
|
|
||||||
const dTag = item.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind: 30023,
|
|
||||||
pubkey: item.event.pubkey,
|
|
||||||
identifier: dTag
|
|
||||||
})
|
|
||||||
return `/a/${naddr}`
|
|
||||||
} 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 }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
// For kind:30023 articles, navigate to the article route
|
||||||
@@ -327,9 +245,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Apply reading progress filter
|
// Apply archive filter
|
||||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
const filteredReadArticles = readArticles.filter(post => {
|
||||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
const position = readingPositions.get(post.event.id)
|
||||||
|
|
||||||
|
switch (archiveFilter) {
|
||||||
|
case 'to-read':
|
||||||
|
// No position or 0% progress
|
||||||
|
return !position || position === 0
|
||||||
|
case 'reading':
|
||||||
|
// Has some progress but not completed (0 < position < 1)
|
||||||
|
return position !== undefined && position > 0 && position < 0.95
|
||||||
|
case 'completed':
|
||||||
|
// 95% or more read (we consider 95%+ as completed)
|
||||||
|
return position !== undefined && position >= 0.95
|
||||||
|
case 'marked':
|
||||||
|
// Manually marked as read (in archive but no reading position data)
|
||||||
|
// These are articles that were marked via the emoji reaction
|
||||||
|
return !position || position === 0
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
@@ -338,7 +276,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -353,9 +291,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 && !loading ? (
|
return highlights.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No highlights yet.
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list me-highlights-list">
|
<div className="highlights-list me-highlights-list">
|
||||||
@@ -382,9 +320,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return allIndividualBookmarks.length === 0 && !loading ? (
|
return allIndividualBookmarks.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No bookmarks yet.
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -448,9 +386,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'reads':
|
case 'archive':
|
||||||
// Show loading skeletons while fetching or if no data
|
if (showSkeletons) {
|
||||||
if (reads.length === 0 || (loading && !loadedTabs.has('reads'))) {
|
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -459,64 +396,30 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return readArticles.length === 0 ? (
|
||||||
// Show reads with filters
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
return (
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<ReadingProgressFilters
|
{readArticles.length > 0 && (
|
||||||
selectedFilter={readingProgressFilter}
|
<ArchiveFilters
|
||||||
onFilterChange={setReadingProgressFilter}
|
selectedFilter={archiveFilter}
|
||||||
|
onFilterChange={setArchiveFilter}
|
||||||
/>
|
/>
|
||||||
{filteredReads.length === 0 ? (
|
)}
|
||||||
|
{filteredReadArticles.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles match this filter.
|
No articles match this filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{filteredReads.map((item) => (
|
{filteredReadArticles.map((post) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={item.id}
|
key={post.event.id}
|
||||||
post={convertReadItemToBlogPostPreview(item)}
|
post={post}
|
||||||
href={getReadItemUrl(item)}
|
href={getPostUrl(post)}
|
||||||
readingProgress={item.readingProgress}
|
readingProgress={readingPositions.get(post.event.id)}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'links':
|
|
||||||
// Show loading skeletons while fetching or if no data
|
|
||||||
if (links.length === 0 || (loading && !loadedTabs.has('links'))) {
|
|
||||||
return (
|
|
||||||
<div className="explore-grid">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<BlogPostSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show links with filters
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ReadingProgressFilters
|
|
||||||
selectedFilter={readingProgressFilter}
|
|
||||||
onFilterChange={setReadingProgressFilter}
|
|
||||||
/>
|
|
||||||
{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>
|
</div>
|
||||||
@@ -534,9 +437,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 && !loading ? (
|
return writings.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles written yet.
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -584,20 +487,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||||
data-tab="reads"
|
data-tab="archive"
|
||||||
onClick={() => navigate('/me/reads')}
|
onClick={() => navigate('/me/archive')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Reads</span>
|
<span className="tab-label">Archive</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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,32 +117,6 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Sync reading position across devices</span>
|
<span>Sync reading position across devices</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ interface ThreePaneLayoutProps {
|
|||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
eventStore: IEventStore | null
|
eventStore: IEventStore | null
|
||||||
readingPositions?: Map<string, number>
|
|
||||||
markedAsReadIds?: Set<string>
|
|
||||||
|
|
||||||
// Content pane
|
// Content pane
|
||||||
readerLoading: boolean
|
readerLoading: boolean
|
||||||
@@ -326,8 +324,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
readingPositions={props.readingPositions}
|
|
||||||
markedAsReadIds={props.markedAsReadIds}
|
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||||
import { IEventStore } from 'applesauce-core'
|
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { fetchContacts } from '../services/contactService'
|
import { fetchContacts } from '../services/contactService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
|
|
||||||
import { fetchReadArticles } from '../services/libraryService'
|
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -21,7 +17,6 @@ interface UseBookmarksDataParams {
|
|||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
eventStore?: IEventStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksData = ({
|
export const useBookmarksData = ({
|
||||||
@@ -32,8 +27,7 @@ export const useBookmarksData = ({
|
|||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings,
|
settings
|
||||||
eventStore
|
|
||||||
}: UseBookmarksDataParams) => {
|
}: UseBookmarksDataParams) => {
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||||
@@ -42,8 +36,6 @@ export const useBookmarksData = ({
|
|||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
|
||||||
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
const handleFetchContacts = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
@@ -133,93 +125,6 @@ export const useBookmarksData = ({
|
|||||||
handleFetchContacts()
|
handleFetchContacts()
|
||||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, 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 {
|
return {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
@@ -232,9 +137,7 @@ export const useBookmarksData = ({
|
|||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
handleFetchBookmarks,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll,
|
handleRefreshAll
|
||||||
readingPositions,
|
|
||||||
markedAsReadIds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import { fetchReadArticles } from './libraryService'
|
|
||||||
import { queryEvents } from './dataFetch'
|
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { ReadItem } from './readsService'
|
|
||||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
|
||||||
|
|
||||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
): Promise<ReadItem[]> {
|
|
||||||
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch all data sources in parallel
|
|
||||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
|
||||||
queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
|
||||||
fetchReadArticles(relayPool, userPubkey)
|
|
||||||
])
|
|
||||||
|
|
||||||
console.log('📊 [Links] Data fetched:', {
|
|
||||||
readingPositions: readingPositionEvents.length,
|
|
||||||
markedAsRead: markedAsReadArticles.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process data using shared utilities
|
|
||||||
const linksMap = new Map<string, ReadItem>()
|
|
||||||
processReadingPositions(readingPositionEvents, linksMap)
|
|
||||||
processMarkedAsRead(markedAsReadArticles, linksMap)
|
|
||||||
|
|
||||||
// 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,12 +1,11 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { ReadItem } from './readsService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
reads: ReadItem[]
|
readArticles: BlogPostPreview[]
|
||||||
links: ReadItem[]
|
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,14 +21,12 @@ export function setCachedMeData(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
highlights: Highlight[],
|
highlights: Highlight[],
|
||||||
bookmarks: Bookmark[],
|
bookmarks: Bookmark[],
|
||||||
reads: ReadItem[],
|
readArticles: BlogPostPreview[]
|
||||||
links: ReadItem[] = []
|
|
||||||
): void {
|
): void {
|
||||||
meCache.set(pubkey, {
|
meCache.set(pubkey, {
|
||||||
highlights,
|
highlights,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
reads,
|
readArticles,
|
||||||
links,
|
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -48,10 +45,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
|
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||||
const existing = meCache.get(pubkey)
|
const existing = meCache.get(pubkey)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
|
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
|
||||||
import { ReadItem } from './readsService'
|
|
||||||
|
|
||||||
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 or proper 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
|
|
||||||
|
|
||||||
// Filter out items without titles
|
|
||||||
if (!item.title || item.title === 'Untitled') {
|
|
||||||
// For Nostr articles, we need the title from the event
|
|
||||||
if (item.type === 'article' && !item.event) return false
|
|
||||||
// For external URLs, we need a proper title
|
|
||||||
if (item.type === 'external' && !item.title) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
|
||||||
import { Helpers } from 'applesauce-core'
|
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
|
||||||
import { fetchReadArticles } from './libraryService'
|
|
||||||
import { queryEvents } from './dataFetch'
|
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
|
||||||
|
|
||||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
|
||||||
|
|
||||||
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[]
|
|
||||||
): Promise<ReadItem[]> {
|
|
||||||
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch all data sources in parallel
|
|
||||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
|
||||||
queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
|
||||||
fetchReadArticles(relayPool, userPubkey)
|
|
||||||
])
|
|
||||||
|
|
||||||
console.log('📊 [Reads] Data fetched:', {
|
|
||||||
readingPositions: readingPositionEvents.length,
|
|
||||||
markedAsRead: markedAsReadArticles.length,
|
|
||||||
bookmarks: bookmarks.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process data using shared utilities
|
|
||||||
const readsMap = new Map<string, ReadItem>()
|
|
||||||
processReadingPositions(readingPositionEvents, readsMap)
|
|
||||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
|
||||||
|
|
||||||
// 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 and external article/website bookmarks
|
|
||||||
if (bookmarkType === 'article') {
|
|
||||||
// Kind:30023 nostr article
|
|
||||||
const coordinate = bookmark.id // Already in coordinate format
|
|
||||||
const existing = readsMap.get(coordinate)
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
readsMap.set(coordinate, {
|
|
||||||
id: coordinate,
|
|
||||||
source: 'bookmark',
|
|
||||||
type: 'article',
|
|
||||||
readingProgress: 0,
|
|
||||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (bookmarkType === 'external') {
|
|
||||||
// External article URL
|
|
||||||
const urls = extractUrlFromBookmark(bookmark)
|
|
||||||
if (urls.length > 0) {
|
|
||||||
const url = urls[0]
|
|
||||||
const existing = readsMap.get(url)
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
readsMap.set(url, {
|
|
||||||
id: url,
|
|
||||||
source: 'bookmark',
|
|
||||||
type: 'external',
|
|
||||||
url,
|
|
||||||
readingProgress: 0,
|
|
||||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 === 30023) {
|
|
||||||
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 && parts[0] === '30023') {
|
|
||||||
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: [30023], authors, '#d': identifiers },
|
|
||||||
{ relayUrls: RELAYS }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Merge event data into ReadItems
|
|
||||||
for (const event of events) {
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `30023:${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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract URL from bookmark content
|
|
||||||
function extractUrlFromBookmark(bookmark: IndividualBookmark): string[] {
|
|
||||||
const urls: string[] = []
|
|
||||||
|
|
||||||
// Check for web bookmark (kind 39701) with 'd' tag
|
|
||||||
if (bookmark.kind === 39701) {
|
|
||||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
|
||||||
if (dTag) {
|
|
||||||
urls.push(dTag.startsWith('http') ? dTag : `https://${dTag}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract URLs from content
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
|
||||||
const matches = bookmark.content.match(urlRegex)
|
|
||||||
if (matches) {
|
|
||||||
urls.push(...matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -56,8 +56,6 @@ export interface UserSettings {
|
|||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
// Reading position sync
|
// Reading position sync
|
||||||
syncReadingPosition?: boolean // default: false (opt-in)
|
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(
|
export async function loadSettings(
|
||||||
|
|||||||
@@ -216,72 +216,7 @@
|
|||||||
.mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); }
|
.mark-as-read-btn: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:active:not(:disabled) { transform: translateY(0); }
|
||||||
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.mark-as-read-btn svg { font-size: 1.1rem; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
.mark-as-read-btn svg { font-size: 1.1rem; }
|
||||||
|
|
||||||
/* 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) {
|
@media (max-width: 768px) {
|
||||||
.reader {
|
.reader {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -211,12 +211,3 @@
|
|||||||
background: transparent;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user