Files
boris/src/components/BookmarkList.tsx
Gigi 165d10c49b feat: split 'To read' filter into 'Unopened' and 'Started'
- Add 'unopened' filter (no progress, 0%) - uses fa-envelope icon
- Add 'started' filter (0-10% progress) - uses fa-envelope-open icon
- Remove 'to-read' filter
- Use classic/regular variant for envelope icons
- Update filter logic in BookmarkList and Me components
- New filter ranges:
  - Unopened: 0% (never opened)
  - Started: 0-10% (opened but not read far)
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:13:34 +02:00

321 lines
12 KiB
TypeScript

import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import CompactButton from './CompactButton'
import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
interface BookmarkListProps {
bookmarks: Bookmark[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
isCollapsed: boolean
onToggleCollapse: () => void
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
selectedUrl?: string
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
onSelectUrl,
isCollapsed,
onToggleCollapse,
onLogout,
viewMode,
onViewModeChange,
selectedUrl,
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
isMobile = false,
settings,
readingPositions,
markedAsReadIds
}) => {
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
// Pull-to-refresh for bookmarks
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
// Apply type filter
const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Apply reading progress filter (only affects kind:30023 articles)
const filteredBookmarks = typeFilteredBookmarks.filter(bookmark => {
// Only apply reading progress filter to kind:30023 articles
if (bookmark.kind !== 30023) return true
// If reading progress filter is 'all', show all articles
if (readingProgressFilter === 'all') return true
const isMarkedAsRead = markedAsReadIds?.has(bookmark.id)
const position = readingPositions?.get(bookmark.id)
// Marked-as-read articles are always treated as 100% complete
if (isMarkedAsRead) {
return readingProgressFilter === 'completed'
}
switch (readingProgressFilter) {
case 'unopened':
// No reading progress - never opened
return !position || position === 0
case 'started':
// 0-10% reading progress - opened but not read far
return position !== undefined && position > 0 && position <= 0.10
case 'reading':
// Has some progress but not completed (11% - 94%)
return position !== undefined && position > 0.10 && position <= 0.94
case 'completed':
// 95% or more read
return position !== undefined && position >= 0.95
default:
return true
}
})
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks as before
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
if (isCollapsed) {
// Check if the selected URL is in bookmarks
const isBookmarked = selectedUrl && bookmarks.some(bookmark => {
const bookmarkUrl = bookmark.url
return bookmarkUrl === selectedUrl || selectedUrl.includes(bookmarkUrl) || bookmarkUrl.includes(selectedUrl)
})
return (
<div className="bookmarks-container collapsed">
<button
onClick={onToggleCollapse}
className={`toggle-sidebar-btn with-icon ${isBookmarked ? 'is-bookmarked' : ''}`}
title="Expand bookmarks sidebar"
aria-label="Expand bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronLeft} />
<FontAwesomeIcon icon={faBookmark} className={isBookmarked ? 'glow-blue' : ''} />
</button>
</div>
)
}
return (
<div className="bookmarks-container">
<SidebarHeader
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
isMobile={isMobile}
/>
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
)}
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
<div className="empty-state">
<p>No bookmarks match this filter.</p>
</div>
) : allIndividualBookmarks.length === 0 ? (
loading ? (
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
) : (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
)
) : (
<div
ref={bookmarksListRef}
className="bookmarks-list"
>
<RefreshIndicator
isRefreshing={isPulling || isRefreshing || false}
pullPosition={pullPosition}
/>
{sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
{section.key === 'web' && activeAccount && (
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
)}
</div>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
/>
))}
</div>
</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-left">
<IconButton
icon={faHeart}
onClick={() => navigate('/support')}
title="Support Boris"
ariaLabel="Support"
variant="ghost"
style={{ color: friendsColor }}
/>
</div>
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</div>
)
}