mirror of
https://github.com/dergigi/boris.git
synced 2026-01-17 05:44:24 +01:00
Web bookmarks store their URL in the 'd' tag, not in content. The getBookmarkReadingProgress function was only extracting URLs from content, which meant reading progress indicators weren't showing for web bookmarks. Now it properly extracts URLs from the 'd' tag for kind:39701 bookmarks.
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import React, { useRef, useState, useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
|
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, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
|
import { UserSettings } from '../services/settingsService'
|
|
import AddBookmarkModal from './AddBookmarkModal'
|
|
import { createWebBookmark } from '../services/webBookmarkService'
|
|
import { Hooks } from 'applesauce-react'
|
|
import { getActiveRelayUrls } from '../services/relayManager'
|
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
|
import LoginOptions from './LoginOptions'
|
|
import { useEffect } from 'react'
|
|
import { readingProgressController } from '../services/readingProgressController'
|
|
import { nip19 } from 'nostr-tools'
|
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
|
|
|
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
|
|
}
|
|
|
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|
bookmarks,
|
|
onSelectUrl,
|
|
isCollapsed,
|
|
onToggleCollapse,
|
|
onLogout,
|
|
viewMode,
|
|
onViewModeChange,
|
|
selectedUrl,
|
|
onOpenSettings,
|
|
onRefresh,
|
|
isRefreshing,
|
|
loading = false,
|
|
relayPool,
|
|
isMobile = false,
|
|
settings
|
|
}) => {
|
|
const navigate = useNavigate()
|
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
|
const [showAddModal, setShowAddModal] = useState(false)
|
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
|
return saved === 'grouped' ? 'grouped' : 'flat'
|
|
})
|
|
const activeAccount = Hooks.useActiveAccount()
|
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
|
|
|
// Subscribe to reading progress updates
|
|
useEffect(() => {
|
|
// Get initial progress map
|
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
|
|
|
// Subscribe to updates
|
|
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
|
|
|
return () => {
|
|
unsubProgress()
|
|
}
|
|
}, [])
|
|
|
|
// Helper to get reading progress for a bookmark
|
|
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
|
|
if (bookmark.kind === 30023) {
|
|
// For articles, use naddr as key
|
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
|
if (!dTag) return undefined
|
|
try {
|
|
const naddr = nip19.naddrEncode({
|
|
kind: 30023,
|
|
pubkey: bookmark.pubkey,
|
|
identifier: dTag
|
|
})
|
|
return readingProgressMap.get(naddr)
|
|
} catch (err) {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
|
if (bookmark.kind === 39701) {
|
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
|
if (dTag) {
|
|
// Ensure URL has protocol
|
|
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
|
return readingProgressMap.get(url)
|
|
}
|
|
}
|
|
|
|
// For other bookmark types, try to extract URL from content
|
|
const urls = extractUrlsFromContent(bookmark.content)
|
|
if (urls.length > 0) {
|
|
return readingProgressMap.get(urls[0])
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const toggleGroupingMode = () => {
|
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
|
setGroupingMode(newMode)
|
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
|
}
|
|
|
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
|
const titles: Record<BookmarkFilterType, string> = {
|
|
'all': 'All Bookmarks',
|
|
'article': 'Bookmarked Reads',
|
|
'external': 'Bookmarked Links',
|
|
'video': 'Bookmarked Videos',
|
|
'note': 'Bookmarked Notes',
|
|
'web': 'Web Bookmarks'
|
|
}
|
|
return titles[filter]
|
|
}
|
|
|
|
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, getActiveRelayUrls(relayPool))
|
|
}
|
|
|
|
// 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 - memoized to ensure consistent sorting
|
|
const sections = useMemo(() => {
|
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
|
.filter(hasContent)
|
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
|
|
|
// Apply filter
|
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
|
|
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
|
|
|
// Group non-set bookmarks by source or flatten based on mode
|
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
|
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
|
groupingMode === 'flat'
|
|
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
|
: [
|
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
|
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
|
|
]
|
|
|
|
// Add bookmark sets as additional sections (only in grouped mode)
|
|
if (groupingMode === 'grouped') {
|
|
bookmarkSets.forEach(set => {
|
|
sectionsArray.push({
|
|
key: `set-${set.name}`,
|
|
title: set.title || set.name,
|
|
items: set.bookmarks
|
|
})
|
|
})
|
|
}
|
|
|
|
return sectionsArray
|
|
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
|
|
|
// Get all filtered bookmarks for empty state checks
|
|
const allIndividualBookmarks = useMemo(() =>
|
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
|
.filter(hasContent)
|
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
|
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
|
)
|
|
|
|
const filteredBookmarks = useMemo(() =>
|
|
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
|
[allIndividualBookmarks, selectedFilter]
|
|
)
|
|
|
|
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}
|
|
/>
|
|
)}
|
|
|
|
{!activeAccount ? (
|
|
<LoginOptions />
|
|
) : 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>
|
|
</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={getBookmarkReadingProgress(individualBookmark)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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 }}
|
|
/>
|
|
{activeAccount && (
|
|
<IconButton
|
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
|
onClick={toggleGroupingMode}
|
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
</div>
|
|
{activeAccount && (
|
|
<div className="view-mode-right">
|
|
<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>
|
|
)
|
|
}
|