import React, { useState, useEffect, useCallback } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { IEventStore } from 'applesauce-core' 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 { highlightsController } from '../services/highlightsController' import { writingsController } from '../services/writingsController' import { fetchLinks } from '../services/linksService' import { ReadItem, readsController } from '../services/readsController' import { BlogPostPreview } from '../services/exploreService' import { Bookmark, IndividualBookmark } from '../types/bookmarks' import AuthorCard from './AuthorCard' import BlogPostCard from './BlogPostCard' import { BookmarkItem } from './BookmarkItem' import IconButton from './IconButton' 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, hasCreationDate } from '../utils/bookmarkUtils' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { readingProgressController } from '../services/readingProgressController' import { archiveController } from '../services/archiveController' import { UserSettings } from '../services/settingsService' interface MeProps { relayPool: RelayPool eventStore: IEventStore activeTab?: TabType bookmarks: Bookmark[] // From centralized App.tsx state bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use) settings: UserSettings } type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings' // Valid reading progress filters const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive'] const Me: React.FC = ({ relayPool, eventStore, activeTab: propActiveTab, bookmarks, settings }) => { const activeAccount = Hooks.useActiveAccount() const navigate = useNavigate() const { filter: urlFilter } = useParams<{ filter?: string }>() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') // Only for own profile const viewingPubkey = activeAccount?.pubkey const [highlights, setHighlights] = useState([]) const [reads, setReads] = useState([]) 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) 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 // Backward compat: map legacy 'emoji' route to 'archive' const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType) ? (normalizedUrlFilter as ReadingProgressFilterType) : 'all' const [readingProgressFilter, setReadingProgressFilter] = useState(initialFilter) // Reading progress state for writings tab (naddr -> progress 0-1) const [readingProgressMap, setReadingProgressMap] = useState>(new Map()) // 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() } }, []) // Sync activeTab from prop when route changes useEffect(() => { if (propActiveTab) { setActiveTab(propActiveTab) } }, [propActiveTab]) // Sync filter state with URL changes useEffect(() => { const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType) ? (normalized 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 }) } } else if (activeTab === 'links') { if (filter === 'all') { navigate('/me/links', { replace: true }) } else { navigate(`/me/links/${filter}`, { replace: true }) } } } // Subscribe to reads controller useEffect(() => { // Get initial state immediately setReads(readsController.getReads()) // Subscribe to updates const unsubReads = readsController.onReads(setReads) return () => { unsubReads() } }, []) // Subscribe to reading progress map for writings and links enrichment useEffect(() => { // Get initial state immediately setReadingProgressMap(readingProgressController.getProgressMap()) // Subscribe to updates const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) return () => { unsubProgress() } }, []) // Load reading progress data for writings tab useEffect(() => { if (!viewingPubkey) { return } readingProgressController.start({ relayPool, eventStore, pubkey: viewingPubkey, force: refreshTrigger > 0 }) }, [viewingPubkey, relayPool, eventStore, refreshTrigger]) // Tab-specific loading functions const loadHighlightsTab = useCallback(async () => { if (!viewingPubkey) return // Highlights come from controller subscription (sync effect handles it) setLoadedTabs(prev => new Set(prev).add('highlights')) setLoading(false) }, [viewingPubkey]) const loadWritingsTab = useCallback(async () => { if (!viewingPubkey) return try { // Use centralized controller await writingsController.start({ relayPool, eventStore, pubkey: viewingPubkey, force: refreshTrigger > 0 }) setLoadedTabs(prev => new Set(prev).add('writings')) setLoading(false) } catch (err) { console.error('Failed to load writings:', err) setLoading(false) } }, [viewingPubkey, relayPool, eventStore, refreshTrigger]) const loadReadingListTab = useCallback(async () => { if (!viewingPubkey || !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) } }, [viewingPubkey, activeAccount, loadedTabs]) const loadReadsTab = useCallback(async () => { if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reads') try { if (!hasBeenLoaded) setLoading(true) // Use readsController to get reads with progressive hydration await readsController.start({ relayPool, eventStore, pubkey: viewingPubkey }) setLoadedTabs(prev => new Set(prev).add('reads')) if (!hasBeenLoaded) setLoading(false) } catch (err) { console.error('Failed to load reads:', err) if (!hasBeenLoaded) setLoading(false) } }, [viewingPubkey, activeAccount, loadedTabs, relayPool, eventStore]) const loadLinksTab = useCallback(async () => { if (!viewingPubkey || !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 (item.type === 'article' && item.author) { const progress = readingProgressMap.get(item.id) if (progress !== undefined) { newMap.set(item.id, { ...item, readingProgress: progress }) } } return newMap }) }).catch(err => console.warn('Failed to enrich links:', err)) } catch (err) { console.error('Failed to load links:', err) if (!hasBeenLoaded) setLoading(false) } }, [viewingPubkey, activeAccount, loadedTabs, bookmarks, relayPool, readingProgressMap]) // Load active tab data const loadActiveTab = useCallback(() => { if (!viewingPubkey || !activeTab) { setLoading(false) return } // Load cached data immediately if available 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 } }, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab]) useEffect(() => { loadActiveTab() }, [loadActiveTab]) // Sync myHighlights from controller useEffect(() => { setHighlights(myHighlights) }, [myHighlights]) // Sync myWritings from controller useEffect(() => { setWritings(myWritings) }, [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 if (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)}`) } } // Helper to get reading progress for a post const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => { const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] if (!dTag) return undefined try { const naddr = nip19.naddrEncode({ kind: 30023, pubkey: post.author, identifier: dTag }) return readingProgressMap.get(naddr) } catch (err) { return undefined } } // Helper to get reading progress for a bookmark const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => { if (bookmark.kind === 30023) { 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 } } return undefined } // Merge and flatten all individual bookmarks const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) .filter(hasContent) .filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)) // Apply bookmark filter const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter) const groups = groupIndividualBookmarks(filteredBookmarks) // Enrich links with reading progress (reads already have progress from controller) const linksWithProgress = links.map(item => { if (item.url) { const progress = readingProgressMap.get(item.url) if (progress !== undefined) { return { ...item, readingProgress: progress } } } return item }) // Apply reading progress filter with simple type separation to keep Views distinct and DRY const filteredReads = filterByReadingProgress( reads.filter(item => item.type === 'article'), readingProgressFilter, highlights ) const filteredLinks = filterByReadingProgress( linksWithProgress.filter(item => item.type === 'external'), readingProgressFilter, highlights ) // Helper: build archive-only list from marked IDs and a base list const buildArchiveOnly = ( baseItems: ReadItem[], options: { kind: 'article' | 'external' } ): ReadItem[] => { const allMarked = archiveController.getMarkedIds() const relevantMarked = options.kind === 'article' ? allMarked.filter(id => id.startsWith('naddr1')) : allMarked.filter(id => !id.startsWith('naddr1')) const markedSet = new Set(relevantMarked) const items: ReadItem[] = [] for (const item of baseItems) { const key = options.kind === 'article' ? item.id : (item.url || item.id) if (key && markedSet.has(key)) { items.push({ ...item, markedAsRead: true }) } } for (const id of markedSet) { const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id) if (!exists) { items.push({ id, source: 'marked-as-read', type: options.kind, url: options.kind === 'article' ? undefined : id, markedAsRead: true, readingTimestamp: Math.floor(Date.now() / 1000) }) } } return items } // Archive-only lists: independent of reading progress const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive' ? buildArchiveOnly(reads, { kind: 'article' }) : [] const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive' ? buildArchiveOnly(linksWithProgress, { kind: 'external' }) : [] 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: 'Private Lists', items: groups.amethystPrivate }, { key: 'amethyst-public', title: 'My 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 || myHighlightsLoading) && !hasData const renderTabContent = () => { switch (activeTab) { case 'highlights': if (showSkeletons) { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) } return highlights.length === 0 && !loading && !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) => ( ))}
)))}
) 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 ( <> {readingProgressFilter === 'archive' ? ( archiveOnlyReads.length === 0 ? (
No articles in archive.
) : (
{archiveOnlyReads .filter(item => item.type === 'article') .map((item) => ( ))}
) ) : ( 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 ( <> {readingProgressFilter === 'archive' ? ( archiveOnlyLinks.length === 0 ? (
No links in archive.
) : (
{archiveOnlyLinks.map((item) => ( ))}
) ) : ( 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 && !myWritingsLoading ? (
No articles written yet.
) : (
{writings.map((post) => ( ))}
) default: return null } } return (
{viewingPubkey && }
{renderTabContent()}
) } export default Me