import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { IEventStore, Helpers } from 'applesauce-core' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' import { nip19, NostrEvent } 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 { highlightsController } from '../services/highlightsController' import { writingsController } from '../services/writingsController' 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' import { useStoreTimeline } from '../hooks/useStoreTimeline' import { eventToHighlight } from '../services/highlightEventProcessor' import { KINDS } from '../config/kinds' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers interface MeProps { relayPool: RelayPool eventStore: IEventStore 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, eventStore, 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()) // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) // Get myWritings directly from controller const [myWritings, setMyWritings] = useState([]) const [myWritingsLoading, setMyWritingsLoading] = useState(false) // Load cached data from event store for OTHER profiles (not own) const cachedHighlights = useStoreTimeline( eventStore, !isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 }, eventToHighlight, [viewingPubkey, isOwnProfile] ) const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({ event, title: getArticleTitle(event) || 'Untitled', summary: getArticleSummary(event), image: getArticleImage(event), published: getArticlePublished(event), author: event.pubkey }), []) const cachedWritings = useStoreTimeline( eventStore, !isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 }, toBlogPostPreview, [viewingPubkey, isOwnProfile] ) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { const saved = localStorage.getItem('bookmarkGroupingMode') return saved === 'flat' ? 'flat' : 'grouped' }) const toggleGroupingMode = () => { const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped' setGroupingMode(newMode) localStorage.setItem('bookmarkGroupingMode', newMode) } // 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) // Subscribe to highlights controller useEffect(() => { // Get initial state immediately setMyHighlights(highlightsController.getHighlights()) // Subscribe to updates const unsubHighlights = highlightsController.onHighlights(setMyHighlights) const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) return () => { unsubHighlights() unsubLoading() } }, []) // Subscribe to writings controller useEffect(() => { // Get initial state immediately setMyWritings(writingsController.getWritings()) // Subscribe to updates const unsubWritings = writingsController.onWritings(setMyWritings) const unsubLoading = writingsController.onLoading(setMyWritingsLoading) return () => { unsubWritings() unsubLoading() } }, []) // 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) // For own profile, highlights come from controller subscription (sync effect handles it) // For viewing other users, seed with cached data then fetch fresh if (!isOwnProfile) { // Seed with cached highlights first if (cachedHighlights.length > 0) { setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at)) } // Fetch fresh highlights 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) // For own profile, use centralized controller if (isOwnProfile) { await writingsController.start({ relayPool, eventStore, pubkey: viewingPubkey, force: refreshTrigger > 0 }) setLoadedTabs(prev => new Set(prev).add('writings')) return } // For other profiles, seed with cached writings first if (cachedWritings.length > 0) { setWritings(cachedWritings.sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA })) } // Fetch fresh writings for other profiles 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]) // Sync myHighlights from controller when viewing own profile useEffect(() => { if (isOwnProfile) { setHighlights(myHighlights) } }, [isOwnProfile, myHighlights]) // Sync myWritings from controller when viewing own profile useEffect(() => { if (isOwnProfile) { setWritings(myWritings) } }, [isOwnProfile, myWritings]) // 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[] }> = groupingMode === 'flat' ? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }] : [ { key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private }, { key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public }, { key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate }, { key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic }, { key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb } ] // 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 || (isOwnProfile && myHighlightsLoading)) && !hasData const renderTabContent = () => { switch (activeTab) { case 'highlights': if (showSkeletons) { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) } return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
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