mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3baf1416 | ||
|
|
e0c169edbc | ||
|
|
d2181ad772 | ||
|
|
8ff3f08d8c | ||
|
|
e17e1bc824 | ||
|
|
948674ae8c | ||
|
|
431f14f56d | ||
|
|
4cc9d557a0 | ||
|
|
cc60f9584a | ||
|
|
94f1f9035b | ||
|
|
e5b1594933 | ||
|
|
2bf9b9789b | ||
|
|
d3405a4029 | ||
|
|
763f7bef4d | ||
|
|
e8e629f4e1 | ||
|
|
a0829e834f | ||
|
|
ff938aa384 | ||
|
|
3991bfeeb2 | ||
|
|
e8c35c8914 | ||
|
|
46345c154b | ||
|
|
f43dae92aa | ||
|
|
99c164a5e9 | ||
|
|
569b4357f2 | ||
|
|
de287c625b | ||
|
|
1424f6ebc5 | ||
|
|
b0a368fc64 | ||
|
|
6f8cf641b7 | ||
|
|
23b4c3475f | ||
|
|
5633dc640c | ||
|
|
0f1dfa445a | ||
|
|
ab5225de50 | ||
|
|
b89705cf43 | ||
|
|
740dd53299 | ||
|
|
eb61553c20 | ||
|
|
8b708535ca | ||
|
|
f77761c002 | ||
|
|
b900666eb8 | ||
|
|
2639c78957 | ||
|
|
8320911bc9 | ||
|
|
00d6bd4c46 | ||
|
|
cd377b6f26 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.2] - 2025-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Reading progress indicator in compact bookmark cards
|
||||
- Shows progress bar for articles and web bookmarks with reading data
|
||||
- Progress bar aligned with bookmark text for better visual association
|
||||
- Color-coded progress (blue for reading, green for completed, gray for started)
|
||||
|
||||
### Changed
|
||||
|
||||
- Compact cards layout optimizations for more space-efficient display
|
||||
- Reduced vertical padding from 0.5rem to 0.25rem
|
||||
- Reduced compact row height from 28px to 24px
|
||||
- Reduced gap between compact cards from 0.5rem to 0.25rem
|
||||
- Reading progress bar styling for compact view
|
||||
- Bar height reduced from 2px to 1px for more subtle appearance
|
||||
- Left margin of 1.5rem aligns bar with bookmark text instead of appearing as separator
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed borders from compact bookmarks in bookmarks sidebar
|
||||
- Border styling from `.bookmarks-list` no longer applies to compact cards
|
||||
- Compact cards now display as truly borderless and transparent
|
||||
|
||||
## [0.8.0] - 2025-10-19
|
||||
|
||||
### Added
|
||||
@@ -2044,7 +2069,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.2...HEAD
|
||||
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
|
||||
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
||||
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
|
||||
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
||||
|
||||
@@ -15,7 +15,6 @@ const RELAYS = [
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -182,7 +182,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
const { progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
@@ -648,7 +648,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{isTextContent && (
|
||||
<ReadingProgressIndicator
|
||||
progress={progressPercentage}
|
||||
isComplete={isReadingComplete}
|
||||
// Consider complete only at 95%+
|
||||
isComplete={progressPercentage >= 95}
|
||||
showPercentage={true}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSettings } from '../hooks/useSettings'
|
||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
|
||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||
@@ -102,6 +103,21 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||
|
||||
// Reading Progress loading state
|
||||
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
|
||||
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
|
||||
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
|
||||
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
|
||||
|
||||
// Mark-as-read reactions loading state
|
||||
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
|
||||
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
|
||||
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
|
||||
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
|
||||
|
||||
// Deduplicated reading progress from controller
|
||||
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Live timing state
|
||||
const [liveTiming, setLiveTiming] = useState<{
|
||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||
@@ -109,6 +125,8 @@ const Debug: React.FC<DebugProps> = ({
|
||||
loadBookmarks?: { startTime: number }
|
||||
decryptBookmarks?: { startTime: number }
|
||||
loadHighlights?: { startTime: number }
|
||||
loadReadingProgress?: { startTime: number }
|
||||
loadMarkAsRead?: { startTime: number }
|
||||
}>({})
|
||||
|
||||
// Web of Trust state
|
||||
@@ -724,6 +742,150 @@ const Debug: React.FC<DebugProps> = ({
|
||||
setTFirstWriting(null)
|
||||
}
|
||||
|
||||
const handleLoadReadingProgress = async () => {
|
||||
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load reading progress')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingReadingProgress(true)
|
||||
setReadingProgressEvents([])
|
||||
setTLoadReadingProgress(null)
|
||||
setTFirstReadingProgress(null)
|
||||
setDeduplicatedProgressMap(new Map())
|
||||
DebugBus.info('debug', 'Loading reading progress events...')
|
||||
|
||||
const start = performance.now()
|
||||
let firstEventTime: number | null = null
|
||||
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
const { KINDS } = await import('../config/kinds')
|
||||
|
||||
// Load raw events for display
|
||||
const rawEvents: NostrEvent[] = []
|
||||
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstReadingProgress(Math.round(firstEventTime))
|
||||
}
|
||||
rawEvents.push(evt)
|
||||
setReadingProgressEvents([...rawEvents])
|
||||
}
|
||||
})
|
||||
|
||||
// Load deduplicated results via controller
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
setDeduplicatedProgressMap(new Map(progressMap))
|
||||
})
|
||||
|
||||
// Run both in parallel
|
||||
await Promise.all([
|
||||
rawQueryPromise,
|
||||
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
|
||||
])
|
||||
|
||||
unsubProgress()
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadReadingProgress(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadReadingProgress, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
const finalMap = readingProgressController.getProgressMap()
|
||||
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading progress:', err)
|
||||
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setIsLoadingReadingProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearReadingProgress = () => {
|
||||
setReadingProgressEvents([])
|
||||
setTLoadReadingProgress(null)
|
||||
setTFirstReadingProgress(null)
|
||||
setDeduplicatedProgressMap(new Map())
|
||||
DebugBus.info('debug', 'Cleared reading progress data')
|
||||
}
|
||||
|
||||
const handleLoadMarkAsReadReactions = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMarkAsRead(true)
|
||||
setMarkAsReadReactions([])
|
||||
setTLoadMarkAsRead(null)
|
||||
setTFirstMarkAsRead(null)
|
||||
DebugBus.info('debug', 'Loading mark-as-read reactions...')
|
||||
|
||||
const start = performance.now()
|
||||
let firstEventTime: number | null = null
|
||||
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
const { MARK_AS_READ_EMOJI } = await import('../services/reactionService')
|
||||
|
||||
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (evt.content === MARK_AS_READ_EMOJI) {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||
}
|
||||
setMarkAsReadReactions(prev => [...prev, evt])
|
||||
}
|
||||
}
|
||||
}),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
|
||||
onEvent: (evt) => {
|
||||
if (evt.content === MARK_AS_READ_EMOJI) {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||
}
|
||||
setMarkAsReadReactions(prev => [...prev, evt])
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const totalEvents = kind7Events.length + kind17Events.length
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadMarkAsRead(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadMarkAsRead, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
console.error('Failed to load mark-as-read reactions:', err)
|
||||
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setIsLoadingMarkAsRead(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearMarkAsRead = () => {
|
||||
setMarkAsReadReactions([])
|
||||
setTLoadMarkAsRead(null)
|
||||
setTFirstMarkAsRead(null)
|
||||
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
|
||||
}
|
||||
|
||||
const handleLoadFriendsList = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||
@@ -1348,6 +1510,194 @@ const Debug: React.FC<DebugProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reading Progress Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Reading Progress Loading</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadReadingProgress}
|
||||
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingReadingProgress ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Reading Progress'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearReadingProgress}
|
||||
disabled={readingProgressEvents.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadReadingProgress} />
|
||||
<Stat label="first event" value={tFirstReadingProgress} />
|
||||
</div>
|
||||
{readingProgressEvents.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{readingProgressEvents.map((evt, idx) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||
const content = evt.content || ''
|
||||
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{dTag && <div>d-tag: {dTag}</div>}
|
||||
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
|
||||
{content && <div>Progress: {content}</div>}
|
||||
</div>
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deduplicatedProgressMap.size > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
|
||||
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
let unopened = 0, started = 0, reading = 0, completed = 0
|
||||
for (const progress of deduplicatedProgressMap.values()) {
|
||||
if (progress === 0) unopened++
|
||||
else if (progress > 0 && progress <= 0.10) started++
|
||||
else if (progress > 0.10 && progress <= 0.94) reading++
|
||||
else if (progress >= 0.95) completed++
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Unopened (0%):</span>
|
||||
<span className="font-semibold">{unopened}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Started (0% < progress ≤ 10%):</span>
|
||||
<span className="font-semibold">{started}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
|
||||
<span>Reading (10% < progress ≤ 94%) ✓:</span>
|
||||
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Completed (≥ 95%):</span>
|
||||
<span className="font-semibold">{completed}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
|
||||
<div className="font-semibold mb-1">Article #{idx + 1}</div>
|
||||
<div className="mt-1">
|
||||
<div className="break-all">ID: {articleId}</div>
|
||||
<div className="mt-1">
|
||||
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
|
||||
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-full"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mark-as-read Reactions Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the MARK_AS_READ_EMOJI for the logged-in user</div>
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadMarkAsReadReactions}
|
||||
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingMarkAsRead ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Mark-as-read Reactions'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearMarkAsRead}
|
||||
disabled={markAsReadReactions.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadMarkAsRead} />
|
||||
<Stat label="first event" value={tFirstMarkAsRead} />
|
||||
</div>
|
||||
{markAsReadReactions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{markAsReadReactions.map((evt, idx) => {
|
||||
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
|
||||
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Kind: {evt.kind}</div>
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div>Emoji: {evt.content}</div>
|
||||
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
|
||||
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
|
||||
</div>
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web of Trust Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Web of Trust</h3>
|
||||
|
||||
@@ -44,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
try {
|
||||
if (!highlight.eventReference) return
|
||||
|
||||
// Skip if it's a raw event ID (hex string without colons)
|
||||
// Raw event IDs cannot be decoded to nadrs without additional context
|
||||
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert eventReference to naddr if needed
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { ReadItem } from '../services/readsService'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
@@ -28,9 +28,7 @@ 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 { readingProgressController } from '../services/readingProgressController'
|
||||
|
||||
interface MeProps {
|
||||
@@ -44,7 +42,7 @@ interface MeProps {
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'emoji']
|
||||
|
||||
const Me: React.FC<MeProps> = ({
|
||||
relayPool,
|
||||
@@ -159,12 +157,81 @@ const Me: React.FC<MeProps> = ({
|
||||
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress,
|
||||
markedAsRead: readingProgressController.isMarkedAsRead(id),
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
// Include items that are only marked-as-read (no progress event yet)
|
||||
const markedIds = readingProgressController.getMarkedAsReadIds()
|
||||
for (const id of markedIds) {
|
||||
if (!readItems.find(i => i.id === id)) {
|
||||
const isArticle = id.startsWith('naddr1')
|
||||
readItems.push({
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: isArticle ? 'article' : 'external',
|
||||
url: isArticle ? undefined : id,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const readsMap = new Map(readItems.map(item => [item.id, item]))
|
||||
setReadsMap(readsMap)
|
||||
setReads(readItems)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to marked-as-read changes and rebuild reads list
|
||||
useEffect(() => {
|
||||
const unsubMarkedAsRead = readingProgressController.onMarkedAsReadChanged(() => {
|
||||
// Rebuild reads list including marked-as-read-only items
|
||||
const progressMap = readingProgressController.getProgressMap()
|
||||
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress,
|
||||
markedAsRead: readingProgressController.isMarkedAsRead(id),
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
// Include items that are only marked-as-read (no progress event yet)
|
||||
const markedIds = readingProgressController.getMarkedAsReadIds()
|
||||
for (const id of markedIds) {
|
||||
if (!readItems.find(i => i.id === id)) {
|
||||
const isArticle = id.startsWith('naddr1')
|
||||
readItems.push({
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: isArticle ? 'article' : 'external',
|
||||
url: isArticle ? undefined : id,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const readsMap = new Map(readItems.map(item => [item.id, item]))
|
||||
setReadsMap(readsMap)
|
||||
setReads(readItems)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubMarkedAsRead()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load reading progress data for writings tab
|
||||
useEffect(() => {
|
||||
@@ -232,39 +299,70 @@ const Me: React.FC<MeProps> = ({
|
||||
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)
|
||||
// Reads come from centralized readingProgressController (already loaded in App.tsx)
|
||||
// It provides deduped reading progress per article
|
||||
const progressMap = readingProgressController.getProgressMap()
|
||||
|
||||
// Convert progress map to ReadItems
|
||||
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress,
|
||||
markedAsRead: readingProgressController.isMarkedAsRead(id),
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
// Include items that are only marked-as-read (no progress event yet)
|
||||
const markedIds = readingProgressController.getMarkedAsReadIds()
|
||||
for (const id of markedIds) {
|
||||
if (!readItems.find(i => i.id === id)) {
|
||||
const isArticle = id.startsWith('naddr1')
|
||||
readItems.push({
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: isArticle ? 'article' : 'external',
|
||||
url: isArticle ? undefined : id,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const readsMap = new Map(readItems.map(item => [item.id, item]))
|
||||
setReadsMap(readsMap)
|
||||
setReads(readItems)
|
||||
|
||||
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) => {
|
||||
setReadsMap(prevMap => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) {
|
||||
return prevMap
|
||||
}
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
const merged = mergeReadItem(newMap, item)
|
||||
if (merged) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to reading progress updates
|
||||
useEffect(() => {
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress,
|
||||
markedAsRead: readingProgressController.isMarkedAsRead(id),
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const readsMap = new Map(readItems.map(item => [item.id, item]))
|
||||
setReadsMap(readsMap)
|
||||
setReads(readItems)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLinksTab = async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
@@ -290,12 +388,13 @@ const Me: React.FC<MeProps> = ({
|
||||
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
|
||||
if (item.type === 'article' && item.author) {
|
||||
const progress = readingProgressMap.get(item.id)
|
||||
if (progress !== undefined) {
|
||||
newMap.set(item.id, { ...item, readingProgress: progress })
|
||||
}
|
||||
}
|
||||
return prevMap
|
||||
return newMap
|
||||
})
|
||||
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBook } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
|
||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'emoji'
|
||||
|
||||
interface ReadingProgressFiltersProps {
|
||||
selectedFilter: ReadingProgressFilterType
|
||||
@@ -13,11 +14,13 @@ interface ReadingProgressFiltersProps {
|
||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||
// Emoji-marked items (marked via reaction emoji)
|
||||
{ type: 'emoji' as const, icon: faBook, label: 'Emoji' }
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -31,6 +34,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
||||
activeStyle = { color: '#10b981' } // green
|
||||
} else if (filter.type === 'highlighted') {
|
||||
activeStyle = { color: '#fde047' } // yellow
|
||||
} else if (filter.type === 'emoji') {
|
||||
activeStyle = { color: '#60a5fa' } // blue accent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export const RELAYS = [
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
|
||||
@@ -74,19 +74,18 @@ export function useArticleLoader({
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
// Merge streaming results with existing UI state to preserve locally created highlights
|
||||
setHighlights((prev) => {
|
||||
if (prev.some(h => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ export function useExternalUrlLoader({
|
||||
[url]
|
||||
)
|
||||
|
||||
// Load content and start streaming highlights when URL changes
|
||||
useEffect(() => {
|
||||
if (!relayPool || !url) return
|
||||
|
||||
@@ -87,7 +88,13 @@ export function useExternalUrlLoader({
|
||||
|
||||
// Seed with cached highlights first
|
||||
if (cachedUrlHighlights.length > 0) {
|
||||
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
setHighlights((prev) => {
|
||||
// Seed with cache but keep any locally created highlights already in state
|
||||
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
|
||||
const localOnly = prev.filter(h => !seen.has(h.id))
|
||||
const next = [...cachedUrlHighlights, ...localOnly]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
} else {
|
||||
setHighlights([])
|
||||
}
|
||||
@@ -106,7 +113,7 @@ export function useExternalUrlLoader({
|
||||
seen.add(highlight.id)
|
||||
setHighlights((prev) => {
|
||||
if (prev.some(h => h.id === highlight.id)) return prev
|
||||
const next = [...prev, highlight]
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
@@ -134,6 +141,19 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
||||
|
||||
// Keep UI highlights synced with cached store updates without reloading content
|
||||
useEffect(() => {
|
||||
if (!url) return
|
||||
if (cachedUrlHighlights.length === 0) return
|
||||
setHighlights((prev) => {
|
||||
const seen = new Set<string>(prev.map(h => h.id))
|
||||
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
|
||||
if (additions.length === 0) return prev
|
||||
const next = [...additions, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}, [cachedUrlHighlights, url, setHighlights])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Filter, NostrEvent } from 'nostr-tools'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { processReadingProgress } from './readingDataProcessor'
|
||||
import { ReadItem } from './readsService'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
console.log('[readingProgress] Module loaded')
|
||||
|
||||
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
@@ -20,11 +24,14 @@ const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
class ReadingProgressController {
|
||||
private progressListeners: ProgressMapCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
private markedAsReadListeners: (() => void)[] = []
|
||||
|
||||
private currentProgressMap: Map<string, number> = new Map()
|
||||
private markedAsReadIds: Set<string> = new Set()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||
private isLoading = false
|
||||
|
||||
onProgress(cb: ProgressMapCallback): () => void {
|
||||
this.progressListeners.push(cb)
|
||||
@@ -40,6 +47,13 @@ class ReadingProgressController {
|
||||
}
|
||||
}
|
||||
|
||||
onMarkedAsReadChanged(cb: () => void): () => void {
|
||||
this.markedAsReadListeners.push(cb)
|
||||
return () => {
|
||||
this.markedAsReadListeners = this.markedAsReadListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
@@ -48,6 +62,10 @@ class ReadingProgressController {
|
||||
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
||||
}
|
||||
|
||||
private emitMarkedAsReadChanged(): void {
|
||||
this.markedAsReadListeners.forEach(cb => cb())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reading progress map without triggering a reload
|
||||
*/
|
||||
@@ -91,6 +109,20 @@ class ReadingProgressController {
|
||||
return this.currentProgressMap.get(naddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is marked as read
|
||||
*/
|
||||
isMarkedAsRead(naddr: string): boolean {
|
||||
return this.markedAsReadIds.has(naddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all marked as read IDs (for debugging)
|
||||
*/
|
||||
getMarkedAsReadIds(): string[] {
|
||||
return Array.from(this.markedAsReadIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reading progress is loaded for a specific pubkey
|
||||
*/
|
||||
@@ -113,24 +145,11 @@ class ReadingProgressController {
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
this.currentProgressMap = new Map()
|
||||
this.markedAsReadIds = new Set()
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
@@ -157,13 +176,22 @@ class ReadingProgressController {
|
||||
const { relayPool, eventStore, pubkey, force = false } = params
|
||||
const startGeneration = this.generation
|
||||
|
||||
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
|
||||
|
||||
// Skip if already loaded for this pubkey and not forcing
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[readingProgress] Already loaded for pubkey, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent starts
|
||||
if (this.isLoading) {
|
||||
console.log('[readingProgress] Already loading, skipping concurrent start')
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
// Seed from local cache immediately (survives refresh/flight mode)
|
||||
@@ -173,8 +201,8 @@ class ReadingProgressController {
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
}
|
||||
|
||||
// Subscribe to local timeline for immediate and reactive updates
|
||||
// Clean up any previous subscription first
|
||||
// Subscribe to local eventStore timeline for immediate and reactive updates
|
||||
// This handles both local writes and synced events from relays
|
||||
if (this.timelineSubscription) {
|
||||
try {
|
||||
this.timelineSubscription.unsubscribe()
|
||||
@@ -184,55 +212,62 @@ class ReadingProgressController {
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Setting up eventStore subscription...')
|
||||
const timeline$ = eventStore.timeline({
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
})
|
||||
const generationAtSubscribe = this.generation
|
||||
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
|
||||
// Ignore if controller generation has changed (e.g., logout/login)
|
||||
if (generationAtSubscribe !== this.generation) return
|
||||
if (!Array.isArray(localEvents) || localEvents.length === 0) return
|
||||
this.processEvents(localEvents)
|
||||
})
|
||||
console.log('[readingProgress] EventStore subscription ready - updates streaming')
|
||||
|
||||
// Query events from relays
|
||||
// Force full sync if map is empty (first load) or if explicitly forced
|
||||
const needsFullSync = force || this.currentProgressMap.size === 0
|
||||
const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey)
|
||||
|
||||
const filter: Filter = {
|
||||
// Mark as loaded immediately - queries run in background non-blocking
|
||||
this.lastLoadedPubkey = pubkey
|
||||
|
||||
// Query reading progress from relays in background (non-blocking, fire-and-forget)
|
||||
console.log('[readingProgress] Starting background relay query for reading progress...')
|
||||
queryEvents(relayPool, {
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
}
|
||||
|
||||
if (lastSynced && !needsFullSync) {
|
||||
filter.since = lastSynced
|
||||
}
|
||||
}, { relayUrls: RELAYS })
|
||||
.then((relayEvents) => {
|
||||
if (startGeneration !== this.generation) return
|
||||
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
|
||||
if (relayEvents.length > 0) {
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
this.processEvents(relayEvents)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.updateLastSyncedAt(pubkey, now)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[readingProgress] Background reading progress query failed:', err)
|
||||
})
|
||||
|
||||
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
|
||||
|
||||
if (startGeneration !== this.generation) {
|
||||
return
|
||||
}
|
||||
// Load mark-as-read reactions in background (non-blocking, streaming)
|
||||
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
|
||||
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
|
||||
.then(() => {
|
||||
console.log('[readingProgress] Mark-as-read reactions loading complete')
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
|
||||
})
|
||||
|
||||
if (relayEvents.length > 0) {
|
||||
// Add to event store
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
|
||||
// Process and emit (merge with existing)
|
||||
this.processEvents(relayEvents)
|
||||
|
||||
// Update last synced
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.updateLastSyncedAt(pubkey, now)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('📊 [ReadingProgress] Failed to load:', err)
|
||||
console.error('📊 [ReadingProgress] Failed to setup:', err)
|
||||
} finally {
|
||||
if (startGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
this.isLoading = false
|
||||
console.log('[readingProgress] === LOADED ===')
|
||||
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
|
||||
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +306,84 @@ class ReadingProgressController {
|
||||
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mark-as-read reactions in background (non-blocking)
|
||||
*/
|
||||
private async loadMarkAsReadReactions(
|
||||
relayPool: RelayPool,
|
||||
_eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
|
||||
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
|
||||
const seenReactionIds = new Set<string>()
|
||||
|
||||
const handleUrlReaction = (evt: NostrEvent) => {
|
||||
if (seenReactionIds.has(evt.id)) return
|
||||
seenReactionIds.add(evt.id)
|
||||
if (evt.content !== MARK_AS_READ_EMOJI) return
|
||||
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||
if (!rTag) return
|
||||
this.markedAsReadIds.add(rTag)
|
||||
this.emitMarkedAsReadChanged()
|
||||
}
|
||||
|
||||
const pendingEventIds = new Set<string>()
|
||||
const handleEventReaction = (evt: NostrEvent) => {
|
||||
if (seenReactionIds.has(evt.id)) return
|
||||
seenReactionIds.add(evt.id)
|
||||
if (evt.content !== MARK_AS_READ_EMOJI) return
|
||||
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||
if (!eTag) return
|
||||
pendingEventIds.add(eTag)
|
||||
}
|
||||
|
||||
// Fire queries with onEvent callbacks for streaming behavior
|
||||
const [kind17Events, kind7Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
|
||||
])
|
||||
|
||||
if (generation !== this.generation) return
|
||||
|
||||
// Include any reactions that arrived only at EOSE
|
||||
kind17Events.forEach(handleUrlReaction)
|
||||
kind7Events.forEach(handleEventReaction)
|
||||
|
||||
if (pendingEventIds.size > 0) {
|
||||
// Fetch referenced 30023 events, streaming not required here
|
||||
const ids = Array.from(pendingEventIds)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
|
||||
const eventIdToNaddr = new Map<string, string>()
|
||||
for (const article of articleEvents) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||
eventIdToNaddr.set(article.id, naddr)
|
||||
} catch (e) {
|
||||
console.warn('[readingProgress] Failed to encode naddr for article:', article.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Map pending event IDs to naddrs and emit
|
||||
for (const eId of pendingEventIds) {
|
||||
const naddr = eventIdToNaddr.get(eId)
|
||||
if (naddr) {
|
||||
this.markedAsReadIds.add(naddr)
|
||||
}
|
||||
}
|
||||
this.emitMarkedAsReadChanged()
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
|
||||
} catch (err) {
|
||||
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readingProgressController = new ReadingProgressController()
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function fetchAllReads(
|
||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
onItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,11 @@ export function filterByReadingProgress(
|
||||
case 'reading':
|
||||
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||
case 'completed':
|
||||
return progress >= 0.95 || isMarked
|
||||
// Completed is 95%+ progress only (no emoji fallback)
|
||||
return progress >= 0.95
|
||||
case 'emoji':
|
||||
// Emoji-marked items regardless of progress
|
||||
return isMarked
|
||||
case 'highlighted':
|
||||
return hasHighlights
|
||||
case 'all':
|
||||
|
||||
Reference in New Issue
Block a user