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, faRotate, faHeart, faPlus, faLayerGroup, faBars } 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, 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 = ({ bookmarks, onSelectUrl, isCollapsed, onToggleCollapse, onLogout, viewMode, onViewModeChange, selectedUrl, onOpenSettings, onRefresh, isRefreshing, lastFetchTime, loading = false, relayPool, isMobile = false, settings }) => { const navigate = useNavigate() const bookmarksListRef = useRef(null) const friendsColor = settings?.highlightColorFriends || '#f97316' const [showAddModal, setShowAddModal] = useState(false) const [selectedFilter, setSelectedFilter] = useState('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>(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 and other types, try to use URL if available 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 = { '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: '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 }, { key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb } ] // 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 (
) } return (
{allIndividualBookmarks.length > 0 && ( )} {!activeAccount ? ( ) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (

No bookmarks match this filter.

) : allIndividualBookmarks.length === 0 ? ( loading ? (
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => ( ))}
) : (

No bookmarks found.

Add bookmarks using your nostr client to see them here.

) ) : (
{sections.filter(s => s.items.length > 0).map(section => (

{section.title}

{section.key === 'web' && activeAccount && ( setShowAddModal(true)} title="Add web bookmark" ariaLabel="Add web bookmark" className="bookmark-section-action" /> )}
{section.items.map((individualBookmark, index) => ( ))}
))}
)}
navigate('/support')} title="Support Boris" ariaLabel="Support" variant="ghost" style={{ color: friendsColor }} />
{activeAccount && (
{onRefresh && ( )} onViewModeChange('compact')} title="Compact list view" ariaLabel="Compact list view" variant={viewMode === 'compact' ? 'primary' : 'ghost'} /> onViewModeChange('cards')} title="Cards view" ariaLabel="Cards view" variant={viewMode === 'cards' ? 'primary' : 'ghost'} /> onViewModeChange('large')} title="Large preview view" ariaLabel="Large preview view" variant={viewMode === 'large' ? 'primary' : 'ghost'} />
)}
{showAddModal && ( setShowAddModal(false)} onSave={handleSaveBookmark} /> )}
) }