import React, { useState, useEffect } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' import { nip19 } from 'nostr-tools' import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchLinks } from '../services/linksService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { RELAYS } from '../config/relays' import { Bookmark, IndividualBookmark } from '../types/bookmarks' import AuthorCard from './AuthorCard' import BlogPostCard from './BlogPostCard' import { BookmarkItem } from './BookmarkItem' import IconButton from './IconButton' import { ViewMode } from './Bookmarks' import { getCachedMeData, updateCachedHighlights } from '../services/meCache' import { faBooks } from '../icons/customIcons' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' interface MeProps { relayPool: RelayPool activeTab?: TabType pubkey?: string // Optional pubkey for viewing other users' profiles bookmarks: Bookmark[] // From centralized App.tsx state bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use) } type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings' // Valid reading progress filters const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed'] const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey, bookmarks }) => { const activeAccount = Hooks.useActiveAccount() const navigate = useNavigate() const { filter: urlFilter } = useParams<{ filter?: string }>() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') // Use provided pubkey or fall back to active account const viewingPubkey = propPubkey || activeAccount?.pubkey const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey) const [highlights, setHighlights] = useState([]) const [reads, setReads] = useState([]) const [, setReadsMap] = useState>(new Map()) const [links, setLinks] = useState([]) const [, setLinksMap] = useState>(new Map()) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) const [loadedTabs, setLoadedTabs] = useState>(new Set()) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') // Initialize reading progress filter from URL param const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType) ? (urlFilter as ReadingProgressFilterType) : 'all' const [readingProgressFilter, setReadingProgressFilter] = useState(initialFilter) // Update local state when prop changes useEffect(() => { if (propActiveTab) { setActiveTab(propActiveTab) } }, [propActiveTab]) // Sync filter state with URL changes useEffect(() => { const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType) ? (urlFilter as ReadingProgressFilterType) : 'all' setReadingProgressFilter(filterFromUrl) }, [urlFilter]) // Handler to change reading progress filter and update URL const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => { setReadingProgressFilter(filter) if (activeTab === 'reads') { if (filter === 'all') { navigate('/me/reads', { replace: true }) } else { navigate(`/me/reads/${filter}`, { replace: true }) } } } // Tab-specific loading functions const loadHighlightsTab = async () => { if (!viewingPubkey) return // Only show loading skeleton if tab hasn't been loaded yet const hasBeenLoaded = loadedTabs.has('highlights') try { if (!hasBeenLoaded) setLoading(true) const userHighlights = await fetchHighlights(relayPool, viewingPubkey) setHighlights(userHighlights) setLoadedTabs(prev => new Set(prev).add('highlights')) } catch (err) { console.error('Failed to load highlights:', err) } finally { if (!hasBeenLoaded) setLoading(false) } } const loadWritingsTab = async () => { if (!viewingPubkey) return const hasBeenLoaded = loadedTabs.has('writings') try { if (!hasBeenLoaded) setLoading(true) const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) setWritings(userWritings) setLoadedTabs(prev => new Set(prev).add('writings')) } catch (err) { console.error('Failed to load writings:', err) } finally { if (!hasBeenLoaded) setLoading(false) } } const loadReadingListTab = async () => { if (!viewingPubkey || !isOwnProfile || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reading-list') try { if (!hasBeenLoaded) setLoading(true) // Bookmarks come from centralized loading in App.tsx 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) // Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx) const initialReads = deriveReadsFromBookmarks(bookmarks) const initialMap = new Map(initialReads.map(item => [item.id, item])) setReadsMap(initialMap) setReads(initialReads) setLoadedTabs(prev => new Set(prev).add('reads')) if (!hasBeenLoaded) setLoading(false) // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => { console.log('📈 [Reads] Enrichment item received:', { id: item.id.slice(0, 20) + '...', progress: item.readingProgress, hasProgress: item.readingProgress !== undefined && item.readingProgress > 0 }) setReadsMap(prevMap => { // Only update if item exists in our current map if (!prevMap.has(item.id)) { console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...') return prevMap } const newMap = new Map(prevMap) const merged = mergeReadItem(newMap, item) if (merged) { console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress) // Update reads array after map is updated setReads(Array.from(newMap.values())) return newMap } return prevMap }) }).catch(err => console.warn('Failed to enrich reads:', err)) } catch (err) { console.error('Failed to load reads:', err) if (!hasBeenLoaded) setLoading(false) } } const loadLinksTab = async () => { if (!viewingPubkey || !isOwnProfile || !activeAccount) return const hasBeenLoaded = loadedTabs.has('links') try { if (!hasBeenLoaded) setLoading(true) // Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx) const initialLinks = deriveLinksFromBookmarks(bookmarks) const initialMap = new Map(initialLinks.map(item => [item.id, item])) setLinksMap(initialMap) setLinks(initialLinks) setLoadedTabs(prev => new Set(prev).add('links')) if (!hasBeenLoaded) setLoading(false) // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map fetchLinks(relayPool, viewingPubkey, (item) => { setLinksMap(prevMap => { // Only update if item exists in our current map if (!prevMap.has(item.id)) return prevMap const newMap = new Map(prevMap) if (mergeReadItem(newMap, item)) { // Update links array after map is updated setLinks(Array.from(newMap.values())) return newMap } return prevMap }) }).catch(err => console.warn('Failed to enrich links:', err)) } catch (err) { console.error('Failed to load links:', err) if (!hasBeenLoaded) setLoading(false) } } // Load active tab data useEffect(() => { if (!viewingPubkey || !activeTab) { setLoading(false) return } // Load cached data immediately if available if (isOwnProfile) { const cached = getCachedMeData(viewingPubkey) if (cached) { setHighlights(cached.highlights) // Bookmarks come from App.tsx centralized state, no local caching needed setReads(cached.reads || []) setLinks(cached.links || []) } } // Load data for active tab (refresh in background if already loaded) switch (activeTab) { case 'highlights': loadHighlightsTab() break case 'writings': loadWritingsTab() break case 'reading-list': loadReadingListTab() break case 'reads': loadReadsTab() break case 'links': loadLinksTab() break } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab, viewingPubkey, refreshTrigger]) // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { // Just trigger refresh - loaders will merge new data setRefreshTrigger(prev => prev + 1) }, maximumPullLength: 240, refreshThreshold: 80, isDisabled: !viewingPubkey }) const handleHighlightDelete = (highlightId: string) => { setHighlights(prev => { const updated = prev.filter(h => h.id !== highlightId) // Update cache when highlight is deleted (own profile only) if (isOwnProfile && viewingPubkey) { updateCachedHighlights(viewingPubkey, updated) } return updated }) } const getPostUrl = (post: BlogPostPreview) => { const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' const naddr = nip19.naddrEncode({ kind: 30023, pubkey: post.author, identifier: dTag }) return `/a/${naddr}` } const getReadItemUrl = (item: ReadItem) => { if (item.type === 'article') { // ID is already in naddr format return `/a/${item.id}` } else if (item.url) { return `/r/${encodeURIComponent(item.url)}` } return '#' } const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => { if (item.event) { return { event: item.event, title: item.title || 'Untitled', summary: item.summary, image: item.image, published: item.published, author: item.author || item.event.pubkey } } // Create a mock event for external URLs const mockEvent = { id: item.id, pubkey: item.author || '', created_at: item.readingTimestamp || Math.floor(Date.now() / 1000), kind: 1, tags: [] as string[][], content: item.title || item.url || 'Untitled', sig: '' } as const return { event: mockEvent as unknown as import('nostr-tools').NostrEvent, title: item.title || item.url || 'Untitled', summary: item.summary, image: item.image, published: item.published, author: item.author || '' } } const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => { if (bookmark && bookmark.kind === 30023) { // For kind:30023 articles, navigate to the article route const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || '' if (dTag && bookmark.pubkey) { const pointer = { identifier: dTag, kind: 30023, pubkey: bookmark.pubkey, } const naddr = nip19.naddrEncode(pointer) navigate(`/a/${naddr}`) } } else if (url) { // For regular URLs, navigate to the reader route navigate(`/r/${encodeURIComponent(url)}`) } } // Merge and flatten all individual bookmarks const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) .filter(hasContent) // Apply bookmark filter const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter) const groups = groupIndividualBookmarks(filteredBookmarks) // Apply reading progress filter const filteredReads = filterByReadingProgress(reads, readingProgressFilter) const filteredLinks = filterByReadingProgress(links, readingProgressFilter) const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ { key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, { key: 'web', title: 'Web Bookmarks', items: groups.web }, { key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst } ] // 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 showSkeletons = loading && !hasData const renderTabContent = () => { switch (activeTab) { case 'highlights': if (showSkeletons) { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) } return highlights.length === 0 && !loading ? (
No highlights yet.
) : (
{highlights.map((highlight) => ( ))}
) case 'reading-list': if (showSkeletons) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) } return allIndividualBookmarks.length === 0 && !loading ? (
No bookmarks yet.
) : (
{allIndividualBookmarks.length > 0 && ( )} {filteredBookmarks.length === 0 ? (
No bookmarks match this filter.
) : ( sections.filter(s => s.items.length > 0).map(section => (

{section.title}

{section.items.map((individualBookmark, index) => ( ))}
)))}
setViewMode('compact')} title="Compact list view" ariaLabel="Compact list view" variant={viewMode === 'compact' ? 'primary' : 'ghost'} /> setViewMode('cards')} title="Cards view" ariaLabel="Cards view" variant={viewMode === 'cards' ? 'primary' : 'ghost'} /> setViewMode('large')} title="Large preview view" ariaLabel="Large preview view" variant={viewMode === 'large' ? 'primary' : 'ghost'} />
) case 'reads': // Show loading skeletons only while initially loading if (loading && !loadedTabs.has('reads')) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) } // Show empty state if loaded but no reads if (reads.length === 0 && loadedTabs.has('reads')) { return (
No articles read yet.
) } // Show reads with filters return ( <> {filteredReads.length === 0 ? (
No articles match this filter.
) : (
{filteredReads.map((item) => ( ))}
)} ) case 'links': // Show loading skeletons only while initially loading if (loading && !loadedTabs.has('links')) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) } // Show empty state if loaded but no links if (links.length === 0 && loadedTabs.has('links')) { return (
No links with reading progress yet.
) } // Show links with filters return ( <> {filteredLinks.length === 0 ? (
No links match this filter.
) : (
{filteredLinks.map((item) => ( ))}
)} ) case 'writings': if (showSkeletons) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) } return writings.length === 0 && !loading ? (
No articles written yet.
) : (
{writings.map((post) => ( ))}
) default: return null } } return (
{viewingPubkey && }
{isOwnProfile && ( <> )}
{renderTabContent()}
) } export default Me