mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a4ae392f | ||
|
|
6e438b8ee2 | ||
|
|
31974e7271 | ||
|
|
676be1a932 | ||
|
|
9883f2eb1a | ||
|
|
87e46be86f | ||
|
|
b745a92a7e | ||
|
|
5a79da4024 | ||
|
|
a7d05a29f5 | ||
|
|
0740d53d37 | ||
|
|
914738abb4 | ||
|
|
4fac5f42c9 | ||
|
|
16b3668e73 | ||
|
|
f3a83256a8 | ||
|
|
0e98ddeef4 | ||
|
|
1ba375e93e | ||
|
|
5d14d25d0e | ||
|
|
616038a23a | ||
|
|
14fce2c3dc | ||
|
|
7c511de474 | ||
|
|
3a10ac8691 | ||
|
|
205879f948 | ||
|
|
bff43f4a28 | ||
|
|
2a7fffd594 | ||
|
|
50a4161e16 | ||
|
|
5fd8976097 | ||
|
|
80b26abff2 | ||
|
|
c0638851c6 | ||
|
|
9b6b14cfe8 | ||
|
|
b6ad62a3ab | ||
|
|
85d87bac29 | ||
|
|
3b31eceeab | ||
|
|
442c138d6a | ||
|
|
61e6027252 | ||
|
|
7d373015b4 | ||
|
|
32b1286079 | ||
|
|
17fdd92827 | ||
|
|
aa6aeb2723 | ||
|
|
4b0f275f57 | ||
|
|
73e2e060e3 | ||
|
|
3007ae83c2 | ||
|
|
a862eb880e | ||
|
|
016e369fb1 | ||
|
|
4f21982c48 | ||
|
|
f6d3fe9aba | ||
|
|
fc60e6b80a | ||
|
|
d9cdbb7279 | ||
|
|
401d333e0f | ||
|
|
d32a47e3c3 | ||
|
|
35efdb6d3f | ||
|
|
c7f7792d73 | ||
|
|
8aa26caae0 | ||
|
|
6c00904bd5 | ||
|
|
23526954ea | ||
|
|
9a437dd97b | ||
|
|
0baf75462c | ||
|
|
30b8f1af92 | ||
|
|
07aea9d35f | ||
|
|
41a4abff37 | ||
|
|
c9998984c3 | ||
|
|
a799709e62 | ||
|
|
18c6c3e68a | ||
|
|
5e7395652f | ||
|
|
83076e7b01 | ||
|
|
c79f4122da | ||
|
|
179fe0bbc2 | ||
|
|
20b4f2b1b2 | ||
|
|
936f9093cf | ||
|
|
3149e5b824 | ||
|
|
8619cecaf3 |
140
CHANGELOG.md
140
CHANGELOG.md
@@ -7,6 +7,141 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.4] - 2025-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Profile page data preloading for instant tab switching
|
||||
- Automatically preloads all highlights and writings when viewing a profile (`/p/` pages)
|
||||
- Non-blocking background fetch stores all events in event store
|
||||
- Tab switching becomes instant after initial preload
|
||||
|
||||
### Changed
|
||||
|
||||
- `/me/bookmarks` tab now displays in cards view only
|
||||
- Removed view mode toggle buttons (compact, large) from bookmarks tab
|
||||
- Cards view provides optimal bookmark browsing experience
|
||||
- Grouping toggle (grouped/flat) still available
|
||||
- Highlights sidebar filters simplified when logged out
|
||||
- Only nostrverse filter button shown when not logged in
|
||||
- Friends and personal highlight filters hidden when logged out
|
||||
- Cleaner UX showing only available options
|
||||
|
||||
### Fixed
|
||||
|
||||
- Profile page tabs now display cached content instantly
|
||||
- Highlights and writings show immediately from event store cache
|
||||
- Network fetches happen in background without blocking UI
|
||||
- Matches Explore and Debug page non-blocking loading pattern
|
||||
- Eliminated loading delays when switching between tabs
|
||||
|
||||
### Performance
|
||||
|
||||
- Cache-first profile loading strategy
|
||||
- Instant display of cached highlights and writings from event store
|
||||
- Background refresh updates data without blocking
|
||||
- Tab switches show content immediately without loading states
|
||||
|
||||
## [0.7.3] - 2025-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Centralized nostrverse writings controller for kind 30023 content
|
||||
- Automatically starts at app initialization
|
||||
- Streams nostrverse blog posts progressively to Explore page
|
||||
- Provides non-blocking, cache-first loading strategy
|
||||
- Centralized nostrverse highlights controller
|
||||
- Pre-loads nostrverse highlights at app start for instant toggling
|
||||
- Streams highlights progressively to Explore page
|
||||
- Integrated with EventStore for caching
|
||||
- Writings loading debug section on `/debug` page
|
||||
- Diagnostics for writings controller and loading states
|
||||
|
||||
### Changed
|
||||
|
||||
- Explore page now uses centralized `writingsController` for user's own writings
|
||||
- Auto-loads user writings at login for instant availability
|
||||
- Non-blocking fetch with progressive streaming
|
||||
- Explore page loading strategy optimized
|
||||
- Shows skeleton placeholders instead of blocking spinners
|
||||
- Seeds from cache, then streams and merges results progressively
|
||||
- Keeps nostrverse fetches non-blocking
|
||||
- User's own writings now included in Explore when enabled
|
||||
- Lazy-loads on 'mine' toggle when logged in
|
||||
- Streams in parallel with friends/nostrverse content
|
||||
|
||||
### Fixed
|
||||
|
||||
- Explore page works correctly in logged-out mode
|
||||
- Relies solely on centralized nostrverse controllers
|
||||
- Controllers start even when logged out
|
||||
- Fetches nostrverse content properly without authentication
|
||||
- Explore page no longer allows disabling all scope filters
|
||||
- Ensures at least one filter (mine/friends/nostrverse) remains active
|
||||
- Prevents blank content state
|
||||
- Explore page reflects default scope setting immediately
|
||||
- No more blank lists on initial load
|
||||
- Pre-loads and merges nostrverse from event store
|
||||
- Explore page highlights properly scoped
|
||||
- Nostrverse highlights never block the page
|
||||
- Shows empty state instead of spinner
|
||||
- Streams results into store immediately
|
||||
- Highlights are merged and loaded correctly
|
||||
- Article-specific highlights properly filtered
|
||||
- Highlights scoped to current article on `/a/` and `/r/` routes
|
||||
- Derives coordinate from naddr for early filtering
|
||||
- Sidebar and content only show relevant highlights
|
||||
- ContentPanel shows only article-specific highlights for nostr articles
|
||||
- Explore writings properly deduplicated
|
||||
- Deduplication by replaceable event (author:d-tag) happens before visibility filtering
|
||||
- Consistent dedupe/sort behavior across all loading scenarios
|
||||
- Debug page writings loading section added
|
||||
- No infinite loop when loading nostrverse content
|
||||
|
||||
### Performance
|
||||
|
||||
- Non-blocking explore page loading
|
||||
- Fully non-blocking loading strategy
|
||||
- Seeds caches then streams and merges results progressively
|
||||
- Lazy-loading for content filters
|
||||
- Nostrverse writings lazy-load when toggled on while logged in
|
||||
- Avoids redundant loading with guard flags
|
||||
- Streaming callbacks for progressive updates
|
||||
- Writings stream to UI via onPost callback
|
||||
- Posts appear instantly as they arrive from cache or network
|
||||
|
||||
## [0.7.2] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Cached-first loading with EventStore across the app
|
||||
- Instant display of cached highlights and writings from local event store
|
||||
- Progressive loading with streaming updates from relays
|
||||
- Centralized event storage for improved performance and offline support
|
||||
- Default explore scope setting for controlling content visibility
|
||||
- Configurable default scope for explore page content
|
||||
- Dedicated Explore section in settings for better organization
|
||||
|
||||
### Changed
|
||||
|
||||
- Highlights and writings now load from cache first, then stream from relays
|
||||
- Explore page shows cached content instantly before network updates
|
||||
- Article-specific highlights stored in centralized event store for faster access
|
||||
- Nostrverse content cached locally for improved performance
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent "No highlights yet" flash on `/me/highlights` page
|
||||
- Force React to remount tab content when switching tabs for proper state management
|
||||
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
|
||||
- Show skeleton placeholders while highlights are loading for better UX
|
||||
|
||||
### Performance
|
||||
|
||||
- Local-first loading strategy reduces perceived loading times
|
||||
- Cached content displays immediately while background sync occurs
|
||||
- Centralized event storage eliminates redundant network requests
|
||||
|
||||
## [0.7.0] - 2025-10-18
|
||||
|
||||
### Added
|
||||
@@ -1878,7 +2013,10 @@ 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.7.0...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.4...HEAD
|
||||
[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
|
||||
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
|
||||
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
75
public/md/NIP-85.md
Normal file
75
public/md/NIP-85.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# NIP-85
|
||||
|
||||
## Reading Progress
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
* [Format](#format)
|
||||
* [Tags](#tags)
|
||||
* [Content](#content)
|
||||
* [Examples](#examples)
|
||||
|
||||
## Format
|
||||
|
||||
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
|
||||
|
||||
### Tags
|
||||
|
||||
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
|
||||
|
||||
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
|
||||
|
||||
- `d` (required): Unique identifier for the target content
|
||||
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
|
||||
- For external URLs: `url:<base64url-encoded-url>`
|
||||
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
|
||||
- `r` (optional but recommended for URLs): Raw URL of the external content
|
||||
|
||||
### Content
|
||||
|
||||
The content is a JSON object with the following fields:
|
||||
|
||||
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
|
||||
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
|
||||
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
|
||||
- `ver` (optional): Schema version string
|
||||
|
||||
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
|
||||
|
||||
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
|
||||
|
||||
## Examples
|
||||
|
||||
### Nostr Article
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 39802,
|
||||
"pubkey": "<user-pubkey>",
|
||||
"created_at": 1734635012,
|
||||
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
|
||||
"tags": [
|
||||
["d", "30023:<author-pubkey>:<article-identifier>"],
|
||||
["a", "30023:<author-pubkey>:<article-identifier>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### External URL
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 39802,
|
||||
"pubkey": "<user-pubkey>",
|
||||
"created_at": 1734635999,
|
||||
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
|
||||
"tags": [
|
||||
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
|
||||
["r", "https://example.com/post"]
|
||||
]
|
||||
}
|
||||
```
|
||||
33
src/App.tsx
33
src/App.tsx
@@ -23,6 +23,11 @@ import { Bookmark } from './types/bookmarks'
|
||||
import { bookmarkController } from './services/bookmarkController'
|
||||
import { contactsController } from './services/contactsController'
|
||||
import { highlightsController } from './services/highlightsController'
|
||||
import { writingsController } from './services/writingsController'
|
||||
import { readingProgressController } from './services/readingProgressController'
|
||||
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -50,18 +55,14 @@ function AppRoutes({
|
||||
|
||||
// Subscribe to bookmark controller
|
||||
useEffect(() => {
|
||||
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
|
||||
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
|
||||
setBookmarks(bookmarks)
|
||||
})
|
||||
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||
console.log('[bookmark] 📥 Loading state:', loading)
|
||||
setBookmarksLoading(loading)
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
|
||||
unsubBookmarks()
|
||||
unsubLoading()
|
||||
}
|
||||
@@ -94,7 +95,6 @@ function AppRoutes({
|
||||
|
||||
// Load bookmarks
|
||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
}
|
||||
|
||||
@@ -109,16 +109,34 @@ function AppRoutes({
|
||||
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Load writings (controller manages its own state)
|
||||
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||
console.log('[writings] 🚀 Auto-loading writings on mount/login')
|
||||
writingsController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
|
||||
// Start centralized nostrverse highlights controller (non-blocking)
|
||||
if (eventStore) {
|
||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||
}
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||
|
||||
// Ensure nostrverse controllers run even when logged out
|
||||
useEffect(() => {
|
||||
if (relayPool && eventStore) {
|
||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||
}
|
||||
}, [relayPool, eventStore])
|
||||
|
||||
// Manual refresh (for sidebar button)
|
||||
const handleRefreshBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) {
|
||||
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
|
||||
return
|
||||
}
|
||||
console.log('[bookmark] 🔄 Manual refresh triggered')
|
||||
bookmarkController.reset()
|
||||
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
}, [relayPool, activeAccount, accountManager])
|
||||
@@ -128,6 +146,7 @@ function AppRoutes({
|
||||
bookmarkController.reset() // Clear bookmarks via controller
|
||||
contactsController.reset() // Clear contacts via controller
|
||||
highlightsController.reset() // Clear highlights via controller
|
||||
readingProgressController.reset() // Clear reading progress via controller
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
// Debug log
|
||||
if (readingProgress !== undefined) {
|
||||
console.log('[progress] 🎴 Card render:', post.title.slice(0, 30), '=> progress:', progressPercent + '%', 'color:', progressColor)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import Profile from './Profile'
|
||||
import Support from './Support'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
@@ -330,7 +331,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
|
||||
@@ -151,7 +151,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||
console.log('[progress] ⏭️ ContentPanel: Skipping save - missing requirements:', {
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
@@ -160,11 +160,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||
console.log('[progress] ⏭️ ContentPanel: Sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
console.log('[progress] 💾 ContentPanel: Saving position:', {
|
||||
position,
|
||||
percentage: Math.round(position * 100) + '%',
|
||||
scrollTop,
|
||||
articleIdentifier: articleIdentifier.slice(0, 50) + '...',
|
||||
url: selectedUrl?.slice(0, 50)
|
||||
})
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
@@ -176,25 +183,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{
|
||||
position,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||
scrollTop
|
||||
}
|
||||
)
|
||||
console.log('[progress] ✅ ContentPanel: Save completed successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
|
||||
console.log('[progress] 📖 Auto-marking as read on completion')
|
||||
handleMarkAsRead()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Log sync status when it changes
|
||||
useEffect(() => {
|
||||
console.log('[progress] 📊 ContentPanel reading position sync status:', {
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasArticleIdentifier: !!articleIdentifier,
|
||||
currentProgress: progressPercentage + '%'
|
||||
})
|
||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||
|
||||
// Load saved reading position when article loads
|
||||
useEffect(() => {
|
||||
@@ -208,8 +230,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled in settings - not restoring position')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
|
||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||
|
||||
@@ -94,6 +96,12 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
||||
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
|
||||
|
||||
// Writings loading state
|
||||
const [isLoadingWritings, setIsLoadingWritings] = useState(false)
|
||||
const [writingPosts, setWritingPosts] = useState<BlogPostPreview[]>([])
|
||||
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||
|
||||
// Live timing state
|
||||
const [liveTiming, setLiveTiming] = useState<{
|
||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||
@@ -538,6 +546,188 @@ const Debug: React.FC<DebugProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadMyWritings = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey || !eventStore) {
|
||||
DebugBus.warn('debug', 'Please log in to load your writings')
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
setWritingPosts([])
|
||||
setIsLoadingWritings(true)
|
||||
setTLoadWritings(null)
|
||||
setTFirstWriting(null)
|
||||
DebugBus.info('debug', 'Loading my writings via writingsController...')
|
||||
try {
|
||||
let firstEventTime: number | null = null
|
||||
const unsub = writingsController.onWritings((posts) => {
|
||||
if (firstEventTime === null && posts.length > 0) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstWriting(Math.round(firstEventTime))
|
||||
}
|
||||
setWritingPosts(posts)
|
||||
})
|
||||
|
||||
await writingsController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: activeAccount.pubkey,
|
||||
force: true
|
||||
})
|
||||
|
||||
unsub()
|
||||
const currentWritings = writingsController.getWritings()
|
||||
setWritingPosts(currentWritings)
|
||||
DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`)
|
||||
} finally {
|
||||
setIsLoadingWritings(false)
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadWritings(elapsed)
|
||||
DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadFriendsWritings = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load friends writings')
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
setWritingPosts([])
|
||||
setIsLoadingWritings(true)
|
||||
setTLoadWritings(null)
|
||||
setTFirstWriting(null)
|
||||
DebugBus.info('debug', 'Loading friends writings...')
|
||||
try {
|
||||
// Get contacts first
|
||||
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey })
|
||||
const friends = contactsController.getContacts()
|
||||
const friendsArray = Array.from(friends)
|
||||
DebugBus.info('debug', `Found ${friendsArray.length} friends`)
|
||||
|
||||
if (friendsArray.length === 0) {
|
||||
DebugBus.warn('debug', 'No friends found to load writings from')
|
||||
return
|
||||
}
|
||||
|
||||
let firstEventTime: number | null = null
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const posts = await fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
friendsArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstWriting(Math.round(firstEventTime))
|
||||
}
|
||||
setWritingPosts(prev => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const exists = prev.find(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
if (exists) return prev
|
||||
return [...prev, post].sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
setWritingPosts(posts)
|
||||
DebugBus.info('debug', `Loaded ${posts.length} friend writings`)
|
||||
} finally {
|
||||
setIsLoadingWritings(false)
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadWritings(elapsed)
|
||||
DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadNostrverseWritings = async () => {
|
||||
if (!relayPool) {
|
||||
DebugBus.warn('debug', 'Relay pool not available')
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
setWritingPosts([])
|
||||
setIsLoadingWritings(true)
|
||||
setTLoadWritings(null)
|
||||
setTFirstWriting(null)
|
||||
DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...')
|
||||
try {
|
||||
let firstEventTime: number | null = null
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
const { Helpers } = await import('applesauce-core')
|
||||
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
await queryEvents(relayPool, { kinds: [30023], limit: 50 }, {
|
||||
relayUrls,
|
||||
onEvent: (evt) => {
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${evt.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || evt.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, evt)
|
||||
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstWriting(Math.round(firstEventTime))
|
||||
}
|
||||
|
||||
const posts = Array.from(uniqueEvents.values()).map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
} as BlogPostPreview)).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
setWritingPosts(posts)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const finalPosts = Array.from(uniqueEvents.values()).map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
} as BlogPostPreview)).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
setWritingPosts(finalPosts)
|
||||
DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`)
|
||||
} finally {
|
||||
setIsLoadingWritings(false)
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadWritings(elapsed)
|
||||
DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearWritings = () => {
|
||||
setWritingPosts([])
|
||||
setTLoadWritings(null)
|
||||
setTFirstWriting(null)
|
||||
}
|
||||
|
||||
const handleLoadFriendsList = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||
@@ -1070,6 +1260,99 @@ const Debug: React.FC<DebugProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Writings Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Writings Loading</h3>
|
||||
|
||||
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadMyWritings}
|
||||
disabled={isLoadingWritings || !relayPool || !activeAccount || !eventStore}
|
||||
>
|
||||
{isLoadingWritings ? (
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||
) : (
|
||||
'Load My Writings'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadFriendsWritings}
|
||||
disabled={isLoadingWritings || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingWritings ? (
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||
) : (
|
||||
'Load Friends Writings'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadNostrverseWritings}
|
||||
disabled={isLoadingWritings || !relayPool}
|
||||
>
|
||||
{isLoadingWritings ? (
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||
) : (
|
||||
'Load Nostrverse Writings'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary text-sm ml-auto"
|
||||
onClick={handleClearWritings}
|
||||
disabled={writingPosts.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadWritings} />
|
||||
<Stat label="first event" value={tFirstWriting} />
|
||||
</div>
|
||||
|
||||
{writingPosts.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Writings ({writingPosts.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{writingPosts.map((post, idx) => {
|
||||
const title = post.title
|
||||
const summary = post.summary
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[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">Writing #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Author: {post.author.slice(0, 16)}...</div>
|
||||
<div>Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}</div>
|
||||
<div>d-tag: {dTag || '(empty)'}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="font-semibold text-[11px]">Title:</div>
|
||||
<div>"{title}"</div>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
<div>Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
{post.image && (
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
<div>Image: {post.image.substring(0, 40)}...</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {post.event.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web of Trust Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Web of Trust</h3>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -27,6 +28,9 @@ import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -48,10 +52,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||
// Remove unused loading state to avoid warnings
|
||||
|
||||
// Reading progress state (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Load cached content from event store (instant display)
|
||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
@@ -66,24 +76,155 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
|
||||
|
||||
// Visibility filters (defaults from settings)
|
||||
|
||||
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
|
||||
// Ensure at least one scope remains active
|
||||
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
|
||||
setVisibility(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] }
|
||||
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||
return prev // ignore toggle that would disable all scopes
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Subscribe to highlights controller
|
||||
useEffect(() => {
|
||||
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
||||
return () => {
|
||||
unsubHighlights()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to nostrverse highlights controller for global stream
|
||||
useEffect(() => {
|
||||
const apply = (incoming: Highlight[]) => {
|
||||
setHighlights(prev => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const h of incoming) byId.set(h.id, h)
|
||||
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
// seed immediately
|
||||
apply(nostrverseHighlightsController.getHighlights())
|
||||
const unsub = nostrverseHighlightsController.onHighlights(apply)
|
||||
return () => unsub()
|
||||
}, [])
|
||||
|
||||
// Subscribe to nostrverse writings controller for global stream
|
||||
useEffect(() => {
|
||||
const apply = (incoming: BlogPostPreview[]) => {
|
||||
setBlogPosts(prev => {
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
for (const p of prev) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
byKey.set(key, p)
|
||||
}
|
||||
for (const p of incoming) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||
}
|
||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}
|
||||
apply(nostrverseWritingsController.getWritings())
|
||||
const unsub = nostrverseWritingsController.onWritings(apply)
|
||||
return () => unsub()
|
||||
}, [])
|
||||
|
||||
// Subscribe to writings controller for "mine" posts and seed immediately
|
||||
useEffect(() => {
|
||||
// Seed from controller's current state
|
||||
const seed = writingsController.getWritings()
|
||||
if (seed.length > 0) {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
|
||||
return merged.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Stream updates
|
||||
const unsub = writingsController.onWritings((posts) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
|
||||
return merged.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return () => unsub()
|
||||
}, [])
|
||||
|
||||
// Subscribe to reading progress controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
const initialMap = readingProgressController.getProgressMap()
|
||||
console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size)
|
||||
setReadingProgressMap(initialMap)
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||
console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size)
|
||||
setReadingProgressMap(newMap)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load reading progress data when logged in
|
||||
useEffect(() => {
|
||||
if (!activeAccount?.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
readingProgressController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: activeAccount.pubkey,
|
||||
force: refreshTrigger > 0
|
||||
})
|
||||
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Update visibility when settings/login state changes
|
||||
useEffect(() => {
|
||||
if (!activeAccount) {
|
||||
// When logged out, show nostrverse by default
|
||||
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
} else {
|
||||
// When logged in, use settings defaults immediately
|
||||
setVisibility({
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
setHasLoadedNostrverse(false)
|
||||
setHasLoadedNostrverseHighlights(false)
|
||||
}
|
||||
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -93,21 +234,22 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// show spinner but keep existing data
|
||||
// begin load, but do not block rendering
|
||||
setLoading(true)
|
||||
|
||||
// If not logged in, only fetch nostrverse content with streaming posts
|
||||
if (!activeAccount) {
|
||||
// Logged out: rely entirely on centralized controllers; do not fetch here
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||
}
|
||||
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||
}
|
||||
@@ -133,10 +275,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
})
|
||||
}
|
||||
|
||||
// At this point, we have seeded any available data; lift the loading state
|
||||
setLoading(false)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
activeAccount?.pubkey || '',
|
||||
(partial) => {
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
@@ -184,7 +329,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
setBlogPosts((prev) => {
|
||||
@@ -213,7 +358,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
@@ -229,14 +374,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(activeAccount.pubkey, merged)
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
@@ -250,52 +395,143 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// Fetch both friends content and nostrverse content in parallel
|
||||
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(contacts)
|
||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
])
|
||||
// Use centralized writingsController for my posts (non-blocking)
|
||||
// pull from writingsController; no need to store promise
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
setHasLoadedMine(true)
|
||||
const nostrversePostsPromise = visibility.nostrverse
|
||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
||||
// Stream nostrverse posts too when logged in
|
||||
setBlogPosts(prev => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) return prev
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
}
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
})
|
||||
: Promise.resolve([] as BlogPostPreview[])
|
||||
|
||||
// Merge and deduplicate all posts
|
||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
// Fire non-blocking fetches and merge as they resolve
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
||||
.then((friendsPosts) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
||||
// Pre-cache profiles in background
|
||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||
return sorted
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
|
||||
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
|
||||
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||
.then((friendsHighlights) => {
|
||||
setHighlights(prev => {
|
||||
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
|
||||
return sorted
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
// Fetch profiles for all blog post authors to cache them
|
||||
if (uniquePosts.length > 0) {
|
||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
||||
console.error('Failed to fetch author profiles:', err)
|
||||
})
|
||||
}
|
||||
nostrversePostsPromise.then((nostrversePosts) => {
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
}).catch(() => {})
|
||||
|
||||
// No blocking errors - let empty states handle messaging
|
||||
setBlogPosts(uniquePosts)
|
||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||
|
||||
setHighlights(uniqueHighlights)
|
||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
.then((nostriverseHighlights) => {
|
||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||
}).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// loading is already turned off after seeding
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
|
||||
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||
useEffect(() => {
|
||||
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
setHasLoadedNostrverse(true)
|
||||
fetchNostrverseBlogPosts(
|
||||
relayPool,
|
||||
relayUrls,
|
||||
50,
|
||||
eventStore || undefined,
|
||||
(post) => {
|
||||
setBlogPosts(prev => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) return prev
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
}
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}
|
||||
).then((finalPosts) => {
|
||||
// Ensure final deduped list
|
||||
setBlogPosts(prev => {
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
for (const p of [...prev, ...finalPosts]) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||
}
|
||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}).catch(() => {})
|
||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
||||
|
||||
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||
useEffect(() => {
|
||||
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
.then((hl) => {
|
||||
if (hl && hl.length > 0) {
|
||||
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
|
||||
|
||||
// Lazy-load my writings when user toggles "mine" on (logged in)
|
||||
// No direct fetch here; writingsController streams my posts centrally
|
||||
useEffect(() => {
|
||||
if (!activeAccount || !visibility.mine || hasLoadedMine) return
|
||||
setHasLoadedMine(true)
|
||||
}, [visibility.mine, activeAccount, hasLoadedMine])
|
||||
|
||||
// Pull-to-refresh
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
@@ -333,10 +569,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||
|
||||
// Dedupe and sort posts once for rendering
|
||||
const uniqueSortedPosts = useMemo(() => {
|
||||
const unique = dedupeWritingsByReplaceable(blogPosts)
|
||||
return unique.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}, [blogPosts])
|
||||
|
||||
// Filter blog posts by future dates and visibility, and add level classification
|
||||
const filteredBlogPosts = useMemo(() => {
|
||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||
return blogPosts
|
||||
return uniqueSortedPosts
|
||||
.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
@@ -360,7 +606,40 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
return { ...post, level }
|
||||
})
|
||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
||||
|
||||
// Helper to get reading progress for a post
|
||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) {
|
||||
console.log('[progress] ⚠️ No d-tag for post:', post.title)
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
const progress = readingProgressMap.get(naddr)
|
||||
|
||||
// Only log first lookup to avoid spam, or when found
|
||||
if (progress || readingProgressMap.size === 0) {
|
||||
console.log('[progress] 🔍 Looking up:', {
|
||||
title: post.title.slice(0, 30),
|
||||
naddr: naddr.slice(0, 80),
|
||||
mapSize: readingProgressMap.size,
|
||||
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
|
||||
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
|
||||
})
|
||||
}
|
||||
return progress
|
||||
} catch (err) {
|
||||
console.error('[progress] ❌ Error encoding naddr:', err)
|
||||
return undefined
|
||||
}
|
||||
}, [readingProgressMap])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
@@ -386,6 +665,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
readingProgress={getReadingProgress(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -403,7 +683,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<span>No highlights to show for the selected scope.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -422,9 +702,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
// Show skeletons while first load in this session
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
@@ -451,7 +731,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||
onClick={() => toggleScope('nostrverse')}
|
||||
title="Toggle nostrverse content"
|
||||
ariaLabel="Toggle nostrverse content"
|
||||
variant="ghost"
|
||||
@@ -462,7 +742,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||
onClick={() => toggleScope('friends')}
|
||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||
ariaLabel="Toggle friends content"
|
||||
variant="ghost"
|
||||
@@ -474,7 +754,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||
onClick={() => toggleScope('mine')}
|
||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||
ariaLabel="Toggle my content"
|
||||
variant="ghost"
|
||||
|
||||
@@ -46,36 +46,38 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
{currentUserPubkey && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
title="Toggle friends highlights"
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
title="Toggle my highlights"
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19, NostrEvent } from 'nostr-tools'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
@@ -33,17 +31,12 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
activeTab?: TabType
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||
}
|
||||
@@ -56,8 +49,7 @@ const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started'
|
||||
const Me: React.FC<MeProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeTab: propActiveTab,
|
||||
pubkey: propPubkey,
|
||||
activeTab: propActiveTab,
|
||||
bookmarks
|
||||
}) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
@@ -65,9 +57,8 @@ const Me: React.FC<MeProps> = ({
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
// Only for own profile
|
||||
const viewingPubkey = activeAccount?.pubkey
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [reads, setReads] = useState<ReadItem[]>([])
|
||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
@@ -81,30 +72,10 @@ const Me: React.FC<MeProps> = ({
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||
|
||||
// Load cached data from event store for OTHER profiles (not own)
|
||||
const cachedHighlights = useStoreTimeline(
|
||||
eventStore,
|
||||
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
|
||||
eventToHighlight,
|
||||
[viewingPubkey, isOwnProfile]
|
||||
)
|
||||
// Get myWritings directly from controller
|
||||
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
|
||||
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
|
||||
|
||||
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(
|
||||
eventStore,
|
||||
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
|
||||
toBlogPostPreview,
|
||||
[viewingPubkey, isOwnProfile]
|
||||
)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||
@@ -123,6 +94,9 @@ const Me: React.FC<MeProps> = ({
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||
|
||||
// Reading progress state for writings tab (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Subscribe to highlights controller
|
||||
useEffect(() => {
|
||||
@@ -138,6 +112,20 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to writings controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setMyWritings(writingsController.getWritings())
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubWritings = writingsController.onWritings(setMyWritings)
|
||||
const unsubLoading = writingsController.onLoading(setMyWritingsLoading)
|
||||
return () => {
|
||||
unsubWritings()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -164,68 +152,64 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to reading progress controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load reading progress data for writings tab
|
||||
useEffect(() => {
|
||||
if (!viewingPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
readingProgressController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: viewingPubkey,
|
||||
force: refreshTrigger > 0
|
||||
})
|
||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Tab-specific loading functions
|
||||
const loadHighlightsTab = async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
// Only show loading skeleton if tab hasn't been loaded yet
|
||||
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// For own profile, highlights come from controller subscription (sync effect handles it)
|
||||
// For viewing other users, seed with cached data then fetch fresh
|
||||
if (!isOwnProfile) {
|
||||
// Seed with cached highlights first
|
||||
if (cachedHighlights.length > 0) {
|
||||
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
|
||||
// Fetch fresh highlights
|
||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||
setHighlights(userHighlights)
|
||||
}
|
||||
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load highlights:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
// Highlights come from controller subscription (sync effect handles it)
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadWritingsTab = async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('writings')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Seed with cached writings first
|
||||
if (!isOwnProfile && cachedWritings.length > 0) {
|
||||
setWritings(cachedWritings.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
}))
|
||||
}
|
||||
|
||||
// Fetch fresh writings
|
||||
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
setWritings(userWritings)
|
||||
// Use centralized controller
|
||||
await writingsController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: viewingPubkey,
|
||||
force: refreshTrigger > 0
|
||||
})
|
||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to load writings:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReadingListTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||
|
||||
@@ -241,7 +225,7 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
const loadReadsTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reads')
|
||||
|
||||
@@ -291,7 +275,7 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
const loadLinksTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('links')
|
||||
|
||||
@@ -337,14 +321,12 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
// Load cached data immediately if available
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||
setReads(cached.reads || [])
|
||||
setLinks(cached.links || [])
|
||||
}
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||
setReads(cached.reads || [])
|
||||
setLinks(cached.links || [])
|
||||
}
|
||||
|
||||
// Load data for active tab (refresh in background if already loaded)
|
||||
@@ -368,12 +350,15 @@ const Me: React.FC<MeProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||
|
||||
// Sync myHighlights from controller when viewing own profile
|
||||
// Sync myHighlights from controller
|
||||
useEffect(() => {
|
||||
if (isOwnProfile) {
|
||||
setHighlights(myHighlights)
|
||||
}
|
||||
}, [isOwnProfile, myHighlights])
|
||||
setHighlights(myHighlights)
|
||||
}, [myHighlights])
|
||||
|
||||
// Sync myWritings from controller
|
||||
useEffect(() => {
|
||||
setWritings(myWritings)
|
||||
}, [myWritings])
|
||||
|
||||
// Pull-to-refresh - reload active tab without clearing state
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
@@ -389,8 +374,8 @@ const Me: React.FC<MeProps> = ({
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted (own profile only)
|
||||
if (isOwnProfile && viewingPubkey) {
|
||||
// Update cache when highlight is deleted
|
||||
if (viewingPubkey) {
|
||||
updateCachedHighlights(viewingPubkey, updated)
|
||||
}
|
||||
return updated
|
||||
@@ -468,6 +453,23 @@ const Me: React.FC<MeProps> = ({
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get reading progress for a post
|
||||
const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) return undefined
|
||||
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
return readingProgressMap.get(naddr)
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
@@ -494,7 +496,7 @@ const Me: React.FC<MeProps> = ({
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
|
||||
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
@@ -508,7 +510,7 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
|
||||
return highlights.length === 0 && !loading && !myHighlightsLoading ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No highlights yet.
|
||||
</div>
|
||||
@@ -529,9 +531,9 @@ const Me: React.FC<MeProps> = ({
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
<div className="bookmarks-grid bookmarks-cards">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
<BookmarkSkeleton key={i} viewMode="cards" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,13 +559,13 @@ const Me: React.FC<MeProps> = ({
|
||||
sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
<div className="bookmarks-grid bookmarks-cards">
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
viewMode="cards"
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
@@ -585,27 +587,6 @@ const Me: React.FC<MeProps> = ({
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => setViewMode('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => setViewMode('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -714,7 +695,7 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return writings.length === 0 && !loading ? (
|
||||
return writings.length === 0 && !loading && !myWritingsLoading ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles written yet.
|
||||
</div>
|
||||
@@ -725,6 +706,7 @@ const Me: React.FC<MeProps> = ({
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={getWritingReadingProgress(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -748,43 +730,39 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
>
|
||||
<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
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
>
|
||||
<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
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||
onClick={() => navigate('/me/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
|
||||
280
src/components/Profile.tsx
Normal file
280
src/components/Profile.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
|
||||
interface ProfileProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
activeTab?: 'highlights' | 'writings'
|
||||
}
|
||||
|
||||
const Profile: React.FC<ProfileProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
activeTab: propActiveTab
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Reading progress state (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Load cached data from event store instantly
|
||||
const cachedHighlights = useStoreTimeline(
|
||||
eventStore,
|
||||
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||
eventToHighlight,
|
||||
[pubkey]
|
||||
)
|
||||
|
||||
const cachedWritings = useStoreTimeline(
|
||||
eventStore,
|
||||
{ kinds: [30023], authors: [pubkey] },
|
||||
toBlogPostPreview,
|
||||
[pubkey]
|
||||
)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
// Subscribe to reading progress controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
const initialMap = readingProgressController.getProgressMap()
|
||||
console.log('[progress] 🎯 Profile: Initial progress map size:', initialMap.size)
|
||||
setReadingProgressMap(initialMap)
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||
console.log('[progress] 🎯 Profile: Received progress update, size:', newMap.size)
|
||||
setReadingProgressMap(newMap)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load reading progress data when logged in
|
||||
useEffect(() => {
|
||||
if (!activeAccount?.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
readingProgressController.start({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: activeAccount.pubkey,
|
||||
force: refreshTrigger > 0
|
||||
})
|
||||
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Background fetch to populate event store (non-blocking)
|
||||
useEffect(() => {
|
||||
if (!pubkey || !relayPool || !eventStore) return
|
||||
|
||||
console.log('🔄 [Profile] Background fetching highlights and writings for', pubkey.slice(0, 8))
|
||||
|
||||
// Fetch highlights in background
|
||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||
.then(highlights => {
|
||||
console.log('✅ [Profile] Fetched', highlights.length, 'highlights')
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
||||
})
|
||||
|
||||
// Fetch writings in background (no limit for single user profile)
|
||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
||||
.then(writings => {
|
||||
writings.forEach(w => eventStore.add(w.event))
|
||||
console.log('✅ [Profile] Fetched', writings.length, 'writings')
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
||||
})
|
||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !pubkey
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
// Helper to get reading progress for a post
|
||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) return undefined
|
||||
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
const progress = readingProgressMap.get(naddr)
|
||||
|
||||
// Only log when found or map is empty
|
||||
if (progress || readingProgressMap.size === 0) {
|
||||
console.log('[progress] 🔍 Profile lookup:', {
|
||||
title: post.title?.slice(0, 30),
|
||||
naddr: naddr.slice(0, 80),
|
||||
mapSize: readingProgressMap.size,
|
||||
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
|
||||
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
|
||||
})
|
||||
}
|
||||
return progress
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}, [readingProgressMap])
|
||||
|
||||
const handleHighlightDelete = () => {
|
||||
// Not allowed to delete other users' highlights
|
||||
return
|
||||
}
|
||||
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return cachedHighlights.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No highlights yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
{cachedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={{ ...highlight, level: 'mine' }}
|
||||
relayPool={relayPool}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return cachedWritings.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles written yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{cachedWritings.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={getReadingProgress(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate(`/p/${npub}`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate(`/p/${npub}/writings`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="me-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
|
||||
@@ -39,7 +39,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: false,
|
||||
syncReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
|
||||
@@ -117,6 +117,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<span>Sync reading position across devices</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||
<input
|
||||
id="autoMarkAsReadOnCompletion"
|
||||
type="checkbox"
|
||||
checked={settings.autoMarkAsReadOnCompletion ?? false}
|
||||
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Automatically mark as read at 100%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.classifiedHighlights}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Nostr event kinds used throughout the application
|
||||
export const KINDS = {
|
||||
Highlights: 9802, // NIP-?? user highlights
|
||||
Highlights: 9802, // NIP-84 user highlights
|
||||
BlogPost: 30023, // NIP-23 long-form article
|
||||
AppData: 30078, // NIP-78 application data (reading positions)
|
||||
AppData: 30078, // NIP-78 application data
|
||||
ReadingProgress: 39802, // NIP-85 reading progress
|
||||
List: 30001, // NIP-51 list (addressable)
|
||||
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||
ListSimple: 10003, // NIP-51 simple list
|
||||
|
||||
@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
|
||||
import { useStoreTimeline } from './useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
|
||||
// Determine effective article coordinate as early as possible
|
||||
// Prefer state-derived coordinate, but fall back to route naddr before content loads
|
||||
const effectiveArticleCoordinate = useMemo(() => {
|
||||
if (currentArticleCoordinate) return currentArticleCoordinate
|
||||
if (!naddr) return undefined
|
||||
try {
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
}
|
||||
} catch {
|
||||
// ignore decode failure; treat as no coordinate yet
|
||||
}
|
||||
return undefined
|
||||
}, [currentArticleCoordinate, naddr])
|
||||
|
||||
// Load cached article-specific highlights from event store
|
||||
const articleFilter = useMemo(() => {
|
||||
if (!currentArticleCoordinate) return null
|
||||
if (!effectiveArticleCoordinate) return null
|
||||
return {
|
||||
kinds: [KINDS.Highlights],
|
||||
'#a': [currentArticleCoordinate],
|
||||
'#a': [effectiveArticleCoordinate],
|
||||
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||
}
|
||||
}, [currentArticleCoordinate, currentArticleEventId])
|
||||
}, [effectiveArticleCoordinate, currentArticleEventId])
|
||||
|
||||
const cachedArticleHighlights = useStoreTimeline(
|
||||
eventStore || null,
|
||||
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||
eventToHighlight,
|
||||
[currentArticleCoordinate, currentArticleEventId]
|
||||
[effectiveArticleCoordinate, currentArticleEventId]
|
||||
)
|
||||
|
||||
// Subscribe to centralized controllers
|
||||
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
|
||||
|
||||
setHighlightsLoading(true)
|
||||
try {
|
||||
if (currentArticleCoordinate) {
|
||||
if (effectiveArticleCoordinate) {
|
||||
// Seed with cached highlights first
|
||||
if (cachedArticleHighlights.length > 0) {
|
||||
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
effectiveArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||
|
||||
const handleRefreshAll = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
@@ -143,19 +161,20 @@ export const useBookmarksData = ({
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (currentArticleCoordinate && !externalUrl) {
|
||||
if (effectiveArticleCoordinate && !externalUrl) {
|
||||
handleFetchHighlights()
|
||||
} else if (!naddr && !externalUrl) {
|
||||
// Clear article highlights when not viewing an article
|
||||
setArticleHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
|
||||
// Merge highlights from controller with article-specific highlights
|
||||
const highlights = [...myHighlights, ...articleHighlights]
|
||||
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
// When viewing an article, show only article-specific highlights
|
||||
// Otherwise, show user's highlights from controller
|
||||
const highlights = effectiveArticleCoordinate || externalUrl
|
||||
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
|
||||
: myHighlights
|
||||
|
||||
return {
|
||||
highlights,
|
||||
|
||||
@@ -4,40 +4,53 @@ interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9,
|
||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000
|
||||
autoSaveInterval = 5000,
|
||||
completionHoldMs = 2000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hasSavedOnce = useRef(false)
|
||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounced save function
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Don't save if position is too low (< 5%)
|
||||
if (currentPosition < 0.05) return
|
||||
|
||||
if (!syncEnabled || !onSave) {
|
||||
console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' })
|
||||
return
|
||||
}
|
||||
|
||||
// Don't save if position hasn't changed significantly (less than 1%)
|
||||
// But always save if we've reached 100% (completion)
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||
const isInitialSave = !hasSavedOnce.current
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
||||
console.log('[progress] ⏭️ No significant change:', {
|
||||
current: Math.round(currentPosition * 100) + '%',
|
||||
last: Math.round(lastSavedPosition.current * 100) + '%',
|
||||
diff: Math.abs(currentPosition - lastSavedPosition.current),
|
||||
isInitialSave
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (saveTimerRef.current) {
|
||||
@@ -45,8 +58,11 @@ export const useReadingPosition = ({
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
console.log('[progress] ⏰ Scheduling save in', autoSaveInterval + 'ms for position:', Math.round(currentPosition * 100) + '%')
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%')
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
onSave(currentPosition)
|
||||
}, autoSaveInterval)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
@@ -61,11 +77,11 @@ export const useReadingPosition = ({
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Save if position is meaningful (>= 5%)
|
||||
if (position >= 0.05) {
|
||||
lastSavedPosition.current = position
|
||||
onSave(position)
|
||||
}
|
||||
// Always allow immediate save (including 0%)
|
||||
console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%')
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
onSave(position)
|
||||
}, [syncEnabled, onSave, position])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,17 +105,53 @@ export const useReadingPosition = ({
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
// Only log on significant changes (every 5%) to avoid flooding console
|
||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
||||
const newPercent = Math.floor(clampedProgress * 20)
|
||||
if (prevPercent !== newPercent) {
|
||||
console.log('[progress] 📏 useReadingPosition:', Math.round(clampedProgress * 100) + '%', {
|
||||
scrollTop,
|
||||
documentHeight,
|
||||
isAtBottom
|
||||
})
|
||||
}
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
// Completion detection with 2s hold at 100%
|
||||
if (!hasTriggeredComplete.current) {
|
||||
// If at exact 100%, start a hold timer; cancel if we scroll up
|
||||
if (clampedProgress === 1) {
|
||||
if (!completionTimerRef.current) {
|
||||
completionTimerRef.current = setTimeout(() => {
|
||||
if (!hasTriggeredComplete.current && position === 1) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
console.log('[progress] ✅ Completion hold satisfied (100% for', completionHoldMs, 'ms)')
|
||||
onReadingComplete?.()
|
||||
}
|
||||
completionTimerRef.current = null
|
||||
}, completionHoldMs)
|
||||
console.log('[progress] ⏳ Completion hold started (waiting', completionHoldMs, 'ms)')
|
||||
}
|
||||
} else {
|
||||
// If we moved off 100%, cancel any pending completion hold
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
completionTimerRef.current = null
|
||||
// still allow threshold-based completion for near-bottom if configured
|
||||
if (clampedProgress >= readingCompleteThreshold) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
console.log('[progress] ✅ Completion via threshold:', readingCompleteThreshold)
|
||||
onReadingComplete?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +170,12 @@ export const useReadingPosition = ({
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
}
|
||||
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
@@ -126,6 +183,12 @@ export const useReadingPosition = ({
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
hasSavedOnce.current = false
|
||||
lastSavedPosition.current = 0
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
completionTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
|
||||
@@ -126,18 +126,14 @@ class BookmarkController {
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.eventLoader) {
|
||||
console.warn('[bookmark] ⚠️ EventLoader not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to unique IDs not already hydrated
|
||||
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
||||
if (unique.length === 0) {
|
||||
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
|
||||
|
||||
// Convert IDs to EventPointers
|
||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||
@@ -159,8 +155,8 @@ class BookmarkController {
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('[bookmark] ❌ EventLoader error:', error)
|
||||
error: () => {
|
||||
// Silent error - EventLoader handles retries
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -175,14 +171,11 @@ class BookmarkController {
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
if (coords.length === 0) return
|
||||
|
||||
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
|
||||
|
||||
// Convert coordinates to AddressPointers
|
||||
const pointers = coords.map(c => ({
|
||||
kind: c.kind,
|
||||
@@ -203,8 +196,8 @@ class BookmarkController {
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('[bookmark] ❌ AddressLoader error:', error)
|
||||
error: () => {
|
||||
// Silent error - AddressLoader handles retries
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -223,10 +216,6 @@ class BookmarkController {
|
||||
return this.decryptedResults.has(getEventKey(evt))
|
||||
})
|
||||
|
||||
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
|
||||
const decryptedCount = readyEvents.length - unencryptedCount
|
||||
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
|
||||
|
||||
if (readyEvents.length === 0) {
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
return
|
||||
@@ -237,17 +226,14 @@ class BookmarkController {
|
||||
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
||||
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
||||
|
||||
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
|
||||
// Process unencrypted events
|
||||
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
||||
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
||||
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
|
||||
|
||||
// Merge in decrypted results
|
||||
let publicItemsAll = [...publicUnencrypted]
|
||||
let privateItemsAll = [...privateUnencrypted]
|
||||
|
||||
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
|
||||
decryptedEvents.forEach(evt => {
|
||||
const eventKey = getEventKey(evt)
|
||||
const decrypted = this.decryptedResults.get(eventKey)
|
||||
@@ -256,11 +242,8 @@ class BookmarkController {
|
||||
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
|
||||
|
||||
// Separate hex IDs from coordinates
|
||||
const noteIds: string[] = []
|
||||
@@ -276,14 +259,11 @@ class BookmarkController {
|
||||
|
||||
// Helper to build and emit bookmarks
|
||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||
console.log('[bookmark] 🔧 Building final bookmarks list...')
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
|
||||
|
||||
console.log('[bookmark] 🔧 Enriching and sorting...')
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
@@ -293,9 +273,7 @@ class BookmarkController {
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
|
||||
|
||||
console.log('[bookmark] 🔧 Creating final Bookmark object...')
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
@@ -310,18 +288,14 @@ class BookmarkController {
|
||||
encryptedContent: undefined
|
||||
}
|
||||
|
||||
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
|
||||
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
|
||||
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
||||
}
|
||||
|
||||
// Emit immediately with empty metadata (show placeholders)
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
|
||||
emitBookmarks(idToEvent)
|
||||
|
||||
// Now fetch events progressively in background using batched hydrators
|
||||
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
|
||||
|
||||
const generation = this.hydrationGeneration
|
||||
const onProgress = () => emitBookmarks(idToEvent)
|
||||
@@ -341,9 +315,7 @@ class BookmarkController {
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||
} catch (error) {
|
||||
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
|
||||
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
|
||||
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
|
||||
console.error('Failed to build bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
}
|
||||
}
|
||||
@@ -356,7 +328,6 @@ class BookmarkController {
|
||||
const { relayPool, activeAccount, accountManager } = options
|
||||
|
||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||
console.error('[bookmark] Invalid activeAccount')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -366,7 +337,6 @@ class BookmarkController {
|
||||
this.hydrationGeneration++
|
||||
|
||||
// Initialize loaders for this session
|
||||
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
@@ -377,7 +347,6 @@ class BookmarkController {
|
||||
})
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
// Get signer for auto-decryption
|
||||
@@ -405,7 +374,6 @@ class BookmarkController {
|
||||
|
||||
// Add/update event
|
||||
this.currentEvents.set(key, evt)
|
||||
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
|
||||
|
||||
// Emit raw event for Debug UI
|
||||
this.emitRawEvent(evt)
|
||||
@@ -415,12 +383,13 @@ class BookmarkController {
|
||||
if (!isEncrypted) {
|
||||
// For unencrypted events, build bookmarks immediately (progressive update)
|
||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
|
||||
.catch(() => {
|
||||
// Silent error - will retry on next event
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
||||
if (isEncrypted) {
|
||||
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
|
||||
// Don't await - let it run in background
|
||||
collectBookmarksFromEvents([evt], account, signerCandidate)
|
||||
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
||||
@@ -433,10 +402,6 @@ class BookmarkController {
|
||||
latestContent,
|
||||
allTags
|
||||
})
|
||||
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
|
||||
public: publicItemsAll.length,
|
||||
private: privateItemsAll.length
|
||||
})
|
||||
|
||||
// Emit decrypt complete for Debug UI
|
||||
this.decryptCompleteListeners.forEach(cb =>
|
||||
@@ -445,10 +410,12 @@ class BookmarkController {
|
||||
|
||||
// Rebuild bookmarks with newly decrypted content (progressive update)
|
||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
|
||||
.catch(() => {
|
||||
// Silent error - will retry on next event
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
|
||||
.catch(() => {
|
||||
// Silent error - decrypt failed
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -457,9 +424,8 @@ class BookmarkController {
|
||||
|
||||
// Final update after EOSE
|
||||
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
console.log('[bookmark] ✅ Bookmark load complete')
|
||||
} catch (error) {
|
||||
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
|
||||
console.error('Failed to load bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
|
||||
@@ -20,13 +20,16 @@ export interface BlogPostPreview {
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @param onPost - Optional callback for streaming posts
|
||||
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[],
|
||||
onPost?: (post: BlogPostPreview) => void
|
||||
onPost?: (post: BlogPostPreview) => void,
|
||||
limit: number | null = 100
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
@@ -34,15 +37,19 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors', limit ? `(limit: ${limit})` : '(no limit)')
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
const filter = limit !== null
|
||||
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||
filter,
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { ReadItem } from './readsService'
|
||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
/**
|
||||
* Fetches external URL links with reading progress from:
|
||||
* - URLs with reading progress (kind:30078)
|
||||
* - URLs with reading progress (kind:39802)
|
||||
* - Manually marked as read URLs (kind:7, kind:17)
|
||||
*/
|
||||
export async function fetchLinks(
|
||||
@@ -32,18 +32,18 @@ export async function fetchLinks(
|
||||
|
||||
try {
|
||||
// Fetch all data sources in parallel
|
||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Links] Data fetched:', {
|
||||
readingPositions: readingPositionEvents.length,
|
||||
readingProgress: progressEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length
|
||||
})
|
||||
|
||||
// Process reading positions and emit external items
|
||||
processReadingPositions(readingPositionEvents, linksMap)
|
||||
// Process reading progress events (kind 39802)
|
||||
processReadingProgress(progressEvents, linksMap)
|
||||
if (onItem) {
|
||||
linksMap.forEach(item => {
|
||||
if (item.type === 'external') {
|
||||
|
||||
139
src/services/nostrverseHighlightsController.ts
Normal file
139
src/services/nostrverseHighlightsController.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||
|
||||
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'nostrverse_highlights_last_synced'
|
||||
|
||||
class NostrverseHighlightsController {
|
||||
private highlightsListeners: HighlightsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentHighlights: Highlight[] = []
|
||||
private loaded = false
|
||||
private generation = 0
|
||||
|
||||
onHighlights(cb: HighlightsCallback): () => void {
|
||||
this.highlightsListeners.push(cb)
|
||||
return () => {
|
||||
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitHighlights(highlights: Highlight[]): void {
|
||||
this.highlightsListeners.forEach(cb => cb(highlights))
|
||||
}
|
||||
|
||||
getHighlights(): Highlight[] {
|
||||
return [...this.currentHighlights]
|
||||
}
|
||||
|
||||
isLoaded(): boolean {
|
||||
return this.loaded
|
||||
}
|
||||
|
||||
private getLastSyncedAt(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed?.ts === 'number' ? parsed.ts : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private setLastSyncedAt(timestamp: number): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts: timestamp }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, force = false } = options
|
||||
|
||||
if (!force && this.loaded) {
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
return
|
||||
}
|
||||
|
||||
this.generation++
|
||||
const currentGeneration = this.generation
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
filter,
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
if (currentGeneration !== this.generation) return
|
||||
if (seenIds.has(evt.id)) return
|
||||
seenIds.add(evt.id)
|
||||
|
||||
eventStore.add(evt)
|
||||
const highlight = eventToHighlight(evt)
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
|
||||
const sorted = sortHighlights(Array.from(highlightsMap.values()))
|
||||
this.currentHighlights = sorted
|
||||
this.emitHighlights(sorted)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
const highlights = events.map(eventToHighlight)
|
||||
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
|
||||
const sorted = sortHighlights(unique)
|
||||
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const newest = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(newest)
|
||||
}
|
||||
} catch (err) {
|
||||
this.currentHighlights = []
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
} finally {
|
||||
if (currentGeneration === this.generation) this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const nostrverseHighlightsController = new NostrverseHighlightsController()
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ export const fetchNostrverseBlogPosts = async (
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
limit = 50,
|
||||
eventStore?: IEventStore
|
||||
eventStore?: IEventStore,
|
||||
onPost?: (post: BlogPostPreview) => void
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
||||
@@ -44,6 +45,19 @@ export const fetchNostrverseBlogPosts = async (
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
|
||||
// Stream post immediately if callback provided
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +106,8 @@ export const fetchNostrverseHighlights = async (
|
||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
// Collect but do not block callers awaiting network completion
|
||||
const collected: NostrEvent[] = []
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], limit },
|
||||
@@ -104,6 +120,7 @@ export const fetchNostrverseHighlights = async (
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
collected.push(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -113,7 +130,7 @@ export const fetchNostrverseHighlights = async (
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
169
src/services/nostrverseWritingsController.ts
Normal file
169
src/services/nostrverseWritingsController.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||
|
||||
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'nostrverse_writings_last_synced'
|
||||
|
||||
function toPreview(event: NostrEvent): BlogPostPreview {
|
||||
return {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
}
|
||||
|
||||
function sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||
return posts.slice().sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
class NostrverseWritingsController {
|
||||
private writingsListeners: WritingsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentPosts: BlogPostPreview[] = []
|
||||
private loaded = false
|
||||
private generation = 0
|
||||
|
||||
onWritings(cb: WritingsCallback): () => void {
|
||||
this.writingsListeners.push(cb)
|
||||
return () => {
|
||||
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitWritings(posts: BlogPostPreview[]): void {
|
||||
this.writingsListeners.forEach(cb => cb(posts))
|
||||
}
|
||||
|
||||
getWritings(): BlogPostPreview[] {
|
||||
return [...this.currentPosts]
|
||||
}
|
||||
|
||||
isLoaded(): boolean {
|
||||
return this.loaded
|
||||
}
|
||||
|
||||
private getLastSyncedAt(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed?.ts === 'number' ? parsed.ts : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private setLastSyncedAt(ts: number): void {
|
||||
try { localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, force = false } = options
|
||||
|
||||
if (!force && this.loaded) {
|
||||
this.emitWritings(this.currentPosts)
|
||||
return
|
||||
}
|
||||
|
||||
this.generation++
|
||||
const currentGeneration = this.generation
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.BlogPost] }
|
||||
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
filter,
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
if (currentGeneration !== this.generation) return
|
||||
if (seenIds.has(evt.id)) return
|
||||
seenIds.add(evt.id)
|
||||
|
||||
eventStore.add(evt)
|
||||
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${evt.pubkey}:${dTag}`
|
||||
const preview = toPreview(evt)
|
||||
const existing = uniqueByReplaceable.get(key)
|
||||
if (!existing || evt.created_at > existing.event.created_at) {
|
||||
uniqueByReplaceable.set(key, preview)
|
||||
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||
this.currentPosts = sorted
|
||||
this.emitWritings(sorted)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
events.forEach(evt => {
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${evt.pubkey}:${dTag}`
|
||||
const existing = uniqueByReplaceable.get(key)
|
||||
if (!existing || evt.created_at > existing.event.created_at) {
|
||||
uniqueByReplaceable.set(key, toPreview(evt))
|
||||
}
|
||||
})
|
||||
|
||||
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||
this.currentPosts = sorted
|
||||
this.loaded = true
|
||||
this.emitWritings(sorted)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const newest = Math.max(...sorted.map(p => p.event.created_at))
|
||||
this.setLastSyncedAt(newest)
|
||||
}
|
||||
} catch {
|
||||
this.currentPosts = []
|
||||
this.emitWritings(this.currentPosts)
|
||||
} finally {
|
||||
if (currentGeneration === this.generation) this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const nostrverseWritingsController = new NostrverseWritingsController()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||
import { ReadItem } from './readsService'
|
||||
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
|
||||
|
||||
interface ReadArticle {
|
||||
id: string
|
||||
@@ -13,44 +14,95 @@ interface ReadArticle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes reading position events into ReadItems
|
||||
* Processes reading progress events (kind 39802) into ReadItems
|
||||
*
|
||||
* Test scenarios:
|
||||
* - Kind 39802 with d="30023:..." → article ReadItem with naddr id
|
||||
* - Kind 39802 with d="url:..." → external ReadItem with decoded URL
|
||||
* - Newer event.created_at overwrites older timestamp
|
||||
* - Invalid d tag format → skip event
|
||||
* - Malformed JSON content → skip event
|
||||
*/
|
||||
export function processReadingPositions(
|
||||
export function processReadingProgress(
|
||||
events: NostrEvent[],
|
||||
readsMap: Map<string, ReadItem>
|
||||
): void {
|
||||
console.log('[progress] 🔧 processReadingProgress called with', events.length, 'events')
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind !== READING_PROGRESS_KIND) {
|
||||
console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind)
|
||||
continue
|
||||
}
|
||||
|
||||
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, '')
|
||||
if (!dTag) {
|
||||
console.log('[progress] ⚠️ Event missing d-tag:', event.id.slice(0, 8))
|
||||
continue
|
||||
}
|
||||
|
||||
console.log('[progress] 📝 Processing event:', event.id.slice(0, 8), 'd-tag:', dTag.slice(0, 50))
|
||||
|
||||
try {
|
||||
const positionData = JSON.parse(event.content)
|
||||
const position = positionData.position
|
||||
const timestamp = positionData.timestamp
|
||||
const content = JSON.parse(event.content)
|
||||
const position = content.progress || 0
|
||||
|
||||
console.log('[progress] 📊 Progress value:', position, '(' + Math.round(position * 100) + '%)')
|
||||
|
||||
// Validate progress is between 0 and 1 (NIP-85 requirement)
|
||||
if (position < 0 || position > 1) {
|
||||
console.warn('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8))
|
||||
continue
|
||||
}
|
||||
|
||||
// Use event.created_at as authoritative timestamp (NIP-85 spec)
|
||||
const timestamp = event.created_at
|
||||
|
||||
let itemId: string
|
||||
let itemUrl: string | undefined
|
||||
let itemType: 'article' | 'external' = 'external'
|
||||
|
||||
// Check if it's a nostr article (naddr format)
|
||||
if (identifier.startsWith('naddr1')) {
|
||||
itemId = identifier
|
||||
itemType = 'article'
|
||||
} else {
|
||||
// It's a base64url-encoded URL
|
||||
try {
|
||||
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode URL identifier:', identifier)
|
||||
// Check if d tag is a coordinate (30023:pubkey:identifier)
|
||||
if (dTag.startsWith('30023:')) {
|
||||
// It's a nostr article coordinate
|
||||
const parts = dTag.split(':')
|
||||
if (parts.length === 3) {
|
||||
// Convert to naddr for consistency with the rest of the app
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: parseInt(parts[0]),
|
||||
pubkey: parts[1],
|
||||
identifier: parts[2]
|
||||
})
|
||||
itemId = naddr
|
||||
itemType = 'article'
|
||||
console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50))
|
||||
} catch (e) {
|
||||
console.warn('[progress] ❌ Failed to encode naddr from coordinate:', dTag)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn('[progress] ⚠️ Invalid coordinate format:', dTag)
|
||||
continue
|
||||
}
|
||||
} else if (dTag.startsWith('url:')) {
|
||||
// It's a URL with base64url encoding
|
||||
const encoded = dTag.replace('url:', '')
|
||||
try {
|
||||
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50))
|
||||
} catch (e) {
|
||||
console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn('[progress] ⚠️ Unknown d-tag format:', dTag)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add or update the item
|
||||
// Add or update the item, preferring newer timestamps
|
||||
const existing = readsMap.get(itemId)
|
||||
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||
readsMap.set(itemId, {
|
||||
@@ -62,11 +114,16 @@ export function processReadingPositions(
|
||||
readingProgress: position,
|
||||
readingTimestamp: timestamp
|
||||
})
|
||||
console.log('[progress] ✅ Added/updated item in readsMap:', itemId.slice(0, 50), '=', Math.round(position * 100) + '%')
|
||||
} else {
|
||||
console.log('[progress] ⏭️ Skipping older event for:', itemId.slice(0, 50))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse reading position:', error)
|
||||
console.warn('[progress] ❌ Failed to parse reading progress event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[progress] 🏁 processReadingProgress finished, readsMap size:', readsMap.size)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { publishEvent } from './writeService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
|
||||
|
||||
export interface ReadingPosition {
|
||||
position: number // 0-1 scroll progress
|
||||
@@ -15,16 +15,83 @@ export interface ReadingPosition {
|
||||
scrollTop?: number // Optional: pixel position
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading position from an event
|
||||
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||
export interface ReadingProgressContent {
|
||||
progress: number // 0-1 scroll progress
|
||||
ts?: number // Unix timestamp (optional, for display)
|
||||
loc?: number // Optional: pixel position
|
||||
ver?: string // Schema version
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading progress from event (kind 39802)
|
||||
function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined {
|
||||
if (!event.content || event.content.length === 0) return undefined
|
||||
try {
|
||||
return JSON.parse(event.content) as ReadingPosition
|
||||
const content = JSON.parse(event.content) as ReadingProgressContent
|
||||
return {
|
||||
position: content.progress,
|
||||
timestamp: content.ts || event.created_at,
|
||||
scrollTop: content.loc
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Generate d tag for kind 39802 based on target
|
||||
// Test cases:
|
||||
// - naddr1... → "30023:<pubkey>:<identifier>"
|
||||
// - https://example.com/post → "url:<base64url>"
|
||||
// - Invalid naddr → "url:<base64url>" (fallback)
|
||||
function generateDTag(naddrOrUrl: string): string {
|
||||
// If it's a nostr article (naddr format), decode and build coordinate
|
||||
if (naddrOrUrl.startsWith('naddr1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(naddrOrUrl)
|
||||
if (decoded.type === 'naddr') {
|
||||
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
||||
console.log('[progress] 📋 Generated d-tag from naddr:', {
|
||||
naddr: naddrOrUrl.slice(0, 50) + '...',
|
||||
dTag: dTag.slice(0, 80) + '...'
|
||||
})
|
||||
return dTag
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode naddr:', naddrOrUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// For URLs, use url: prefix with base64url encoding
|
||||
const base64url = btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return `url:${base64url}`
|
||||
}
|
||||
|
||||
// Generate tags for kind 39802 event
|
||||
function generateProgressTags(naddrOrUrl: string): string[][] {
|
||||
const dTag = generateDTag(naddrOrUrl)
|
||||
const tags: string[][] = [['d', dTag]]
|
||||
|
||||
// Add 'a' tag for nostr articles
|
||||
if (naddrOrUrl.startsWith('naddr1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(naddrOrUrl)
|
||||
if (decoded.type === 'naddr') {
|
||||
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
|
||||
tags.push(['a', coordinate])
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore decode errors
|
||||
}
|
||||
} else {
|
||||
// Add 'r' tag for URLs
|
||||
tags.push(['r', naddrOrUrl])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for an article
|
||||
* For Nostr articles: use the naddr directly
|
||||
@@ -43,7 +110,7 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reading position to Nostr (Kind 30078)
|
||||
* Save reading position to Nostr (kind 39802)
|
||||
*/
|
||||
export async function saveReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
@@ -52,36 +119,57 @@ export async function saveReadingPosition(
|
||||
articleIdentifier: string,
|
||||
position: ReadingPosition
|
||||
): Promise<void> {
|
||||
console.log('💾 [ReadingPosition] Saving position:', {
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
console.log('[progress] 💾 saveReadingPosition: Starting save:', {
|
||||
identifier: articleIdentifier.slice(0, 50) + '...',
|
||||
position: position.position,
|
||||
positionPercent: Math.round(position.position * 100) + '%',
|
||||
timestamp: position.timestamp,
|
||||
scrollTop: position.scrollTop
|
||||
})
|
||||
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const progressContent: ReadingProgressContent = {
|
||||
progress: position.position,
|
||||
ts: position.timestamp,
|
||||
loc: position.scrollTop,
|
||||
ver: '1'
|
||||
}
|
||||
|
||||
const tags = generateProgressTags(articleIdentifier)
|
||||
|
||||
console.log('[progress] 📝 Creating event with:', {
|
||||
kind: READING_PROGRESS_KIND,
|
||||
content: progressContent,
|
||||
tags: tags.map(t => `[${t.join(', ')}]`).join(', '),
|
||||
created_at: now
|
||||
})
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: APP_DATA_KIND,
|
||||
content: JSON.stringify(position),
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['client', 'boris']
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
kind: READING_PROGRESS_KIND,
|
||||
content: JSON.stringify(progressContent),
|
||||
tags,
|
||||
created_at: now
|
||||
}))
|
||||
|
||||
console.log('[progress] ✍️ Signing event...')
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
// Use unified write service
|
||||
|
||||
console.log('[progress] 📡 Publishing event:', {
|
||||
id: signed.id,
|
||||
kind: signed.kind,
|
||||
pubkey: signed.pubkey.slice(0, 8) + '...',
|
||||
content: signed.content,
|
||||
tags: signed.tags
|
||||
})
|
||||
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
||||
|
||||
console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr
|
||||
* Load reading position from Nostr (kind 39802)
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
@@ -89,32 +177,32 @@ export async function loadReadingPosition(
|
||||
pubkey: string,
|
||||
articleIdentifier: string
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
console.log('📖 [ReadingPosition] Loading position:', {
|
||||
console.log('📖 [ReadingProgress] Loading position:', {
|
||||
pubkey: pubkey.slice(0, 8) + '...',
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
dTag: dTag.slice(0, 50) + '...'
|
||||
})
|
||||
|
||||
// First, check if we already have the position in the local event store
|
||||
// Check local event store first
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingPositionContent(localEvent)
|
||||
const content = getReadingProgressContent(localEvent)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
||||
console.log('✅ [ReadingProgress] Loaded from local store:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
// Fetch from relays in background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
@@ -125,23 +213,49 @@ export async function loadReadingPosition(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached reading position found, fetching from relays...')
|
||||
console.log('📭 No cached reading progress found, fetching from relays...')
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
// Fetch from relays
|
||||
const result = await fetchFromRelays(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
READING_PROGRESS_KIND,
|
||||
dTag,
|
||||
getReadingProgressContent
|
||||
)
|
||||
|
||||
if (result) {
|
||||
console.log('✅ [ReadingProgress] Loaded from relays')
|
||||
return result
|
||||
}
|
||||
|
||||
console.log('📭 No reading progress found')
|
||||
return null
|
||||
}
|
||||
|
||||
// Helper function to fetch from relays with timeout
|
||||
async function fetchFromRelays(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
dTag: string,
|
||||
parser: (event: NostrEvent) => ReadingPosition | undefined
|
||||
): Promise<ReadingPosition | null> {
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.log('⏱️ Reading position load timeout - no position found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 3000) // Shorter timeout for reading positions
|
||||
}, 3000)
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
@@ -153,33 +267,20 @@ export async function loadReadingPosition(
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
eventStore.replaceable(kind, pubkey, dTag)
|
||||
)
|
||||
if (event) {
|
||||
const content = getReadingPositionContent(event)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from relays:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
resolve(content)
|
||||
} else {
|
||||
console.log('⚠️ [ReadingPosition] Event found but no valid content')
|
||||
resolve(null)
|
||||
}
|
||||
const content = parser(event)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
console.log('📭 [ReadingPosition] No position found on relays')
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading reading position:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('❌ Reading position subscription error:', err)
|
||||
error: () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
|
||||
299
src/services/readingProgressController.ts
Normal file
299
src/services/readingProgressController.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Filter, 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'
|
||||
|
||||
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
|
||||
/**
|
||||
* Shared reading progress controller
|
||||
* Manages the user's reading progress (kind:39802) centrally
|
||||
*/
|
||||
class ReadingProgressController {
|
||||
private progressListeners: ProgressMapCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentProgressMap: Map<string, number> = new Map()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||
|
||||
onProgress(cb: ProgressMapCallback): () => void {
|
||||
this.progressListeners.push(cb)
|
||||
return () => {
|
||||
this.progressListeners = this.progressListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitProgress(progressMap: Map<string, number>): void {
|
||||
console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items')
|
||||
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reading progress map without triggering a reload
|
||||
*/
|
||||
getProgressMap(): Map<string, number> {
|
||||
return new Map(this.currentProgressMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached progress from localStorage for a pubkey
|
||||
*/
|
||||
private loadCachedProgress(pubkey: string): Map<string, number> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
|
||||
if (!raw) return new Map()
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, number>>
|
||||
const forUser = parsed[pubkey] || {}
|
||||
return new Map(Object.entries(forUser))
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current progress map to localStorage for the active pubkey
|
||||
*/
|
||||
private persistProgress(pubkey: string, progressMap: Map<string, number>): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
|
||||
const parsed: Record<string, Record<string, number>> = raw ? JSON.parse(raw) : {}
|
||||
parsed[pubkey] = Object.fromEntries(progressMap.entries())
|
||||
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to persist reading progress cache:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific article by naddr
|
||||
*/
|
||||
getProgress(naddr: string): number | undefined {
|
||||
return this.currentProgressMap.get(naddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reading progress is loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.generation++
|
||||
// Unsubscribe from any active timeline subscription
|
||||
if (this.timelineSubscription) {
|
||||
try {
|
||||
this.timelineSubscription.unsubscribe()
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err)
|
||||
}
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
this.currentProgressMap = new Map()
|
||||
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
|
||||
*/
|
||||
private updateLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('Failed to update last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and watch reading progress for a user
|
||||
*/
|
||||
async start(params: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, pubkey, force = false } = params
|
||||
const startGeneration = this.generation
|
||||
|
||||
// Skip if already loaded for this pubkey and not forcing
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('📊 [ReadingProgress] Already loaded for', pubkey.slice(0, 8))
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '')
|
||||
|
||||
this.setLoading(true)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
|
||||
try {
|
||||
// Seed from local cache immediately (survives refresh/flight mode)
|
||||
const cached = this.loadCachedProgress(pubkey)
|
||||
if (cached.size > 0) {
|
||||
console.log('📊 [ReadingProgress] Seeded from cache:', cached.size, 'items')
|
||||
this.currentProgressMap = cached
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
}
|
||||
|
||||
// Subscribe to local timeline for immediate and reactive updates
|
||||
// Clean up any previous subscription first
|
||||
if (this.timelineSubscription) {
|
||||
try {
|
||||
this.timelineSubscription.unsubscribe()
|
||||
} catch (err) {
|
||||
console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err)
|
||||
}
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
|
||||
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
|
||||
console.log('📊 [ReadingProgress] Timeline update with', localEvents.length, 'event(s)')
|
||||
this.processEvents(localEvents)
|
||||
})
|
||||
|
||||
// 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 = {
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
}
|
||||
|
||||
if (lastSynced && !needsFullSync) {
|
||||
filter.since = lastSynced
|
||||
console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString())
|
||||
} else {
|
||||
console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')')
|
||||
}
|
||||
|
||||
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
|
||||
|
||||
if (startGeneration !== this.generation) {
|
||||
console.log('📊 [ReadingProgress] Cancelled (generation changed)')
|
||||
return
|
||||
}
|
||||
|
||||
if (relayEvents.length > 0) {
|
||||
// Add to event store
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
|
||||
// Process and emit (merge with existing)
|
||||
this.processEvents(relayEvents)
|
||||
console.log('📊 [ReadingProgress] Loaded', relayEvents.length, 'events from relays')
|
||||
|
||||
// Update last synced
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.updateLastSyncedAt(pubkey, now)
|
||||
} else {
|
||||
console.log('📊 [ReadingProgress] No new events from relays')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('📊 [ReadingProgress] Failed to load:', err)
|
||||
} finally {
|
||||
if (startGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process events and update progress map
|
||||
*/
|
||||
private processEvents(events: NostrEvent[]): void {
|
||||
console.log('[progress] 🔄 Processing', events.length, 'events')
|
||||
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
// Merge with existing progress
|
||||
for (const [id, progress] of this.currentProgressMap.entries()) {
|
||||
readsMap.set(id, {
|
||||
id,
|
||||
source: 'reading-progress',
|
||||
type: 'article',
|
||||
readingProgress: progress
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[progress] 📦 Starting with', readsMap.size, 'existing items')
|
||||
|
||||
// Process new events
|
||||
processReadingProgress(events, readsMap)
|
||||
|
||||
console.log('[progress] 📦 After processing:', readsMap.size, 'items')
|
||||
|
||||
// Convert back to progress map (naddr -> progress)
|
||||
const newProgressMap = new Map<string, number>()
|
||||
for (const [id, item] of readsMap.entries()) {
|
||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
||||
newProgressMap.set(id, item.readingProgress)
|
||||
console.log('[progress] ✅ Added:', id.slice(0, 50) + '...', '=', Math.round(item.readingProgress * 100) + '%')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[progress] 📊 Final progress map size:', newProgressMap.size)
|
||||
|
||||
this.currentProgressMap = newProgressMap
|
||||
this.emitProgress(this.currentProgressMap)
|
||||
|
||||
// Persist for current user so it survives refresh/flight mode
|
||||
if (this.lastLoadedPubkey) {
|
||||
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readingProgressController = new ReadingProgressController()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
@@ -37,7 +37,7 @@ export interface ReadItem {
|
||||
/**
|
||||
* Fetches all reads from multiple sources:
|
||||
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||
* - Articles/URLs with reading progress (kind:30078)
|
||||
* - Articles/URLs with reading progress (kind:39802)
|
||||
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||
*/
|
||||
export async function fetchAllReads(
|
||||
@@ -61,19 +61,19 @@ export async function fetchAllReads(
|
||||
|
||||
try {
|
||||
// Fetch all data sources in parallel
|
||||
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
console.log('📊 [Reads] Data fetched:', {
|
||||
readingPositions: readingPositionEvents.length,
|
||||
readingProgress: progressEvents.length,
|
||||
markedAsRead: markedAsReadArticles.length,
|
||||
bookmarks: bookmarks.length
|
||||
})
|
||||
|
||||
// Process reading positions and emit items
|
||||
processReadingPositions(readingPositionEvents, readsMap)
|
||||
// Process reading progress events (kind 39802)
|
||||
processReadingProgress(progressEvents, readsMap)
|
||||
if (onItem) {
|
||||
readsMap.forEach(item => {
|
||||
if (item.type === 'article') onItem(item)
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface UserSettings {
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -14,9 +14,12 @@ export async function publishEvent(
|
||||
eventStore: IEventStore,
|
||||
event: NostrEvent
|
||||
): Promise<void> {
|
||||
const isProgressEvent = event.kind === 39802
|
||||
const logPrefix = isProgressEvent ? '[progress]' : ''
|
||||
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(event)
|
||||
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
|
||||
console.log(`${logPrefix} 💾 Stored event in EventStore:`, event.id.slice(0, 8), `(kind ${event.kind})`)
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
@@ -32,12 +35,13 @@ export async function publishEvent(
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Event relay status:', {
|
||||
console.log(`${logPrefix} 📍 Event relay status:`, {
|
||||
targetRelays: RELAYS.length,
|
||||
expectedSuccessRelays: expectedSuccessRelays.length,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: event.id.slice(0, 8)
|
||||
eventId: event.id.slice(0, 8),
|
||||
connectedRelays: connectedRelays.length
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
@@ -46,12 +50,13 @@ export async function publishEvent(
|
||||
}
|
||||
|
||||
// Publish to all configured relays in the background (non-blocking)
|
||||
console.log(`${logPrefix} 📤 Publishing to relays:`, RELAYS)
|
||||
relayPool.publish(RELAYS, event)
|
||||
.then(() => {
|
||||
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
||||
console.log(`${logPrefix} ✅ Event published to`, RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
||||
console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)
|
||||
|
||||
// Surface common bunker signing errors for debugging
|
||||
if (error instanceof Error && error.message.includes('permission')) {
|
||||
|
||||
250
src/services/writingsController.ts
Normal file
250
src/services/writingsController.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||
|
||||
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'writings_last_synced'
|
||||
|
||||
/**
|
||||
* Shared writings controller
|
||||
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
|
||||
* similar to highlightsController
|
||||
*/
|
||||
class WritingsController {
|
||||
private writingsListeners: WritingsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentPosts: BlogPostPreview[] = []
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
|
||||
onWritings(cb: WritingsCallback): () => void {
|
||||
this.writingsListeners.push(cb)
|
||||
return () => {
|
||||
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitWritings(posts: BlogPostPreview[]): void {
|
||||
this.writingsListeners.forEach(cb => cb(posts))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current writings without triggering a reload
|
||||
*/
|
||||
getWritings(): BlogPostPreview[] {
|
||||
return [...this.currentPosts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if writings are loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey && this.currentPosts.length >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.generation++
|
||||
this.currentPosts = []
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitWritings(this.currentPosts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[writings] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
|
||||
*/
|
||||
private toPreview(event: NostrEvent): BlogPostPreview {
|
||||
return {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort posts by published/created date (most recent first)
|
||||
*/
|
||||
private sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||
return posts.slice().sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load writings for a user (kind:30023)
|
||||
* Streams results and stores in event store
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, pubkey, force = false } = options
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitWritings(this.currentPosts)
|
||||
return
|
||||
}
|
||||
|
||||
// Increment generation to cancel any in-flight work
|
||||
this.generation++
|
||||
const currentGeneration = this.generation
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
kinds: [KINDS.BlogPost],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
filter,
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
// Check if this generation is still active
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
if (seenIds.has(evt.id)) return
|
||||
seenIds.add(evt.id)
|
||||
|
||||
// Store in event store immediately
|
||||
eventStore.add(evt)
|
||||
|
||||
// Dedupe by replaceable key (author + d-tag)
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${evt.pubkey}:${dTag}`
|
||||
|
||||
const preview = this.toPreview(evt)
|
||||
const existing = uniqueByReplaceable.get(key)
|
||||
|
||||
// Keep the newest version for replaceable events
|
||||
if (!existing || evt.created_at > existing.event.created_at) {
|
||||
uniqueByReplaceable.set(key, preview)
|
||||
|
||||
// Stream to listeners
|
||||
const sortedPosts = this.sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||
this.currentPosts = sortedPosts
|
||||
this.emitWritings(sortedPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Check if still active after async operation
|
||||
if (currentGeneration !== this.generation) {
|
||||
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
|
||||
return
|
||||
}
|
||||
|
||||
// Store all events in event store
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
// Final processing - ensure we have the latest version of each replaceable
|
||||
events.forEach(evt => {
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${evt.pubkey}:${dTag}`
|
||||
const existing = uniqueByReplaceable.get(key)
|
||||
|
||||
if (!existing || evt.created_at > existing.event.created_at) {
|
||||
uniqueByReplaceable.set(key, this.toPreview(evt))
|
||||
}
|
||||
})
|
||||
|
||||
const sorted = this.sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||
|
||||
this.currentPosts = sorted
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitWritings(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
|
||||
} catch (error) {
|
||||
console.error('[writings] ❌ Failed to load writings:', error)
|
||||
this.currentPosts = []
|
||||
this.emitWritings(this.currentPosts)
|
||||
} finally {
|
||||
// Only clear loading if this generation is still active
|
||||
if (currentGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const writingsController = new WritingsController()
|
||||
|
||||
15
src/utils/toBlogPostPreview.ts
Normal file
15
src/utils/toBlogPostPreview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export const toBlogPostPreview = (event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user