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
|
<Route
|
||||||
path="/me/writings"
|
path="/me/writings"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/reads' ? 'reads' :
|
location.pathname === '/me/reads' ? 'reads' :
|
||||||
|
location.pathname === '/me/links' ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Hooks } from 'applesauce-react'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
@@ -11,6 +11,7 @@ import { HighlightItem } from './HighlightItem'
|
|||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -34,7 +35,7 @@ interface MeProps {
|
|||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
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 Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
@@ -47,6 +48,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
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
|
// Load active tab data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!viewingPubkey || !activeTab) {
|
if (!viewingPubkey || !activeTab) {
|
||||||
@@ -166,6 +187,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
setHighlights(cached.highlights)
|
setHighlights(cached.highlights)
|
||||||
setBookmarks(cached.bookmarks)
|
setBookmarks(cached.bookmarks)
|
||||||
setReads(cached.reads || [])
|
setReads(cached.reads || [])
|
||||||
|
setLinks(cached.links || [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +205,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
case 'reads':
|
case 'reads':
|
||||||
loadReadsTab()
|
loadReadsTab()
|
||||||
break
|
break
|
||||||
|
case 'links':
|
||||||
|
loadLinksTab()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
@@ -324,6 +349,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return true
|
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[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
{ 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
|
// 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 showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
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':
|
case 'writings':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
@@ -550,6 +639,14 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Reads</span>
|
<span className="tab-label">Reads</span>
|
||||||
</button>
|
</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
|
<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[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
reads: ReadItem[]
|
reads: ReadItem[]
|
||||||
|
links: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,12 +22,14 @@ export function setCachedMeData(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
highlights: Highlight[],
|
highlights: Highlight[],
|
||||||
bookmarks: Bookmark[],
|
bookmarks: Bookmark[],
|
||||||
reads: ReadItem[]
|
reads: ReadItem[],
|
||||||
|
links: ReadItem[] = []
|
||||||
): void {
|
): void {
|
||||||
meCache.set(pubkey, {
|
meCache.set(pubkey, {
|
||||||
highlights,
|
highlights,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
reads,
|
reads,
|
||||||
|
links,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,14 +267,8 @@ export async function fetchAllReads(
|
|||||||
if (item.type === 'external' && !item.title) return false
|
if (item.type === 'external' && !item.title) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external URLs, only include if there's reading progress or marked as read
|
// Only include Nostr-native articles in Reads
|
||||||
if (item.type === 'external') {
|
return item.type === 'article'
|
||||||
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
|
||||||
return hasProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include all Nostr articles
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const timeA = a.readingTimestamp || a.markedAt || 0
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||||
|
|||||||
Reference in New Issue
Block a user