mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 14:44:26 +01:00
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:
@@ -120,6 +120,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
element={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
126
src/services/linksService.ts
Normal file
126
src/services/linksService.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user