feat: split Reads tab into Reads and Links

- Reads: Only Nostr-native articles (kind:30023)
- Links: Only external URLs with reading progress
- Create linksService.ts for fetching external URL links
- Update readsService to filter only Nostr articles
- Add Links tab between Reads and Writings with same filtering
- Add /me/links route
- Update meCache to include links field
- Both tabs support reading progress filters
- Lazy loading for both tabs

This provides clear separation between native Nostr content and external web links.
This commit is contained in:
Gigi
2025-10-16 01:33:04 +02:00
parent a064376bd8
commit 11c7564f8c
6 changed files with 242 additions and 12 deletions

View File

@@ -120,6 +120,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/me/links"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/writings"
element={

View File

@@ -53,6 +53,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/reads' ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
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'
@@ -11,6 +11,7 @@ import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
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'
@@ -34,7 +35,7 @@ interface MeProps {
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'writings'
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
@@ -47,6 +48,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [links, setLinks] = useState<ReadItem[]>([])
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
@@ -152,6 +154,25 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}
}
const loadLinksTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
try {
if (!hasBeenLoaded) setLoading(true)
// Fetch links (external URLs with reading progress)
const userLinks = await fetchLinks(relayPool, viewingPubkey)
setLinks(userLinks)
setLoadedTabs(prev => new Set(prev).add('links'))
} catch (err) {
console.error('Failed to load links:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
// Load active tab data
useEffect(() => {
if (!viewingPubkey || !activeTab) {
@@ -166,6 +187,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReads(cached.reads || [])
setLinks(cached.links || [])
}
}
@@ -183,6 +205,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
case 'reads':
loadReadsTab()
break
case 'links':
loadLinksTab()
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
@@ -324,6 +349,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
return true
}
})
const filteredLinks = links.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
switch (readingProgressFilter) {
case 'unopened':
// No reading progress
return progress === 0 && !isMarked
case 'started':
// 0-10% reading progress
return progress > 0 && progress <= 0.10 && !isMarked
case 'reading':
// 11-94% reading progress
return progress > 0.10 && progress <= 0.94 && !isMarked
case 'completed':
// 95%+ or marked as read
return progress >= 0.95 || isMarked
case 'all':
default:
return true
}
})
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 },
@@ -332,7 +380,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
]
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || writings.length > 0
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const renderTabContent = () => {
@@ -483,6 +531,47 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</>
)
case 'links':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return links.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links yet.
</div>
) : (
<>
{links.length > 0 && (
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
/>
)}
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
)
case 'writings':
if (showSkeletons) {
return (
@@ -550,6 +639,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
</button>
</>
)}
<button

View File

@@ -0,0 +1,126 @@
import { RelayPool } from 'applesauce-relay'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { ReadItem } from './readsService'
const APP_DATA_KIND = 30078 // NIP-78 Application Data
const READING_POSITION_PREFIX = 'boris:reading-position:'
/**
* Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:30078)
* - Manually marked as read URLs (kind:7, kind:17)
*/
export async function fetchLinks(
relayPool: RelayPool,
userPubkey: string
): Promise<ReadItem[]> {
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Links] Data fetched:', {
readingPositions: readingPositionEvents.length,
markedAsRead: markedAsReadArticles.length
})
// Map to deduplicate items by ID
const linksMap = new Map<string, ReadItem>()
// 1. Process reading position events for external URLs
for (const event of readingPositionEvents) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
try {
const positionData = JSON.parse(event.content)
const position = positionData.position
const timestamp = positionData.timestamp
// Skip if it's a nostr article (naddr format)
if (identifier.startsWith('naddr1')) continue
// It's a base64url-encoded URL
let itemUrl: string
try {
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
} catch (e) {
console.warn('Failed to decode URL identifier:', identifier)
continue
}
// Add or update the item
const existing = linksMap.get(itemUrl)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
linksMap.set(itemUrl, {
...existing,
id: itemUrl,
source: 'reading-progress',
type: 'external',
url: itemUrl,
readingProgress: position,
readingTimestamp: timestamp
})
}
} catch (error) {
console.warn('Failed to parse reading position:', error)
}
}
// 2. Process marked-as-read external URLs
for (const article of markedAsReadArticles) {
// Only process external URLs (skip Nostr articles)
if (article.url && !article.eventId) {
const existing = linksMap.get(article.url)
linksMap.set(article.url, {
...existing,
id: article.url,
source: 'marked-as-read',
type: 'external',
url: article.url,
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
}
}
// 3. Filter and sort links
const sortedLinks = Array.from(linksMap.values())
.filter(item => {
// Only include items that have a timestamp
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
(item.markedAt && item.markedAt > 0)
if (!hasTimestamp) return false
// Filter out items without titles
if (!item.title || item.title === 'Untitled') return false
// Only include if there's reading progress or marked as read
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
return hasProgress
})
.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0
const timeB = b.readingTimestamp || b.markedAt || 0
return timeB - timeA
})
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
return sortedLinks
} catch (error) {
console.error('Failed to fetch links:', error)
return []
}
}

View File

@@ -6,6 +6,7 @@ export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
reads: ReadItem[]
links: ReadItem[]
timestamp: number
}
@@ -21,12 +22,14 @@ export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
reads: ReadItem[]
reads: ReadItem[],
links: ReadItem[] = []
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
reads,
links,
timestamp: Date.now()
})
}

View File

@@ -267,14 +267,8 @@ export async function fetchAllReads(
if (item.type === 'external' && !item.title) return false
}
// For external URLs, only include if there's reading progress or marked as read
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
return hasProgress
}
// Include all Nostr articles
return true
// Only include Nostr-native articles in Reads
return item.type === 'article'
})
.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0