mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29746f1042 | ||
|
|
829ec4bf6e | ||
|
|
30ae0d9dfb | ||
|
|
8924f1b307 | ||
|
|
f92fa2cc93 | ||
|
|
cc70b533e5 | ||
|
|
003c439658 | ||
|
|
019958073c | ||
|
|
3d47dddbd2 | ||
|
|
cabf897df8 | ||
|
|
4801c0d621 | ||
|
|
ae76d6e4ea | ||
|
|
a611e99ff6 | ||
|
|
1c039e164f | ||
|
|
ffa4b38106 | ||
|
|
3b22cb5c5d | ||
|
|
7bc4522be4 | ||
|
|
048e0d802b | ||
|
|
b282bc4972 | ||
|
|
c1a23c1f8f | ||
|
|
8a5aacfe7b | ||
|
|
9126910de5 | ||
|
|
496bbc36f4 | ||
|
|
90f25420b2 | ||
|
|
9167134a89 | ||
|
|
b5717f1ebf | ||
|
|
0c8eaaf220 | ||
|
|
80b2720838 | ||
|
|
ea69740fc8 | ||
|
|
d650997ff9 | ||
|
|
ba3554b173 | ||
|
|
2cc39d0200 | ||
|
|
9aa914a704 | ||
|
|
497b6fa4be | ||
|
|
4c838b0123 | ||
|
|
d551f66ef1 | ||
|
|
34514199ee | ||
|
|
228304f68a | ||
|
|
ba263acdff | ||
|
|
5131cbe12c | ||
|
|
fa8eed4f4e | ||
|
|
3ff57c4b67 | ||
|
|
51c364ea53 | ||
|
|
4d032372dc | ||
|
|
48b5aa3a30 | ||
|
|
d4483a2f91 | ||
|
|
c62cb21962 | ||
|
|
3f7d726ae6 | ||
|
|
ac0e5eb585 | ||
|
|
5a0dd49e4e | ||
|
|
d067193f21 | ||
|
|
774e2ba67c | ||
|
|
6f1c31058f | ||
|
|
7551a05aee | ||
|
|
df485b883d | ||
|
|
6f428af1bc | ||
|
|
e821aaf058 | ||
|
|
a84d439489 | ||
|
|
67bf7e017d | ||
|
|
e47419a0b8 | ||
|
|
2dda52c30f | ||
|
|
2e0a493243 | ||
|
|
2e955e9bed | ||
|
|
538cbd2296 | ||
|
|
c17eab5a47 | ||
|
|
b3c61ba635 | ||
|
|
3bfa750a0c | ||
|
|
d1f7e549c2 | ||
|
|
0fec120410 | ||
|
|
9b21075a9b | ||
|
|
4f78ee4794 | ||
|
|
8bb871913b | ||
|
|
49eb6855ca | ||
|
|
748b2e1631 | ||
|
|
9fa83a2a1c | ||
|
|
d45705e8e4 | ||
|
|
83c170b4e2 | ||
|
|
8459853c43 | ||
|
|
f7eeb080e1 | ||
|
|
2769b2dba7 | ||
|
|
46636b8e6a | ||
|
|
92a85761ef | ||
|
|
f6a325f7e9 | ||
|
|
a501fa816f | ||
|
|
5ece80b8e9 | ||
|
|
87c017b2c2 | ||
|
|
550ee415f0 | ||
|
|
aaaf226623 | ||
|
|
23ce0c9d4c | ||
|
|
dddf8575c4 | ||
|
|
3ab0610e1e | ||
|
|
e40f820fdc | ||
|
|
3f82bc7873 | ||
|
|
b913cc4d7f | ||
|
|
bc1aed30b4 | ||
|
|
9a801975aa | ||
|
|
f3e44edd51 | ||
|
|
0be6aa81ce | ||
|
|
c7b885cfcd | ||
|
|
11041df1fb | ||
|
|
89273e2a03 | ||
|
|
0610454e74 | ||
|
|
a02413a7cb | ||
|
|
0bc84e7c6c | ||
|
|
a1e28c6bc9 | ||
|
|
a1a7f0e4a4 | ||
|
|
cde8e30ab2 | ||
|
|
aa7e532950 | ||
|
|
c9208cfff2 | ||
|
|
2fb4132342 | ||
|
|
81180c8ba8 | ||
|
|
1c48adf44e |
@@ -2,4 +2,4 @@
|
|||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
Keep files below 210 lines.
|
Keep files below 420 lines.
|
||||||
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: fetching data from relays
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
We fetch data from relays using controllers:
|
||||||
|
- Start controllers immediatly; don’t await.
|
||||||
|
- Stream via onEvent; dedupe replaceables; emit immediately.
|
||||||
|
- Parallel local/remote queries; complete on EOSE.
|
||||||
|
- Finalize and persist since after completion.
|
||||||
|
- Guard with generations to cancel stale runs.
|
||||||
|
- UI flips off loading on first streamed result.
|
||||||
|
|
||||||
|
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate local‑only mode for writes (queueing for later).
|
||||||
|
|
||||||
|
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
|
||||||
|
|
||||||
|
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.
|
||||||
212
CHANGELOG.md
212
CHANGELOG.md
@@ -7,6 +7,208 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Comprehensive debug logging for reading position system
|
||||||
|
- All position restore, save, and suppression events logged with `[reading-position]` prefix
|
||||||
|
- Emoji indicators for easy visual scanning (🎯 restore, 💾 save, 🛡️ suppression, etc.)
|
||||||
|
- Detailed metrics for troubleshooting scroll behavior
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reading position auto-save now uses simple 3-second debounce
|
||||||
|
- Saves only after 3s of no scrolling (was 15s minimum interval)
|
||||||
|
- Much less aggressive, reduces relay traffic
|
||||||
|
- Still saves instantly at 100% completion
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reading position restore no longer causes jumpy scrolling
|
||||||
|
- Stabilized position collector buffers updates for ~700ms, then applies best one (newest timestamp, tie-break by highest progress)
|
||||||
|
- Auto-saves suppressed for 1.5s after programmatic restore to prevent feedback loops
|
||||||
|
- Tiny scroll deltas (<48px or <5%) ignored to avoid unnecessary movement
|
||||||
|
- Instant scroll (behavior: auto) instead of smooth animation reduces perceived oscillation
|
||||||
|
- Fixes jumpy behavior from conflicting relay updates and save-restore loops
|
||||||
|
|
||||||
|
## [0.10.14] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Third relay education article link in PWA settings
|
||||||
|
- Added "Relay Setup 101" article to relay information section
|
||||||
|
- Now links to three educational resources about relays
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Timestamp links in bookmark cards now navigate within app
|
||||||
|
- Articles (kind:30023) open in `/a/{naddr}` route
|
||||||
|
- Notes (kind:1) open in `/e/{eventId}` route
|
||||||
|
- External URLs open in `/r/{encodedUrl}` route
|
||||||
|
- Uses React Router Link for client-side navigation instead of external search
|
||||||
|
- Relay article links punctuation improved for better readability
|
||||||
|
- Changed from "here and here" to "here, here, and here"
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Duplicate video embeds and stray HTML artifacts eliminated
|
||||||
|
- VideoEmbedProcessor now processes HTML and extracts URLs in single pass
|
||||||
|
- Placeholder indices now correctly match collected video URLs
|
||||||
|
- Empty HTML parts no longer rendered, preventing stray characters like `">`
|
||||||
|
- Highlights loading spinner no longer spins forever when article has zero highlights
|
||||||
|
- Loading state properly cleared when no highlights exist
|
||||||
|
- "No highlights" message displays immediately
|
||||||
|
|
||||||
|
## [0.10.13] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Instant article preview when navigating from blog post cards
|
||||||
|
- Title, image, summary, and date display immediately via navigation state
|
||||||
|
- No skeleton loading for metadata already visible on cards
|
||||||
|
- Article content loads seamlessly in background from eventStore or relays
|
||||||
|
- Reliable relay fallback for article fetching
|
||||||
|
- Queries nostr.band, primal, damus, and nos.lol if initial fetch returns no events
|
||||||
|
- Reduces "Article not found" errors
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article loading now follows local-first controller pattern
|
||||||
|
- Uses eventStore and queryEvents for streaming results
|
||||||
|
- Emits content immediately on first event from store or local relays
|
||||||
|
- Finalizes with newest version after EOSE (no artificial timeouts)
|
||||||
|
- Background relay query continues to check for updates
|
||||||
|
- Service Worker now only registers in production builds
|
||||||
|
- Disabled in development to avoid stale cache issues
|
||||||
|
- Preserves PWA functionality in production
|
||||||
|
- Article fetching queries union of naddr relay hints and configured relays
|
||||||
|
- Prevents failures when naddr contains stale or unreachable relay hints
|
||||||
|
- Maintains fast local/hinted paths with reliable fallback
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Article loading race conditions eliminated
|
||||||
|
- Request ID guards prevent stale fetches from overwriting current content
|
||||||
|
- Stale highlights from previous articles no longer appear
|
||||||
|
- Content/title mismatch when switching articles resolved
|
||||||
|
- Markdown preview clears immediately on content change
|
||||||
|
- Forced re-mount of rendered HTML per article via stable content keys
|
||||||
|
- Request guards in external URL loader prevent cross-article bleed
|
||||||
|
- Article re-fetching on settings changes prevented
|
||||||
|
- Settings memoized via ref to avoid triggering effect dependencies
|
||||||
|
- Explore writings tab now shows skeletons instead of spinner when loading
|
||||||
|
- Consistent loading UI across all views
|
||||||
|
|
||||||
|
## [0.10.12] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Person hiking icon (fa-person-hiking) for explore navigation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Explore icon changed from newspaper to person hiking for better semantic meaning
|
||||||
|
- Settings button moved before explore button in sidebar navigation
|
||||||
|
- Profile avatar button now uses 44px touch target on mobile (matches other icon buttons)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web bookmarks (kind:39701) now properly deduplicate by d-tag
|
||||||
|
- Same URL bookmarked multiple times now only appears once
|
||||||
|
- Web bookmark IDs use coordinate format (kind:pubkey:d-tag) for consistent deduplication
|
||||||
|
- Profile avatar button sizing on mobile now matches other IconButton components
|
||||||
|
- Removed all console.log statements from bookmarkController and bookmarkProcessing
|
||||||
|
|
||||||
|
## [0.10.11] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Clock icon for chronological bookmark view
|
||||||
|
- Clickable highlight count to open highlights sidebar
|
||||||
|
- Dynamic bookmark filter titles based on selected filter
|
||||||
|
- Profile picture moved to first position (left-aligned) with consistent sizing
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Default bookmark view changed to flat chronological list (newest first)
|
||||||
|
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
|
||||||
|
- Router updated to handle `/me/reading-list` → `/me/bookmarks` redirect
|
||||||
|
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
|
||||||
|
- Me.tsx updated to use faClock icon instead of faBars
|
||||||
|
- Removed bookmark count from section headings for cleaner display
|
||||||
|
- Hide close/collapse sidebar buttons on mobile for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bookmark sorting now uses proper display time (created_at || listUpdatedAt) with nulls last
|
||||||
|
- Robust sorting of merged bookmarks with fallback timestamps
|
||||||
|
- Corrected bookmark timestamp to use bookmark list creation time, not content creation time
|
||||||
|
- Preserved content created_at while adding listUpdatedAt for proper sorting
|
||||||
|
- Removed synthetic added_at field, now uses created_at from bookmark list event
|
||||||
|
- Consistent chronological sorting with useMemo optimization
|
||||||
|
- Removed unused faTimes import
|
||||||
|
- Bookmark timestamps now show sane dates using created_at fallback to listUpdatedAt
|
||||||
|
- Guarded formatters to prevent timestamp display errors
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Removed excessive debug logging for cleaner console output
|
||||||
|
- Bookmark timestamp handling never defaults to "now", allows nulls and sorts nulls last
|
||||||
|
- Renders empty when timestamp is missing instead of showing invalid dates
|
||||||
|
|
||||||
|
## [0.10.10] - 2025-10-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Version bump for consistency (no user-facing changes)
|
||||||
|
|
||||||
|
## [0.10.9] - 2025-10-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Event fetching reliability with exponential backoff in eventManager
|
||||||
|
- Improved retry logic with incremental backoff delays
|
||||||
|
- Better handling of concurrent event requests
|
||||||
|
- More robust event retrieval from relay pool
|
||||||
|
- Bookmark timestamp handling
|
||||||
|
- Use per-item `added_at`/`created_at` timestamps when available
|
||||||
|
- Improves accuracy of bookmark date tracking
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed all debug console logs
|
||||||
|
- Cleaner console output in development and production
|
||||||
|
- Improved performance by eliminating debugging statements
|
||||||
|
|
||||||
|
## [0.10.8] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Individual event rendering via `/e/:eventId` path
|
||||||
|
- Display `kind:1` notes and other events with article-like presentation
|
||||||
|
- Publication date displayed in top-right corner like articles
|
||||||
|
- Author attribution with "Note by @author" titles
|
||||||
|
- Direct event loading with intelligent caching from eventStore
|
||||||
|
- Centralized event fetching via new `eventManager` singleton
|
||||||
|
- Request deduplication for concurrent fetches
|
||||||
|
- Automatic retry logic when relay pool becomes available
|
||||||
|
- Non-blocking background fetching with 12-second timeout
|
||||||
|
- Seamless integration with eventStore for instant cached event display
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bookmark hydration efficiency
|
||||||
|
- Only request content for bookmarks missing data (not all bookmarks)
|
||||||
|
- Use eventStore fallback for instant display of cached profiles
|
||||||
|
- Prevents over-fetching and improves initial load performance
|
||||||
|
- Search button behavior for notes
|
||||||
|
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
|
||||||
|
- Articles continue to use search portal with proper naddr encoding
|
||||||
|
- Removes unwanted `nostr-event:` prefix from URLs
|
||||||
|
- Author profile resolution
|
||||||
|
- Fetch author profiles from eventStore cache first before relay requests
|
||||||
|
- Instant title updates if profile already loaded
|
||||||
|
- Graceful fallback to short pubkey display if profile unavailable
|
||||||
|
|
||||||
## [0.10.7] - 2025-10-21
|
## [0.10.7] - 2025-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -2388,7 +2590,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.12...HEAD
|
||||||
|
[0.10.12]: https://github.com/dergigi/boris/compare/v0.10.11...v0.10.12
|
||||||
|
[0.10.11]: https://github.com/dergigi/boris/compare/v0.10.10...v0.10.11
|
||||||
|
[0.10.10]: https://github.com/dergigi/boris/compare/v0.10.9...v0.10.10
|
||||||
|
[0.10.9]: https://github.com/dergigi/boris/compare/v0.10.8...v0.10.9
|
||||||
|
[0.10.8]: https://github.com/dergigi/boris/compare/v0.10.7...v0.10.8
|
||||||
|
[0.10.7]: https://github.com/dergigi/boris/compare/v0.10.6...v0.10.7
|
||||||
|
[0.10.6]: https://github.com/dergigi/boris/compare/v0.10.5...v0.10.6
|
||||||
|
[0.10.5]: https://github.com/dergigi/boris/compare/v0.10.4...v0.10.5
|
||||||
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
|
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
|
||||||
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
|
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
|
||||||
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.5",
|
"version": "0.10.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.5",
|
"version": "0.10.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.7",
|
"version": "0.10.15",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reading-list"
|
path="/me/bookmarks"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -578,8 +578,6 @@ function App() {
|
|||||||
|
|
||||||
// Handle user relay list and blocked relays when account changes
|
// Handle user relay list and blocked relays when account changes
|
||||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||||
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
|
|
||||||
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
|
|
||||||
if (account) {
|
if (account) {
|
||||||
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||||
const pubkey = account.pubkey
|
const pubkey = account.pubkey
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
|
state={{
|
||||||
|
previewData: {
|
||||||
|
title: post.title,
|
||||||
|
image: post.image,
|
||||||
|
summary: post.summary,
|
||||||
|
published: post.published
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
import { IndividualBookmark } from '../types/bookmarks'
|
import { IndividualBookmark } from '../types/bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl } from '../utils/helpers'
|
||||||
@@ -58,8 +58,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
// Resolve author profile using applesauce
|
// Resolve author profile using applesauce
|
||||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||||
const authorNpub = npubEncode(bookmark.pubkey)
|
const authorNpub = npubEncode(bookmark.pubkey)
|
||||||
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
|
|
||||||
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
|
|
||||||
|
|
||||||
// Get display name for author
|
// Get display name for author
|
||||||
const getAuthorDisplayName = () => {
|
const getAuthorDisplayName = () => {
|
||||||
@@ -135,7 +133,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
@@ -152,7 +149,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useRef, useState, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -13,7 +14,7 @@ import { ViewMode } from './Bookmarks'
|
|||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { BookmarkSkeleton } from './Skeletons'
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
return saved === 'flat' ? 'flat' : 'grouped'
|
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||||
})
|
})
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
@@ -120,6 +121,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||||
|
const titles: Record<BookmarkFilterType, string> = {
|
||||||
|
'all': 'All Bookmarks',
|
||||||
|
'article': 'Bookmarked Reads',
|
||||||
|
'external': 'Bookmarked Links',
|
||||||
|
'video': 'Bookmarked Videos',
|
||||||
|
'note': 'Bookmarked Notes',
|
||||||
|
'web': 'Web Bookmarks'
|
||||||
|
}
|
||||||
|
return titles[filter]
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
if (!activeAccount || !relayPool) {
|
if (!activeAccount || !relayPool) {
|
||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isDisabled: !onRefresh
|
isDisabled: !onRefresh
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const sections = useMemo(() => {
|
||||||
.filter(hasContent)
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
|
|
||||||
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
|
// Group non-set bookmarks by source or flatten based on mode
|
||||||
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
|
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
|
groupingMode === 'flat'
|
||||||
|
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||||
|
: [
|
||||||
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||||
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add bookmark sets as additional sections (only in grouped mode)
|
||||||
|
if (groupingMode === 'grouped') {
|
||||||
|
bookmarkSets.forEach(set => {
|
||||||
|
sectionsArray.push({
|
||||||
|
key: `set-${set.name}`,
|
||||||
|
title: set.title || set.name,
|
||||||
|
items: set.bookmarks
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectionsArray
|
||||||
|
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||||
|
|
||||||
// Apply filter
|
// Get all filtered bookmarks for empty state checks
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
const allIndividualBookmarks = useMemo(() =>
|
||||||
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||||
|
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||||
|
)
|
||||||
|
|
||||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
const filteredBookmarks = useMemo(() =>
|
||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
[allIndividualBookmarks, selectedFilter]
|
||||||
|
)
|
||||||
// Group non-set bookmarks by source or flatten based on mode
|
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
|
||||||
groupingMode === 'flat'
|
|
||||||
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
|
||||||
: [
|
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
|
||||||
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
|
||||||
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add bookmark sets as additional sections
|
|
||||||
bookmarkSets.forEach(set => {
|
|
||||||
sections.push({
|
|
||||||
key: `set-${set.name}`,
|
|
||||||
title: set.title || set.name,
|
|
||||||
items: set.bookmarks
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -286,7 +318,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div className="view-mode-right">
|
<div className="view-mode-right">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
onClick={toggleGroupingMode}
|
onClick={toggleGroupingMode}
|
||||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import RichContent from '../RichContent'
|
|||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -18,7 +18,6 @@ interface CardViewProps {
|
|||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
@@ -34,7 +33,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
@@ -82,6 +80,29 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get internal route for the bookmark
|
||||||
|
const getInternalRoute = (): string | null => {
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
// Nostr-native article - use /a/ route
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = naddrEncode({
|
||||||
|
kind: bookmark.kind,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
} else if (bookmark.kind === 1) {
|
||||||
|
// Note - use /e/ route
|
||||||
|
return `/e/${bookmark.id}`
|
||||||
|
} else if (firstUrl) {
|
||||||
|
// External URL - use /r/ route
|
||||||
|
return `/r/${encodeURIComponent(firstUrl)}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${bookmark.id}-${index}`}
|
key={`${bookmark.id}-${index}`}
|
||||||
@@ -103,19 +124,17 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent ? (
|
{getInternalRoute() ? (
|
||||||
<a
|
<Link
|
||||||
href={getEventUrl(eventNevent)}
|
to={getInternalRoute()!}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
title="Open event in search"
|
title="Open in app"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<code>{bookmark.id.slice(0, 12)}...</code>
|
<code>{bookmark.id.slice(0, 12)}...</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { formatDate } from '../../utils/bookmarkUtils'
|
|||||||
import RichContent from '../RichContent'
|
import RichContent from '../RichContent'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -18,7 +18,6 @@ interface LargeViewProps {
|
|||||||
getIconForUrlType: IconGetter
|
getIconForUrlType: IconGetter
|
||||||
previewImage: string | null
|
previewImage: string | null
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
@@ -35,7 +34,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getIconForUrlType,
|
getIconForUrlType,
|
||||||
previewImage,
|
previewImage,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
@@ -63,6 +61,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get internal route for the bookmark
|
||||||
|
const getInternalRoute = (): string | null => {
|
||||||
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
// Nostr-native article - use /a/ route
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = naddrEncode({
|
||||||
|
kind: bookmark.kind,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
} else if (bookmark.kind === 1) {
|
||||||
|
// Note - use /e/ route
|
||||||
|
return `/e/${bookmark.id}`
|
||||||
|
} else if (firstUrl) {
|
||||||
|
// External URL - use /r/ route
|
||||||
|
return `/r/${encodeURIComponent(firstUrl)}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${bookmark.id}-${index}`}
|
key={`${bookmark.id}-${index}`}
|
||||||
@@ -136,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent && (
|
{getInternalRoute() ? (
|
||||||
<a
|
<Link
|
||||||
href={getEventUrl(eventNevent)}
|
to={getInternalRoute()!}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
|
title="Open in app"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
</a>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
// Extract tab from me routes
|
// Extract tab from me routes
|
||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/bookmarks' ? 'bookmarks' :
|
||||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
location.pathname.startsWith('/me/links') ? 'links' :
|
location.pathname.startsWith('/me/links') ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
@@ -230,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
useArticleLoader({
|
useArticleLoader({
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ import { EventFactory } from 'applesauce-factory'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import {
|
import {
|
||||||
generateArticleIdentifier,
|
generateArticleIdentifier,
|
||||||
loadReadingPosition,
|
saveReadingPosition
|
||||||
saveReadingPosition
|
|
||||||
} from '../services/readingPositionService'
|
} from '../services/readingPositionService'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import TTSControls from './TTSControls'
|
import TTSControls from './TTSControls'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
@@ -76,6 +76,7 @@ interface ContentPanelProps {
|
|||||||
// For reading progress indicator positioning
|
// For reading progress indicator positioning
|
||||||
isSidebarCollapsed?: boolean
|
isSidebarCollapsed?: boolean
|
||||||
isHighlightsCollapsed?: boolean
|
isHighlightsCollapsed?: boolean
|
||||||
|
onOpenHighlights?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -103,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
isSidebarCollapsed = false,
|
isSidebarCollapsed = false,
|
||||||
isHighlightsCollapsed = false
|
isHighlightsCollapsed = false,
|
||||||
|
onOpenHighlights
|
||||||
}) => {
|
}) => {
|
||||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
@@ -132,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
followedPubkeys
|
followedPubkeys
|
||||||
})
|
})
|
||||||
|
// Key used to force re-mount of markdown preview/render when content changes
|
||||||
|
const contentKey = useMemo(() => {
|
||||||
|
// Prefer selectedUrl as a stable per-article key; fallback to title+length
|
||||||
|
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
||||||
|
}, [selectedUrl, title, markdown, html])
|
||||||
|
|
||||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
@@ -143,8 +150,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Get event store for reading position service
|
// Get event store for reading position service
|
||||||
const eventStore = Hooks.useEventStore()
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
// Reading position tracking - only for text content, not videos
|
// Reading position tracking - only for text content that's loaded and long enough
|
||||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
// Wait for content to load, check it's not a video, and verify it's long enough to track
|
||||||
|
const isTextContent = useMemo(() => {
|
||||||
|
if (loading) return false
|
||||||
|
if (!markdown && !html) return false
|
||||||
|
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
|
||||||
|
if (!shouldTrackReadingProgress(html, markdown)) return false
|
||||||
|
return true
|
||||||
|
}, [loading, markdown, html, selectedUrl])
|
||||||
|
|
||||||
// Generate article identifier for saving/loading position
|
// Generate article identifier for saving/loading position
|
||||||
const articleIdentifier = useMemo(() => {
|
const articleIdentifier = useMemo(() => {
|
||||||
@@ -155,20 +169,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Callback to save reading position
|
// Callback to save reading position
|
||||||
const handleSavePosition = useCallback(async (position: number) => {
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('[reading-position] ❌ Cannot save: missing dependencies')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!settings?.syncReadingPosition) {
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('[reading-position] ⚠️ Save skipped: sync disabled in settings')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if content is long enough to track reading progress
|
// Check if content is long enough to track reading progress
|
||||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||||
|
console.log('[reading-position] ⚠️ Save skipped: content too short')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 🚀 Publishing position ${Math.round(position * 100)}% to relays...`)
|
||||||
const factory = new EventFactory({ signer: activeAccount })
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
await saveReadingPosition(
|
await saveReadingPosition(
|
||||||
relayPool,
|
relayPool,
|
||||||
@@ -181,13 +199,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
scrollTop
|
scrollTop
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||||
|
|
||||||
const { progressPercentage, saveNow } = useReadingPosition({
|
// Delay enabling position tracking to ensure content is stable
|
||||||
enabled: isTextContent,
|
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
|
||||||
|
|
||||||
|
// Reset tracking when article changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsTrackingEnabled(false)
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
// Enable tracking after content is stable
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTextContent && !isTrackingEnabled) {
|
||||||
|
// Wait 500ms after content loads before enabling tracking
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[reading-position] ✅ Enabling tracking after stability delay')
|
||||||
|
setIsTrackingEnabled(true)
|
||||||
|
}, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isTextContent, isTrackingEnabled])
|
||||||
|
|
||||||
|
const { progressPercentage, suppressSavesFor } = useReadingPosition({
|
||||||
|
enabled: isTrackingEnabled,
|
||||||
syncEnabled: settings?.syncReadingPosition !== false,
|
syncEnabled: settings?.syncReadingPosition !== false,
|
||||||
onSave: handleSavePosition,
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
@@ -207,59 +246,109 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||||
|
|
||||||
// Load saved reading position when article loads
|
// Load saved reading position when article loads (using pre-loaded data from controller)
|
||||||
|
const suppressSavesForRef = useRef(suppressSavesFor)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
suppressSavesForRef.current = suppressSavesFor
|
||||||
|
}, [suppressSavesFor])
|
||||||
|
|
||||||
|
// Track if we've successfully started restore for this article + tracking state
|
||||||
|
// Use a composite key to ensure we only restore once per article when tracking is enabled
|
||||||
|
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||||
|
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[reading-position] 🔍 Restore effect running:', {
|
||||||
|
isTextContent,
|
||||||
|
isTrackingEnabled,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
articleIdentifier,
|
||||||
|
restoreKey,
|
||||||
|
hasAttempted: hasAttemptedRestoreRef.current
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||||
|
console.log('[reading-position] ⏭️ Restore skipped: missing dependencies or not text content')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (settings?.syncReadingPosition === false) {
|
if (settings?.syncReadingPosition === false) {
|
||||||
|
console.log('[reading-position] ⏭️ Restore skipped: sync disabled in settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isTrackingEnabled) {
|
||||||
|
console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPosition = async () => {
|
// Only attempt restore once per article (after tracking is enabled)
|
||||||
try {
|
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||||
const savedPosition = await loadReadingPosition(
|
console.log('[reading-position] ⏭️ Restore skipped: already attempted for this article')
|
||||||
relayPool,
|
return
|
||||||
eventStore,
|
}
|
||||||
activeAccount.pubkey,
|
|
||||||
articleIdentifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
|
||||||
// Wait for content to be fully rendered before scrolling
|
// Mark as attempted using composite key
|
||||||
setTimeout(() => {
|
hasAttemptedRestoreRef.current = restoreKey
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
|
||||||
const windowHeight = window.innerHeight
|
// Get the saved position from the controller (already loaded and displayed on card)
|
||||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||||
|
|
||||||
window.scrollTo({
|
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||||
top: scrollTop,
|
console.log('[reading-position] ℹ️ No position to restore (progress:', savedProgress, ')')
|
||||||
behavior: 'smooth'
|
return
|
||||||
})
|
}
|
||||||
}, 500) // Give content time to render
|
|
||||||
} else if (savedPosition) {
|
console.log('[reading-position] 🎯 Found saved position:', Math.round(savedProgress * 100) + '%')
|
||||||
if (savedPosition.position === 1) {
|
|
||||||
// Article was completed, start from top
|
// Suppress saves during restore (500ms render + 1000ms animation + 500ms buffer = 2000ms)
|
||||||
} else {
|
if (suppressSavesForRef.current) {
|
||||||
// Position was too early, skip restore
|
suppressSavesForRef.current(2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for content to be fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
const docH = document.documentElement.scrollHeight
|
||||||
|
const winH = window.innerHeight
|
||||||
|
const maxScroll = Math.max(0, docH - winH)
|
||||||
|
const currentTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
const targetTop = savedProgress * maxScroll
|
||||||
|
|
||||||
|
console.log('[reading-position] 📐 Restore calculation:', {
|
||||||
|
docHeight: docH,
|
||||||
|
winHeight: winH,
|
||||||
|
maxScroll,
|
||||||
|
currentTop,
|
||||||
|
targetTop,
|
||||||
|
targetPercent: Math.round(savedProgress * 100) + '%'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Skip if delta is too small (< 48px or < 5%)
|
||||||
|
const deltaPx = Math.abs(targetTop - currentTop)
|
||||||
|
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
|
||||||
|
if (deltaPx < 48 || deltaPct < 0.05) {
|
||||||
|
console.log('[reading-position] ⏭️ Restore skipped: delta too small (', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||||
|
// Allow saves immediately since no scroll happened
|
||||||
|
if (suppressSavesForRef.current) {
|
||||||
|
suppressSavesForRef.current(0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return
|
||||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
loadPosition()
|
console.log('[reading-position] 📜 Restoring scroll position (delta:', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
|
||||||
|
|
||||||
// Save position before unmounting or changing article
|
// Perform smooth animated restore
|
||||||
useEffect(() => {
|
window.scrollTo({
|
||||||
return () => {
|
top: targetTop,
|
||||||
if (saveNow) {
|
behavior: 'smooth'
|
||||||
saveNow()
|
})
|
||||||
}
|
console.log('[reading-position] ✅ Scroll restored to', Math.round(savedProgress * 100) + '%')
|
||||||
}
|
}, 500) // Give content time to render
|
||||||
}, [saveNow, selectedUrl])
|
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||||
|
|
||||||
|
// Note: We intentionally do NOT save on unmount because:
|
||||||
|
// 1. Browser may scroll to top during back navigation, causing 0% saves
|
||||||
|
// 2. The auto-save with 3s debounce already captures position during reading
|
||||||
|
// 3. Position state may not reflect actual reading position during navigation
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -577,7 +666,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const handleSearchExternalUrl = () => {
|
const handleSearchExternalUrl = () => {
|
||||||
if (selectedUrl) {
|
if (selectedUrl) {
|
||||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||||
|
if (selectedUrl.startsWith('nostr-event:')) {
|
||||||
|
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||||
|
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||||
|
} else {
|
||||||
|
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowExternalMenu(false)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
@@ -754,7 +849,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
@@ -783,6 +878,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
highlights={relevantHighlights}
|
highlights={relevantHighlights}
|
||||||
highlightVisibility={highlightVisibility}
|
highlightVisibility={highlightVisibility}
|
||||||
|
onHighlightCountClick={onOpenHighlights}
|
||||||
/>
|
/>
|
||||||
{isTextContent && articleText && (
|
{isTextContent && articleText && (
|
||||||
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||||
@@ -874,6 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{markdown ? (
|
{markdown ? (
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
<VideoEmbedProcessor
|
<VideoEmbedProcessor
|
||||||
|
key={`content:${contentKey}`}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
html={finalHtml}
|
html={finalHtml}
|
||||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
@@ -890,6 +987,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<VideoEmbedProcessor
|
<VideoEmbedProcessor
|
||||||
|
key={`content:${contentKey}`}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
html={finalHtml || html || ''}
|
html={finalHtml || html || ''}
|
||||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
@@ -927,13 +1025,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<FontAwesomeIcon icon={faCopy} />
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
<span>Copy URL</span>
|
<span>Copy URL</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||||
className="article-menu-item"
|
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||||
onClick={handleOpenExternalUrl}
|
<button
|
||||||
>
|
className="article-menu-item"
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
onClick={handleOpenExternalUrl}
|
||||||
<span>Open Original</span>
|
>
|
||||||
</button>
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="article-menu-item"
|
className="article-menu-item"
|
||||||
onClick={handleSearchExternalUrl}
|
onClick={handleSearchExternalUrl}
|
||||||
|
|||||||
@@ -781,9 +781,16 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load deduplicated results via controller
|
// Load deduplicated results via controller (includes articles and external URLs)
|
||||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||||
setDeduplicatedProgressMap(new Map(progressMap))
|
setDeduplicatedProgressMap(new Map(progressMap))
|
||||||
|
|
||||||
|
// Regression guard: ensure keys include both naddr and raw URL forms when present
|
||||||
|
try {
|
||||||
|
const keys = Array.from(progressMap.keys())
|
||||||
|
const sample = keys.slice(0, 5).join(', ')
|
||||||
|
DebugBus.info('debug', `Progress keys sample: ${sample}`)
|
||||||
|
} catch { /* ignore */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Run both in parallel
|
// Run both in parallel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
@@ -523,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return filteredBlogPosts.length === 0 ? (
|
return filteredBlogPosts.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-grid">
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -584,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<h1>
|
<h1>
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faPersonHiking} />
|
||||||
Explore
|
Explore
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -656,7 +658,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div key={activeTab}>
|
<div>
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
settings
|
settings,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
const [showHighlights, setShowHighlights] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||||
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && filteredHighlights.length === 0 ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
|
|||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||||
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
onToggleHighlights,
|
onToggleHighlights,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onHighlightVisibilityChange
|
onHighlightVisibilityChange,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="highlights-header">
|
<div className="highlights-header">
|
||||||
@@ -101,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
{!isMobile && (
|
||||||
icon={faChevronRight}
|
<IconButton
|
||||||
onClick={onToggleCollapse}
|
icon={faChevronRight}
|
||||||
title="Collapse highlights panel"
|
onClick={onToggleCollapse}
|
||||||
ariaLabel="Collapse highlights panel"
|
title="Collapse highlights panel"
|
||||||
variant="ghost"
|
ariaLabel="Collapse highlights panel"
|
||||||
style={{ transform: 'rotate(180deg)' }}
|
variant="ghost"
|
||||||
/>
|
style={{ transform: 'rotate(180deg)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
@@ -23,7 +24,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
|||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
@@ -42,7 +43,7 @@ interface MeProps {
|
|||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
// Valid reading progress filters
|
// Valid reading progress filters
|
||||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||||
@@ -229,9 +230,9 @@ const Me: React.FC<MeProps> = ({
|
|||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
setLoadedTabs(prev => {
|
setLoadedTabs(prev => {
|
||||||
const hasBeenLoaded = prev.has('reading-list')
|
const hasBeenLoaded = prev.has('bookmarks')
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
return new Set(prev).add('reading-list')
|
return new Set(prev).add('bookmarks')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Always turn off loading after a tick
|
// Always turn off loading after a tick
|
||||||
@@ -334,7 +335,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
case 'writings':
|
case 'writings':
|
||||||
loadWritingsTab()
|
loadWritingsTab()
|
||||||
break
|
break
|
||||||
case 'reading-list':
|
case 'bookmarks':
|
||||||
loadReadingListTab()
|
loadReadingListTab()
|
||||||
break
|
break
|
||||||
case 'reads':
|
case 'reads':
|
||||||
@@ -418,7 +419,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
pubkey: item.author || '',
|
pubkey: item.author || '',
|
||||||
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
created_at: item.readingTimestamp || 0,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [] as string[][],
|
tags: [] as string[][],
|
||||||
content: item.title || item.url || 'Untitled',
|
content: item.title || item.url || 'Untitled',
|
||||||
@@ -565,9 +566,21 @@ const Me: React.FC<MeProps> = ({
|
|||||||
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||||
|
const titles: Record<BookmarkFilterType, string> = {
|
||||||
|
'all': 'All Bookmarks',
|
||||||
|
'article': 'Bookmarked Reads',
|
||||||
|
'external': 'Bookmarked Links',
|
||||||
|
'video': 'Bookmarked Videos',
|
||||||
|
'note': 'Bookmarked Notes',
|
||||||
|
'web': 'Web Bookmarks'
|
||||||
|
}
|
||||||
|
return titles[filter]
|
||||||
|
}
|
||||||
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
groupingMode === 'flat'
|
groupingMode === 'flat'
|
||||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||||
: [
|
: [
|
||||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
@@ -609,7 +622,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'reading-list':
|
case 'bookmarks':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -664,7 +677,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
borderTop: '1px solid var(--border-color)'
|
borderTop: '1px solid var(--border-color)'
|
||||||
}}>
|
}}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
onClick={toggleGroupingMode}
|
onClick={toggleGroupingMode}
|
||||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
@@ -860,9 +873,9 @@ const Me: React.FC<MeProps> = ({
|
|||||||
<span className="tab-label">Highlights</span>
|
<span className="tab-label">Highlights</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
|
||||||
data-tab="reading-list"
|
data-tab="bookmarks"
|
||||||
onClick={() => navigate('/me/reading-list')}
|
onClick={() => navigate('/me/bookmarks')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
|||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
highlights?: Highlight[]
|
highlights?: Highlight[]
|
||||||
highlightVisibility?: HighlightVisibility
|
highlightVisibility?: HighlightVisibility
|
||||||
|
onHighlightCountClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
highlightCount,
|
highlightCount,
|
||||||
settings,
|
settings,
|
||||||
highlights = [],
|
highlights = [],
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
|
onHighlightCountClick
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image)
|
const cachedImage = useImageCache(image)
|
||||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||||
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(true)}
|
style={getHighlightIndicatorStyles(true)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(false)}
|
style={getHighlightIndicatorStyles(false)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function RouteDebug() {
|
|||||||
// Unexpected during deep-link refresh tests
|
// Unexpected during deep-link refresh tests
|
||||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
} else {
|
} else {
|
||||||
console.debug('[RouteDebug]', info)
|
// silent
|
||||||
}
|
}
|
||||||
}, [location, matchArticle])
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
>
|
>
|
||||||
here
|
here
|
||||||
</a>
|
</a>
|
||||||
{' and '}
|
{', '}
|
||||||
<a
|
<a
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -161,6 +161,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
>
|
>
|
||||||
here
|
here
|
||||||
</a>
|
</a>
|
||||||
|
{', and '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-header-bar">
|
<div className="sidebar-header-bar">
|
||||||
{isMobile ? (
|
|
||||||
<IconButton
|
|
||||||
icon={faTimes}
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
title="Close sidebar"
|
|
||||||
ariaLabel="Close sidebar"
|
|
||||||
variant="ghost"
|
|
||||||
className="mobile-close-btn"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
className="toggle-sidebar-btn"
|
|
||||||
title="Collapse bookmarks sidebar"
|
|
||||||
aria-label="Collapse bookmarks sidebar"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="sidebar-header-right">
|
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div
|
<button
|
||||||
className="profile-avatar"
|
className="profile-avatar-button"
|
||||||
title={getUserDisplayName()}
|
title={getUserDisplayName()}
|
||||||
onClick={() => navigate('/me')}
|
onClick={() => navigate('/me')}
|
||||||
style={{ cursor: 'pointer' }}
|
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||||
>
|
>
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
) : (
|
) : (
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<div className="sidebar-header-right">
|
||||||
icon={faHome}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
title="Home"
|
|
||||||
ariaLabel="Home"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faNewspaper}
|
|
||||||
onClick={() => navigate('/explore')}
|
|
||||||
title="Explore"
|
|
||||||
ariaLabel="Explore"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faGear}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
ariaLabel="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
{activeAccount && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faHome}
|
||||||
onClick={onLogout}
|
onClick={() => navigate('/')}
|
||||||
title="Logout"
|
title="Home"
|
||||||
ariaLabel="Logout"
|
ariaLabel="Home"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
|
icon={faGear}
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
title="Settings"
|
||||||
|
ariaLabel="Settings"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faPersonHiking}
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
title="Explore"
|
||||||
|
ariaLabel="Explore"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
{activeAccount && (
|
||||||
|
<IconButton
|
||||||
|
icon={faRightFromBracket}
|
||||||
|
onClick={onLogout}
|
||||||
|
title="Logout"
|
||||||
|
ariaLabel="Logout"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="toggle-sidebar-btn"
|
||||||
|
title="Collapse bookmarks sidebar"
|
||||||
|
aria-label="Collapse bookmarks sidebar"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
|||||||
const lang = detect(text)
|
const lang = detect(text)
|
||||||
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('[tts][detect] failed', err)
|
// ignore detection errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!langOverride && resolvedSystemLang) {
|
if (!langOverride && resolvedSystemLang) {
|
||||||
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
|||||||
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
const next = SPEED_OPTIONS[nextIndex]
|
const next = SPEED_OPTIONS[nextIndex]
|
||||||
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
|
|
||||||
setRate(next)
|
setRate(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
currentArticle={props.currentArticle}
|
currentArticle={props.currentArticle}
|
||||||
isSidebarCollapsed={props.isCollapsed}
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
|
onOpenHighlights={() => {
|
||||||
|
if (props.isHighlightsCollapsed) {
|
||||||
|
props.onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -413,6 +418,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
|||||||
onMouseUp,
|
onMouseUp,
|
||||||
onTouchEnd
|
onTouchEnd
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const processedHtml = useMemo(() => {
|
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||||
|
const { processedHtml, videoUrls } = useMemo(() => {
|
||||||
if (!renderVideoLinksAsEmbeds || !html) {
|
if (!renderVideoLinksAsEmbeds || !html) {
|
||||||
return html
|
return { processedHtml: html, videoUrls: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||||
@@ -86,71 +87,19 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
|||||||
|
|
||||||
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||||
|
|
||||||
let processedHtml = result
|
let finalHtml = result
|
||||||
remainingUrls.forEach((url) => {
|
remainingUrls.forEach((url) => {
|
||||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||||
collectedUrls.push(url)
|
collectedUrls.push(url)
|
||||||
placeholderIndex++
|
placeholderIndex++
|
||||||
})
|
})
|
||||||
|
|
||||||
// If nothing collected, return original html
|
// Return both processed HTML and collected URLs (in the same order as placeholders)
|
||||||
if (collectedUrls.length === 0) {
|
return {
|
||||||
return html
|
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
|
||||||
|
videoUrls: collectedUrls
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedHtml
|
|
||||||
}, [html, renderVideoLinksAsEmbeds])
|
|
||||||
|
|
||||||
const videoUrls = useMemo(() => {
|
|
||||||
if (!renderVideoLinksAsEmbeds || !html) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const urls: string[] = []
|
|
||||||
|
|
||||||
// 1) Extract from <video> blocks first (video src or nested source src)
|
|
||||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
|
||||||
const videoBlocks = html.match(videoBlockPattern) || []
|
|
||||||
videoBlocks.forEach((block) => {
|
|
||||||
let url: string | null = null
|
|
||||||
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
|
||||||
if (videoSrcMatch && videoSrcMatch[1]) {
|
|
||||||
url = videoSrcMatch[1]
|
|
||||||
} else {
|
|
||||||
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
|
||||||
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
|
||||||
url = sourceSrcMatch[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (url && !urls.includes(url)) urls.push(url)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2) Extract from <img> tags with video src
|
|
||||||
const imgTagPattern = /<img[^>]*>/gi
|
|
||||||
const allImgTags = html.match(imgTagPattern) || []
|
|
||||||
allImgTags.forEach((imgTag) => {
|
|
||||||
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
|
||||||
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
|
|
||||||
urls.push(srcMatch[1])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3) Extract remaining direct file URLs and platform-classified video URLs
|
|
||||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
|
||||||
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
|
|
||||||
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
|
|
||||||
|
|
||||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
|
||||||
const allUrls: string[] = html.match(allUrlPattern) || []
|
|
||||||
allUrls.forEach(u => {
|
|
||||||
const classification = classifyUrl(u)
|
|
||||||
if (classification.type === 'video' && !urls.includes(u)) {
|
|
||||||
urls.push(u)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return urls
|
|
||||||
}, [html, renderVideoLinksAsEmbeds])
|
}, [html, renderVideoLinksAsEmbeds])
|
||||||
|
|
||||||
// If no video embedding is enabled, just render the HTML normally
|
// If no video embedding is enabled, just render the HTML normally
|
||||||
@@ -195,13 +144,16 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular HTML content
|
// Regular HTML content - only render if not empty
|
||||||
return (
|
if (part.trim()) {
|
||||||
<div
|
return (
|
||||||
key={index}
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: part }}
|
key={index}
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: part }}
|
||||||
)
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import type { IEventStore } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { queryEvents } from '../services/dataFetch'
|
||||||
import { fetchArticleByNaddr } from '../services/articleService'
|
import { fetchArticleByNaddr } from '../services/articleService'
|
||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
title: string
|
||||||
|
image?: string
|
||||||
|
summary?: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface UseArticleLoaderProps {
|
interface UseArticleLoaderProps {
|
||||||
naddr: string | undefined
|
naddr: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
setSelectedUrl: (url: string) => void
|
setSelectedUrl: (url: string) => void
|
||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
|
|||||||
export function useArticleLoader({
|
export function useArticleLoader({
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -36,7 +51,18 @@ export function useArticleLoader({
|
|||||||
setCurrentArticle,
|
setCurrentArticle,
|
||||||
settings
|
settings
|
||||||
}: UseArticleLoaderProps) {
|
}: UseArticleLoaderProps) {
|
||||||
|
const location = useLocation()
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
|
// Hold latest settings without retriggering effect
|
||||||
|
const settingsRef = useRef<UserSettings | undefined>(settings)
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRef.current = settings
|
||||||
|
}, [settings])
|
||||||
|
// Track in-flight request to prevent stale updates from previous naddr
|
||||||
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
// Extract preview data from navigation state (from blog post cards)
|
||||||
|
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
@@ -44,67 +70,204 @@ export function useArticleLoader({
|
|||||||
if (!relayPool || !naddr) return
|
if (!relayPool || !naddr) return
|
||||||
|
|
||||||
const loadArticle = async () => {
|
const loadArticle = async () => {
|
||||||
|
const requestId = ++currentRequestIdRef.current
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
|
||||||
setReaderContent(undefined)
|
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
|
|
||||||
try {
|
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
if (previewData) {
|
||||||
|
|
||||||
if (!mountedRef.current) return
|
|
||||||
|
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title: previewData.title,
|
||||||
markdown: article.markdown,
|
markdown: '', // Will be loaded from store or relay
|
||||||
image: article.image,
|
image: previewData.image,
|
||||||
summary: article.summary,
|
summary: previewData.summary,
|
||||||
published: article.published,
|
published: previewData.published,
|
||||||
url: `nostr:${naddr}`
|
url: `nostr:${naddr}`
|
||||||
})
|
})
|
||||||
|
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
} else {
|
||||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
setCurrentArticleCoordinate(articleCoordinate)
|
}
|
||||||
setCurrentArticleEventId(article.event.id)
|
|
||||||
setCurrentArticle?.(article.event)
|
try {
|
||||||
setReaderLoading(false)
|
// Decode naddr to filter
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
// Fetch highlights asynchronously without blocking article display
|
if (decoded.type !== 'naddr') {
|
||||||
|
throw new Error('Invalid naddr format')
|
||||||
|
}
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstEmitted = false
|
||||||
|
let latestEvent: NostrEvent | null = null
|
||||||
|
|
||||||
|
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
||||||
|
if (eventStore) {
|
||||||
|
try {
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||||
|
if (storedEvent) {
|
||||||
|
latestEvent = storedEvent as NostrEvent
|
||||||
|
firstEmitted = true
|
||||||
|
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(storedEvent)
|
||||||
|
const summary = Helpers.getArticleSummary(storedEvent)
|
||||||
|
const published = Helpers.getArticlePublished(storedEvent)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: storedEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
|
setCurrentArticle?.(storedEvent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore store errors, fall through to relay query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||||
|
const events = await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
|
// Store in event store for future local reads
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore?.add?.(evt as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore store errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep latest by created_at
|
||||||
|
if (!latestEvent || evt.created_at > latestEvent.created_at) {
|
||||||
|
latestEvent = evt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately on first event
|
||||||
|
if (!firstEmitted) {
|
||||||
|
firstEmitted = true
|
||||||
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(evt)
|
||||||
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(evt.id)
|
||||||
|
setCurrentArticle?.(evt)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
|
// Finalize with newest version if it's newer than what we first rendered
|
||||||
|
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||||
|
if (finalEvent) {
|
||||||
|
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(finalEvent)
|
||||||
|
const summary = Helpers.getArticleSummary(finalEvent)
|
||||||
|
const published = Helpers.getArticlePublished(finalEvent)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: finalEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(finalEvent.id)
|
||||||
|
setCurrentArticle?.(finalEvent)
|
||||||
|
} else {
|
||||||
|
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||||
|
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||||
|
setReaderContent({
|
||||||
|
title: article.title,
|
||||||
|
markdown: article.markdown,
|
||||||
|
image: article.image,
|
||||||
|
summary: article.summary,
|
||||||
|
published: article.published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(article.event.id)
|
||||||
|
setCurrentArticle?.(article.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights after content is shown
|
||||||
try {
|
try {
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
const le = latestEvent as NostrEvent | null
|
||||||
setHighlights([])
|
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
|
||||||
|
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = le ? le.id : undefined
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
if (coord && eventId) {
|
||||||
relayPool,
|
setHighlightsLoading(true)
|
||||||
articleCoordinate,
|
setHighlights([])
|
||||||
article.event.id,
|
await fetchHighlightsForArticle(
|
||||||
(highlight) => {
|
relayPool,
|
||||||
if (!mountedRef.current) return
|
coord,
|
||||||
|
eventId,
|
||||||
setHighlights((prev: Highlight[]) => {
|
(highlight) => {
|
||||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
if (!mountedRef.current) return
|
||||||
const next = [highlight, ...prev]
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
setHighlights((prev: Highlight[]) => {
|
||||||
})
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
},
|
const next = [highlight, ...prev]
|
||||||
settings
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
})
|
||||||
|
},
|
||||||
|
settingsRef.current
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No article event to fetch highlights for - clear and don't show loading
|
||||||
|
setHighlights([])
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load article:', err)
|
console.error('Failed to load article:', err)
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: 'Error Loading Article',
|
title: 'Error Loading Article',
|
||||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
@@ -123,7 +286,8 @@ export function useArticleLoader({
|
|||||||
}, [
|
}, [
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
relayPool,
|
||||||
settings,
|
eventStore,
|
||||||
|
previewData,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function useExternalUrlLoader({
|
|||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
}: UseExternalUrlLoaderProps) {
|
}: UseExternalUrlLoaderProps) {
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
|
// Track in-flight request to prevent stale updates when switching quickly
|
||||||
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
// Load cached URL-specific highlights from event store
|
// Load cached URL-specific highlights from event store
|
||||||
const urlFilter = useMemo(() => {
|
const urlFilter = useMemo(() => {
|
||||||
@@ -70,6 +72,7 @@ export function useExternalUrlLoader({
|
|||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
const loadExternalUrl = async () => {
|
const loadExternalUrl = async () => {
|
||||||
|
const requestId = ++currentRequestIdRef.current
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
@@ -83,6 +86,7 @@ export function useExternalUrlLoader({
|
|||||||
const content = await fetchReadableContent(url)
|
const content = await fetchReadableContent(url)
|
||||||
|
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
@@ -114,6 +118,7 @@ export function useExternalUrlLoader({
|
|||||||
url,
|
url,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
if (seen.has(highlight.id)) return
|
if (seen.has(highlight.id)) return
|
||||||
seen.add(highlight.id)
|
seen.add(highlight.id)
|
||||||
@@ -131,13 +136,13 @@ export function useExternalUrlLoader({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||||
const filename = getFilenameFromUrl(url)
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: filename,
|
title: filename,
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
|
|||||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Always clear previous render immediately to avoid showing stale content while processing
|
||||||
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
|
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
setRenderedHtml('')
|
|
||||||
setProcessedMarkdown('')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
|
|||||||
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
onSave?: (position: number) => void // Callback for saving position
|
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)
|
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ export const useReadingPosition = ({
|
|||||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||||
syncEnabled = false,
|
syncEnabled = false,
|
||||||
onSave,
|
onSave,
|
||||||
autoSaveInterval = 5000,
|
|
||||||
completionHoldMs = 2000
|
completionHoldMs = 2000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
@@ -30,10 +28,27 @@ export const useReadingPosition = ({
|
|||||||
const hasSavedOnce = useRef(false)
|
const hasSavedOnce = useRef(false)
|
||||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const lastSavedAtRef = useRef<number>(0)
|
const lastSavedAtRef = useRef<number>(0)
|
||||||
|
const suppressUntilRef = useRef<number>(0)
|
||||||
|
const syncEnabledRef = useRef(syncEnabled)
|
||||||
|
const onSaveRef = useRef(onSave)
|
||||||
|
const scheduleSaveRef = useRef<((pos: number) => void) | null>(null)
|
||||||
|
|
||||||
// Debounced save function
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
syncEnabledRef.current = syncEnabled
|
||||||
|
onSaveRef.current = onSave
|
||||||
|
}, [syncEnabled, onSave])
|
||||||
|
|
||||||
|
// Suppress auto-saves for a given duration (used after programmatic restore)
|
||||||
|
const suppressSavesFor = useCallback((ms: number) => {
|
||||||
|
const until = Date.now() + ms
|
||||||
|
suppressUntilRef.current = until
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Suppressing saves for ${ms}ms until ${new Date(until).toISOString()}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Debounced save function - simple 2s debounce
|
||||||
const scheduleSave = useCallback((currentPosition: number) => {
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
if (!syncEnabled || !onSave) {
|
if (!syncEnabledRef.current || !onSaveRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +58,11 @@ export const useReadingPosition = ({
|
|||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
|
||||||
lastSavedPosition.current = 1
|
lastSavedPosition.current = 1
|
||||||
hasSavedOnce.current = true
|
hasSavedOnce.current = true
|
||||||
lastSavedAtRef.current = Date.now()
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(1)
|
onSaveRef.current(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,62 +70,54 @@ export const useReadingPosition = ({
|
|||||||
const MIN_DELTA = 0.05
|
const MIN_DELTA = 0.05
|
||||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
|
||||||
|
|
||||||
// Enforce a minimum interval between saves (15s) to avoid spamming
|
if (!hasSignificantChange) {
|
||||||
const MIN_INTERVAL_MS = 15000
|
|
||||||
const nowMs = Date.now()
|
|
||||||
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
|
|
||||||
|
|
||||||
// Allow the very first meaningful save (when crossing 5%) regardless of interval
|
|
||||||
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
|
|
||||||
|
|
||||||
if (!hasSignificantChange && !isFirstMeaningful) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency
|
// Clear any existing timer and schedule new save
|
||||||
if (!enoughTimeElapsed && !isFirstMeaningful) {
|
|
||||||
// Clear and reschedule within the remaining window, but not sooner than MIN_INTERVAL_MS
|
|
||||||
if (saveTimerRef.current) {
|
|
||||||
clearTimeout(saveTimerRef.current)
|
|
||||||
}
|
|
||||||
const remaining = Math.max(0, MIN_INTERVAL_MS - (nowMs - lastSavedAtRef.current))
|
|
||||||
const delay = Math.max(autoSaveInterval, remaining)
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
|
||||||
lastSavedPosition.current = currentPosition
|
|
||||||
hasSavedOnce.current = true
|
|
||||||
lastSavedAtRef.current = Date.now()
|
|
||||||
onSave(currentPosition)
|
|
||||||
}, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing timer
|
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
|
const DEBOUNCE_MS = 3000 // Save max every 3 seconds
|
||||||
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Auto-save at ${Math.round(currentPosition * 100)}%`)
|
||||||
lastSavedPosition.current = currentPosition
|
lastSavedPosition.current = currentPosition
|
||||||
hasSavedOnce.current = true
|
hasSavedOnce.current = true
|
||||||
lastSavedAtRef.current = Date.now()
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(currentPosition)
|
if (onSaveRef.current) {
|
||||||
}, delay)
|
onSaveRef.current(currentPosition)
|
||||||
}, [syncEnabled, onSave, autoSaveInterval])
|
}
|
||||||
|
saveTimerRef.current = null
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Store scheduleSave in ref for use in scroll handler
|
||||||
|
useEffect(() => {
|
||||||
|
scheduleSaveRef.current = scheduleSave
|
||||||
|
}, [scheduleSave])
|
||||||
|
|
||||||
// Immediate save function
|
// Immediate save function
|
||||||
const saveNow = useCallback(() => {
|
const saveNow = useCallback(() => {
|
||||||
if (!syncEnabled || !onSave) return
|
if (!syncEnabledRef.current || !onSaveRef.current) return
|
||||||
|
|
||||||
|
// Check suppression even for saveNow (e.g., during restore)
|
||||||
|
if (Date.now() < suppressUntilRef.current) {
|
||||||
|
const remainingMs = suppressUntilRef.current - Date.now()
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] ⏭️ saveNow() suppressed (${remainingMs}ms remaining) at ${Math.round(positionRef.current * 100)}%`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}
|
||||||
lastSavedPosition.current = position
|
console.log(`[reading-position] [${new Date().toISOString()}] 💾 saveNow() called at ${Math.round(positionRef.current * 100)}%`)
|
||||||
|
lastSavedPosition.current = positionRef.current
|
||||||
hasSavedOnce.current = true
|
hasSavedOnce.current = true
|
||||||
lastSavedAtRef.current = Date.now()
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(position)
|
onSaveRef.current(positionRef.current)
|
||||||
}, [syncEnabled, onSave, position])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -123,21 +131,29 @@ export const useReadingPosition = ({
|
|||||||
const windowHeight = window.innerHeight
|
const windowHeight = window.innerHeight
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
|
||||||
|
// Ignore if document is too small (likely during page transition)
|
||||||
|
if (documentHeight < 100) return
|
||||||
|
|
||||||
// Calculate position based on how much of the content has been scrolled through
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
|
||||||
const maxScroll = documentHeight - windowHeight
|
const maxScroll = documentHeight - windowHeight
|
||||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||||
|
|
||||||
// If we're within 5px of the bottom, consider it 100%
|
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
|
||||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
// This prevents false 100% during page transitions
|
||||||
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100
|
||||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
positionRef.current = clampedProgress
|
positionRef.current = clampedProgress
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
// Schedule auto-save if sync is enabled
|
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||||
scheduleSave(clampedProgress)
|
if (Date.now() >= suppressUntilRef.current) {
|
||||||
|
scheduleSaveRef.current?.(clampedProgress)
|
||||||
|
} else {
|
||||||
|
const remainingMs = suppressUntilRef.current - Date.now()
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Save suppressed (${remainingMs}ms remaining) at ${Math.round(clampedProgress * 100)}%`)
|
||||||
|
}
|
||||||
|
|
||||||
// Completion detection with 2s hold at 100%
|
// Completion detection with 2s hold at 100%
|
||||||
if (!hasTriggeredComplete.current) {
|
if (!hasTriggeredComplete.current) {
|
||||||
@@ -180,15 +196,24 @@ export const useReadingPosition = ({
|
|||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', handleScroll)
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
// Clear save timer on unmount
|
// Flush pending save before unmount (don't lose progress if navigating away during debounce window)
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current && syncEnabledRef.current && onSaveRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = null
|
||||||
|
|
||||||
|
// Only flush if we have unsaved progress (position differs from last saved)
|
||||||
|
const hasUnsavedProgress = Math.abs(positionRef.current - lastSavedPosition.current) >= 0.05
|
||||||
|
if (hasUnsavedProgress && Date.now() >= suppressUntilRef.current) {
|
||||||
|
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Flushing pending save on unmount at ${Math.round(positionRef.current * 100)}%`)
|
||||||
|
onSaveRef.current(positionRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionTimerRef.current) {
|
if (completionTimerRef.current) {
|
||||||
clearTimeout(completionTimerRef.current)
|
clearTimeout(completionTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, completionHoldMs])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,6 +233,7 @@ export const useReadingPosition = ({
|
|||||||
position,
|
position,
|
||||||
isReadingComplete,
|
isReadingComplete,
|
||||||
progressPercentage: Math.round(position * 100),
|
progressPercentage: Math.round(position * 100),
|
||||||
saveNow
|
saveNow,
|
||||||
|
suppressSavesFor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
|
||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
import { applyTheme } from '../utils/theme'
|
import { applyTheme } from '../utils/theme'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||||
|
|
||||||
// Load settings and set up subscription
|
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !pubkey || !eventStore) return
|
if (!relayPool || !pubkey || !eventStore) return
|
||||||
|
|
||||||
const loadAndWatch = async () => {
|
// Start settings stream: seed from store, stream updates to store in background
|
||||||
try {
|
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
})
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load settings:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAndWatch()
|
|
||||||
|
|
||||||
|
// Also watch store reactively for any further updates
|
||||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
stopNetwork()
|
||||||
|
}
|
||||||
}, [relayPool, pubkey, eventStore])
|
}, [relayPool, pubkey, eventStore])
|
||||||
|
|
||||||
// Apply settings to document
|
// Apply settings to document
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
// Update rate when defaultRate option changes
|
// Update rate when defaultRate option changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.defaultRate !== undefined) {
|
if (options.defaultRate !== undefined) {
|
||||||
console.debug('[tts] defaultRate changed ->', options.defaultRate)
|
|
||||||
setRate(options.defaultRate)
|
setRate(options.defaultRate)
|
||||||
}
|
}
|
||||||
}, [options.defaultRate])
|
}, [options.defaultRate])
|
||||||
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
if (!voice && v.length) {
|
if (!voice && v.length) {
|
||||||
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||||
setVoice(byLang || v[0] || null)
|
setVoice(byLang || v[0] || null)
|
||||||
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
u.onstart = () => {
|
u.onstart = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onstart')
|
|
||||||
setSpeaking(true)
|
setSpeaking(true)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
u.onpause = () => {
|
u.onpause = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onpause')
|
|
||||||
setPaused(true)
|
setPaused(true)
|
||||||
}
|
}
|
||||||
u.onresume = () => {
|
u.onresume = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onresume')
|
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
u.onend = () => {
|
u.onend = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onend')
|
|
||||||
// Continue with next chunk if available
|
// Continue with next chunk if available
|
||||||
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
chunkIndexRef.current += 1
|
chunkIndexRef.current++
|
||||||
globalOffsetRef.current += self.text.length
|
charIndexRef.current += self.text.length
|
||||||
const next = chunksRef.current[chunkIndexRef.current] || ''
|
const nextChunk = chunksRef.current[chunkIndexRef.current]
|
||||||
const nextUtterance = createUtterance(next, langRef.current)
|
const nextUtterance = createUtterance(nextChunk, langRef.current)
|
||||||
utteranceRef.current = nextUtterance
|
utteranceRef.current = nextUtterance
|
||||||
synth!.speak(nextUtterance)
|
synth!.speak(nextUtterance)
|
||||||
return
|
} else {
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
}
|
}
|
||||||
setSpeaking(false)
|
|
||||||
setPaused(false)
|
|
||||||
utteranceRef.current = null
|
|
||||||
}
|
}
|
||||||
u.onerror = () => {
|
u.onerror = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onerror')
|
|
||||||
setSpeaking(false)
|
setSpeaking(false)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
utteranceRef.current = null
|
|
||||||
}
|
}
|
||||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
console.debug('[tts] stop')
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
setSpeaking(false)
|
setSpeaking(false)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
const speak = useCallback((text: string, langOverride?: string) => {
|
const speak = useCallback((text: string, langOverride?: string) => {
|
||||||
if (!supported || !text?.trim()) return
|
if (!supported || !text?.trim()) return
|
||||||
console.debug('[tts] speak', { len: text.length, rate })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
spokenTextRef.current = text
|
spokenTextRef.current = text
|
||||||
charIndexRef.current = 0
|
charIndexRef.current = 0
|
||||||
langRef.current = langOverride
|
langRef.current = langOverride
|
||||||
startSpeakingChunks(text)
|
startSpeakingChunks(text)
|
||||||
}, [supported, synth, startSpeakingChunks, rate])
|
}, [supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (synth!.speaking && !synth!.paused) {
|
if (synth!.speaking && !synth!.paused) {
|
||||||
console.debug('[tts] pause')
|
|
||||||
synth!.pause()
|
synth!.pause()
|
||||||
setPaused(true)
|
setPaused(true)
|
||||||
}
|
}
|
||||||
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (synth!.speaking && synth!.paused) {
|
if (synth!.speaking && synth!.paused) {
|
||||||
console.debug('[tts] resume')
|
|
||||||
synth!.resume()
|
synth!.resume()
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (!utteranceRef.current) return
|
if (!utteranceRef.current) return
|
||||||
|
|
||||||
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
|
|
||||||
|
|
||||||
if (synth!.speaking && !synth!.paused) {
|
if (synth!.speaking && !synth!.paused) {
|
||||||
const fullText = spokenTextRef.current
|
const fullText = spokenTextRef.current
|
||||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||||
const remainingText = fullText.slice(startIndex)
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
|
||||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
// restart chunked from current global index
|
// restart chunked from current global index
|
||||||
spokenTextRef.current = remainingText
|
spokenTextRef.current = remainingText
|
||||||
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
const fullText = spokenTextRef.current
|
const fullText = spokenTextRef.current
|
||||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||||
const remainingText = fullText.slice(startIndex)
|
const remainingText = fullText.slice(startIndex)
|
||||||
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
const u = createUtterance(remainingText)
|
const u = createUtterance(remainingText)
|
||||||
// ensure the new rate is applied immediately on the new utterance
|
// ensure the new rate is applied immediately on the new utterance
|
||||||
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
utteranceRef.current = u
|
utteranceRef.current = u
|
||||||
synth!.speak(u)
|
synth!.speak(u)
|
||||||
} else if (utteranceRef.current) {
|
} else if (utteranceRef.current) {
|
||||||
console.debug('[tts] updateRate -> set on utterance', { newRate })
|
|
||||||
utteranceRef.current.rate = newRate
|
utteranceRef.current.rate = newRate
|
||||||
}
|
}
|
||||||
}, [supported, synth, createUtterance])
|
}, [supported, synth, createUtterance])
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import './styles/tailwind.css'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import 'react-loading-skeleton/dist/skeleton.css'
|
import 'react-loading-skeleton/dist/skeleton.css'
|
||||||
|
|
||||||
// Register Service Worker for PWA functionality
|
// Register Service Worker for PWA functionality (production only)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js', { type: 'module' })
|
.register('/sw.js')
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
|
|
||||||
// Check for updates periodically
|
// Check for updates periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
registration.update()
|
registration.update()
|
||||||
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// New service worker available
|
// New service worker available
|
||||||
|
|
||||||
// Optionally show a toast notification
|
|
||||||
const updateAvailable = new CustomEvent('sw-update-available')
|
const updateAvailable = new CustomEvent('sw-update-available')
|
||||||
window.dispatchEvent(updateAvailable)
|
window.dispatchEvent(updateAvailable)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
|
|||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
// Define relays to query - use union of relay hints from naddr and configured relays
|
||||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
// This avoids failures when naddr contains stale/unreachable relay hints
|
||||||
? pointer.relays
|
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
||||||
: RELAYS
|
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
||||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||||
|
|
||||||
@@ -114,7 +114,28 @@ export async function fetchArticleByNaddr(
|
|||||||
// Parallel local+remote, stream immediate, collect up to first from each
|
// Parallel local+remote, stream immediate, collect up to first from each
|
||||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
||||||
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
||||||
const events = collected as NostrEvent[]
|
let events = collected as NostrEvent[]
|
||||||
|
|
||||||
|
// Fallback: if nothing found, try a second round against a set of reliable public relays
|
||||||
|
if (events.length === 0) {
|
||||||
|
const reliableRelays = Array.from(new Set<string>([
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
...remoteRelays // keep any configured remote relays
|
||||||
|
]))
|
||||||
|
const { remote$: fallback$ } = createParallelReqStreams(
|
||||||
|
relayPool,
|
||||||
|
[], // no local
|
||||||
|
reliableRelays,
|
||||||
|
filter,
|
||||||
|
1500,
|
||||||
|
12000
|
||||||
|
)
|
||||||
|
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
|
||||||
|
events = fallbackCollected as NostrEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
throw new Error('Article not found')
|
throw new Error('Article not found')
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Helpers, EventStore } from 'applesauce-core'
|
import { Helpers, EventStore } from 'applesauce-core'
|
||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { EventPointer } from 'nostr-tools/nip19'
|
|
||||||
import { from } from 'rxjs'
|
|
||||||
import { mergeMap } from 'rxjs/operators'
|
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import {
|
import {
|
||||||
@@ -65,12 +60,8 @@ class BookmarkController {
|
|||||||
}> = new Map()
|
}> = new Map()
|
||||||
private isLoading = false
|
private isLoading = false
|
||||||
private hydrationGeneration = 0
|
private hydrationGeneration = 0
|
||||||
|
|
||||||
// Event loaders for efficient batching
|
|
||||||
private eventStore = new EventStore()
|
|
||||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
|
||||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
|
||||||
private externalEventStore: EventStore | null = null
|
private externalEventStore: EventStore | null = null
|
||||||
|
private relayPool: RelayPool | null = null
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
this.rawEventListeners.push(cb)
|
||||||
@@ -119,15 +110,15 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
* Hydrate events by IDs using queryEvents (local-first, streaming)
|
||||||
*/
|
*/
|
||||||
private hydrateByIds(
|
private async hydrateByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.eventLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,86 +128,146 @@ class BookmarkController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert IDs to EventPointers
|
// Fetch events using local-first queryEvents
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
await queryEvents(
|
||||||
|
this.relayPool,
|
||||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
{ ids: unique },
|
||||||
// This prevents overwhelming relays with 96+ simultaneous requests
|
{
|
||||||
from(pointers).pipe(
|
onEvent: (event) => {
|
||||||
mergeMap(pointer => this.eventLoader!(pointer), 5)
|
// Check if hydration was cancelled
|
||||||
).subscribe({
|
if (this.hydrationGeneration !== generation) return
|
||||||
next: (event) => {
|
|
||||||
// Check if hydration was cancelled
|
idToEvent.set(event.id, event)
|
||||||
if (this.hydrationGeneration !== generation) return
|
|
||||||
|
// Also index by coordinate for addressable events
|
||||||
idToEvent.set(event.id, event)
|
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
// Also index by coordinate for addressable events
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
idToEvent.set(coordinate, event)
|
||||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
}
|
||||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, event)
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to external event store if available
|
|
||||||
if (this.externalEventStore) {
|
|
||||||
this.externalEventStore.add(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress()
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
// Silent error - EventLoader handles retries
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
|
||||||
*/
|
*/
|
||||||
private hydrateByCoordinates(
|
private async hydrateByCoordinates(
|
||||||
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.addressLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.length === 0) return
|
if (coords.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert coordinates to AddressPointers
|
// Group by kind and pubkey for efficient batching
|
||||||
const pointers = coords.map(c => ({
|
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||||
kind: c.kind,
|
|
||||||
pubkey: c.pubkey,
|
for (const coord of coords) {
|
||||||
identifier: c.identifier
|
if (!filtersByKind.has(coord.kind)) {
|
||||||
}))
|
filtersByKind.set(coord.kind, new Map())
|
||||||
|
}
|
||||||
|
const byPubkey = filtersByKind.get(coord.kind)!
|
||||||
|
if (!byPubkey.has(coord.pubkey)) {
|
||||||
|
byPubkey.set(coord.pubkey, [])
|
||||||
|
}
|
||||||
|
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
|
||||||
|
}
|
||||||
|
|
||||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
// Kick off all queries in parallel (fire-and-forget)
|
||||||
from(pointers).pipe(
|
const promises: Promise<void>[] = []
|
||||||
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
|
||||||
).subscribe({
|
for (const [kind, byPubkey] of filtersByKind) {
|
||||||
next: (event) => {
|
for (const [pubkey, identifiers] of byPubkey) {
|
||||||
// Check if hydration was cancelled
|
// Separate empty and non-empty identifiers
|
||||||
if (this.hydrationGeneration !== generation) return
|
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||||
|
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, event)
|
|
||||||
idToEvent.set(event.id, event)
|
|
||||||
|
|
||||||
// Add to external event store if available
|
// Fetch events with non-empty d-tags
|
||||||
if (this.externalEventStore) {
|
if (nonEmptyIdentifiers.length > 0) {
|
||||||
this.externalEventStore.add(event)
|
promises.push(
|
||||||
|
queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// Query completed successfully
|
||||||
|
}).catch(() => {
|
||||||
|
// Silent error - individual query failed
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress()
|
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||||
},
|
if (hasEmptyIdentifier) {
|
||||||
error: () => {
|
promises.push(
|
||||||
// Silent error - AddressLoader handles retries
|
queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ kinds: [kind], authors: [pubkey] },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
// Only process events with empty d-tag
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
if (dTag !== '') return
|
||||||
|
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// Query completed successfully
|
||||||
|
}).catch(() => {
|
||||||
|
// Silent error - individual query failed
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Wait for all queries to complete
|
||||||
|
await Promise.all(promises)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAndEmitBookmarks(
|
private async buildAndEmitBookmarks(
|
||||||
@@ -279,8 +330,6 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
|
|
||||||
|
|
||||||
// Helper to build and emit bookmarks
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
||||||
@@ -293,22 +342,28 @@ class BookmarkController {
|
|||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
// Prefer hydrated content; fallback to any cached event content in external store
|
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
|
||||||
content: b.content && b.content.length > 0
|
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
|
||||||
? b.content
|
|
||||||
: (this.externalEventStore?.getEvent(b.id)?.content || '')
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedBookmarks = enriched
|
const sortedBookmarks = enriched
|
||||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
.map(b => ({
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
...b,
|
||||||
|
urlReferences: extractUrlsFromContent(b.content)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
|
||||||
|
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
|
||||||
|
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
|
||||||
|
return bTs - aTs
|
||||||
|
})
|
||||||
|
|
||||||
const bookmark: Bookmark = {
|
const bookmark: Bookmark = {
|
||||||
id: `${activeAccount.pubkey}-bookmarks`,
|
id: `${activeAccount.pubkey}-bookmarks`,
|
||||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||||
url: '',
|
url: '',
|
||||||
content: latestContent,
|
content: latestContent,
|
||||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
created_at: newestCreatedAt || 0,
|
||||||
tags: allTags,
|
tags: allTags,
|
||||||
bookmarkCount: sortedBookmarks.length,
|
bookmarkCount: sortedBookmarks.length,
|
||||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||||
@@ -324,7 +379,7 @@ class BookmarkController {
|
|||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
emitBookmarks(idToEvent)
|
emitBookmarks(idToEvent)
|
||||||
|
|
||||||
// Now fetch events progressively in background using batched hydrators
|
// Now fetch events progressively in background using local-first queries
|
||||||
|
|
||||||
const generation = this.hydrationGeneration
|
const generation = this.hydrationGeneration
|
||||||
const onProgress = () => emitBookmarks(idToEvent)
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
@@ -339,10 +394,14 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Kick off batched hydration (streaming, non-blocking)
|
// Kick off hydration (streaming, non-blocking, local-first)
|
||||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
// Fire-and-forget - don't await, let it run in background
|
||||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
// Silent error - hydration will retry or show partial results
|
||||||
|
})
|
||||||
|
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
|
||||||
|
// Silent error - hydration will retry or show partial results
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to build bookmarks:', error)
|
console.error('Failed to build bookmarks:', error)
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
@@ -357,7 +416,8 @@ class BookmarkController {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { relayPool, activeAccount, accountManager, eventStore } = options
|
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||||
|
|
||||||
// Store the external event store reference for adding hydrated events
|
// Store references for hydration
|
||||||
|
this.relayPool = relayPool
|
||||||
this.externalEventStore = eventStore || null
|
this.externalEventStore = eventStore || null
|
||||||
|
|
||||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
@@ -369,16 +429,6 @@ class BookmarkController {
|
|||||||
// Increment generation to cancel any in-flight hydration
|
// Increment generation to cancel any in-flight hydration
|
||||||
this.hydrationGeneration++
|
this.hydrationGeneration++
|
||||||
|
|
||||||
// Initialize loaders for this session
|
|
||||||
this.eventLoader = createEventLoader(relayPool, {
|
|
||||||
eventStore: this.eventStore,
|
|
||||||
extraRelays: RELAYS
|
|
||||||
})
|
|
||||||
this.addressLoader = createAddressLoader(relayPool, {
|
|
||||||
eventStore: this.eventStore,
|
|
||||||
extraRelays: RELAYS
|
|
||||||
})
|
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
}
|
}
|
||||||
const unique = Array.from(byId.values())
|
const unique = Array.from(byId.values())
|
||||||
|
|
||||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
|
||||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
|
||||||
|
|
||||||
const bookmarkLists = unique
|
const bookmarkLists = unique
|
||||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||||
|
|
||||||
|
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
|
||||||
const byD = new Map<string, NostrEvent>()
|
const byD = new Map<string, NostrEvent>()
|
||||||
for (const e of unique) {
|
for (const e of unique) {
|
||||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
|
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
|
||||||
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
const prev = byD.get(d)
|
const prev = byD.get(d)
|
||||||
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setsAndNamedLists = Array.from(byD.values())
|
// Separate web bookmarks from bookmark sets/lists
|
||||||
|
const allReplaceable = Array.from(byD.values())
|
||||||
|
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
|
||||||
|
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
|
||||||
|
|
||||||
const out: NostrEvent[] = []
|
const out: NostrEvent[] = []
|
||||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||||
out.push(...setsAndNamedLists)
|
out.push(...setsAndNamedLists)
|
||||||
// Add web bookmarks as individual events
|
// Add deduplicated web bookmarks as individual events
|
||||||
out.push(...webBookmarks)
|
out.push(...webBookmarks)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
identifier: string
|
identifier: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventPointer {
|
export interface EventPointer {
|
||||||
id: string
|
id: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
author?: string
|
author?: string
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplesauceBookmarks {
|
export interface ApplesauceBookmarks {
|
||||||
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: note.created_at ?? null,
|
||||||
pubkey: note.author || activeAccount.pubkey,
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
kind: 1, // Short note kind
|
kind: 1, // Short note kind
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: coordinate,
|
id: coordinate,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: article.created_at ?? null,
|
||||||
pubkey: article.pubkey,
|
pubkey: article.pubkey,
|
||||||
kind: article.kind, // Usually 30023 for long-form articles
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,14 +119,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `hashtag-${hashtag}`,
|
id: `hashtag-${hashtag}`,
|
||||||
content: `#${hashtag}`,
|
content: `#${hashtag}`,
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: 0, // Hashtags don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['t', hashtag]],
|
tags: [['t', hashtag]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -133,14 +137,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `url-${url}`,
|
id: `url-${url}`,
|
||||||
content: url,
|
content: url,
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: 0, // URLs don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['r', url]],
|
tags: [['r', url]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -149,20 +153,24 @@ export const processApplesauceBookmarks = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||||
return bookmarkArray
|
const processed = bookmarkArray
|
||||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||||
.map((bookmark: BookmarkData) => ({
|
.map((bookmark: BookmarkData) => {
|
||||||
id: bookmark.id!,
|
return {
|
||||||
content: bookmark.content || '',
|
id: bookmark.id!,
|
||||||
created_at: bookmark.created_at || parentCreatedAt || 0,
|
content: bookmark.content || '',
|
||||||
pubkey: activeAccount.pubkey,
|
created_at: bookmark.created_at ?? null,
|
||||||
kind: bookmark.kind || 30001,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: bookmark.tags || [],
|
kind: bookmark.kind || 30001,
|
||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
tags: bookmark.tags || [],
|
||||||
type: 'event' as const,
|
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||||
isPrivate,
|
type: 'event' as const,
|
||||||
added_at: bookmark.created_at || parentCreatedAt || 0
|
isPrivate,
|
||||||
}))
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return processed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types and guards around signer/decryption APIs
|
// Types and guards around signer/decryption APIs
|
||||||
|
|||||||
@@ -133,29 +133,36 @@ export async function collectBookmarksFromEvents(
|
|||||||
|
|
||||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
|
// Use coordinate format for web bookmarks to enable proper deduplication
|
||||||
|
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
|
||||||
|
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
|
||||||
|
|
||||||
publicItemsAll.push({
|
publicItemsAll.push({
|
||||||
id: evt.id,
|
id: webBookmarkId,
|
||||||
content: evt.content || '',
|
content: evt.content || '',
|
||||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
created_at: evt.created_at ?? null,
|
||||||
pubkey: evt.pubkey,
|
pubkey: evt.pubkey,
|
||||||
kind: evt.kind,
|
kind: evt.kind,
|
||||||
tags: evt.tags || [],
|
tags: evt.tags || [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'web' as const,
|
type: 'web' as const,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
|
||||||
sourceKind: 39701,
|
sourceKind: 39701,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
setTitle,
|
setTitle,
|
||||||
setDescription,
|
setDescription,
|
||||||
setImage
|
setImage,
|
||||||
|
listUpdatedAt: evt.created_at ?? null
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
|
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||||
|
|
||||||
|
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
|
...processedPub.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class EventManager {
|
|||||||
|
|
||||||
// Safety timeout for event fetches (ms)
|
// Safety timeout for event fetches (ms)
|
||||||
private fetchTimeoutMs = 12000
|
private fetchTimeoutMs = 12000
|
||||||
|
// Retry policy
|
||||||
|
private maxAttempts = 4
|
||||||
|
private baseBackoffMs = 700
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the event manager with event store and relay pool
|
* Initialize the event manager with event store and relay pool
|
||||||
@@ -70,7 +73,7 @@ class EventManager {
|
|||||||
|
|
||||||
// Start a new fetch request
|
// Start a new fetch request
|
||||||
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
||||||
this.fetchFromRelay(eventId)
|
this.fetchFromRelayWithRetry(eventId, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +89,14 @@ class EventManager {
|
|||||||
requests.forEach(req => req.reject(error))
|
requests.forEach(req => req.reject(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
|
||||||
* Actually fetch the event from relay
|
|
||||||
*/
|
|
||||||
private fetchFromRelay(eventId: string): void {
|
|
||||||
// If no loader yet, schedule retry
|
// If no loader yet, schedule retry
|
||||||
if (!this.relayPool || !this.eventLoader) {
|
if (!this.relayPool || !this.eventLoader) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.eventLoader && this.pendingRequests.has(eventId)) {
|
if (this.pendingRequests.has(eventId)) {
|
||||||
this.fetchFromRelay(eventId)
|
this.fetchFromRelayWithRetry(eventId, attempt)
|
||||||
}
|
}
|
||||||
}, 500)
|
}, this.baseBackoffMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,14 +111,23 @@ class EventManager {
|
|||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const error = err instanceof Error ? err : new Error(String(err))
|
const error = err instanceof Error ? err : new Error(String(err))
|
||||||
this.rejectPending(eventId, error)
|
// Retry on error until attempts exhausted
|
||||||
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
|
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||||
|
} else {
|
||||||
|
this.rejectPending(eventId, error)
|
||||||
|
}
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
// Completed without next - consider not found
|
// Completed without next - consider not found, but retry a few times
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
this.rejectPending(eventId, new Error('Event not found'))
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
|
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||||
|
} else {
|
||||||
|
this.rejectPending(eventId, new Error('Event not found'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
@@ -127,8 +136,13 @@ class EventManager {
|
|||||||
// Safety timeout
|
// Safety timeout
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
|
this.fetchFromRelayWithRetry(eventId, attempt + 1)
|
||||||
|
} else {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, this.fetchTimeoutMs)
|
}, this.fetchTimeoutMs)
|
||||||
}
|
}
|
||||||
@@ -139,7 +153,7 @@ class EventManager {
|
|||||||
private retryAllPending(): void {
|
private retryAllPending(): void {
|
||||||
const pendingIds = Array.from(this.pendingRequests.keys())
|
const pendingIds = Array.from(this.pendingRequests.keys())
|
||||||
pendingIds.forEach(eventId => {
|
pendingIds.forEach(eventId => {
|
||||||
this.fetchFromRelay(eventId)
|
this.fetchFromRelayWithRetry(eventId, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,17 @@ export function processReadingProgress(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if (dTag.startsWith('url:')) {
|
} else if (dTag.startsWith('url:')) {
|
||||||
// It's a URL with base64url encoding
|
// It's a URL. We support both raw URLs and base64url-encoded URLs.
|
||||||
const encoded = dTag.replace('url:', '')
|
const value = dTag.slice(4)
|
||||||
|
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
|
||||||
try {
|
try {
|
||||||
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
if (looksBase64Url) {
|
||||||
|
// Decode base64url to raw URL
|
||||||
|
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
} else {
|
||||||
|
// Treat as raw URL (already decoded)
|
||||||
|
itemUrl = value
|
||||||
|
}
|
||||||
itemId = itemUrl
|
itemId = itemUrl
|
||||||
itemType = 'external'
|
itemType = 'external'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
|||||||
if (naddrOrUrl.startsWith('nostr:')) {
|
if (naddrOrUrl.startsWith('nostr:')) {
|
||||||
return naddrOrUrl.replace('nostr:', '')
|
return naddrOrUrl.replace('nostr:', '')
|
||||||
}
|
}
|
||||||
// For URLs, use base64url encoding (URL-safe)
|
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
|
||||||
return btoa(naddrOrUrl)
|
return naddrOrUrl
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,8 +135,148 @@ export async function saveReadingPosition(
|
|||||||
await publishEvent(relayPool, eventStore, signed)
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming reading position loader (non-blocking, EOSE-driven)
|
||||||
|
* Seeds from local eventStore, streams relay updates to store in background
|
||||||
|
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||||
|
*/
|
||||||
|
export function startReadingPositionStream(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
articleIdentifier: string,
|
||||||
|
onPosition: (pos: ReadingPosition | null) => void
|
||||||
|
): () => void {
|
||||||
|
const dTag = generateDTag(articleIdentifier)
|
||||||
|
|
||||||
|
// 1) Seed from local replaceable immediately and watch for updates
|
||||||
|
const storeSub = eventStore
|
||||||
|
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||||
|
.subscribe((event: NostrEvent | undefined) => {
|
||||||
|
if (!event) {
|
||||||
|
onPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed = getReadingProgressContent(event)
|
||||||
|
onPosition(parsed || null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||||
|
const networkSub = relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [READING_PROGRESS_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
// Caller manages lifecycle
|
||||||
|
return () => {
|
||||||
|
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||||
|
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stabilized reading position collector
|
||||||
|
* Collects position updates for a brief window, then emits the best one (newest, then highest progress)
|
||||||
|
* @returns Object with stop() to cancel and onStable(cb) to register callback
|
||||||
|
*/
|
||||||
|
export function collectReadingPositionsOnce(params: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
articleIdentifier: string
|
||||||
|
windowMs?: number
|
||||||
|
}): { stop: () => void; onStable: (cb: (pos: ReadingPosition | null) => void) => void } {
|
||||||
|
const { relayPool, eventStore, pubkey, articleIdentifier, windowMs = 700 } = params
|
||||||
|
|
||||||
|
const candidates: ReadingPosition[] = []
|
||||||
|
let stableCallback: ((pos: ReadingPosition | null) => void) | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let streamStop: (() => void) | null = null
|
||||||
|
let hasEmitted = false
|
||||||
|
|
||||||
|
const emitStable = () => {
|
||||||
|
if (hasEmitted || !stableCallback) return
|
||||||
|
hasEmitted = true
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.log('[reading-position] 📊 No candidates collected during stabilization window')
|
||||||
|
stableCallback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[reading-position] 📊 Collected', candidates.length, 'position candidates:',
|
||||||
|
candidates.map(c => `${Math.round(c.position * 100)}% @${new Date(c.timestamp * 1000).toLocaleTimeString()}`).join(', '))
|
||||||
|
|
||||||
|
// Sort: newest first, then highest progress
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const timeDiff = b.timestamp - a.timestamp
|
||||||
|
if (timeDiff !== 0) return timeDiff
|
||||||
|
return b.position - a.position
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[reading-position] ✅ Best position selected:', Math.round(candidates[0].position * 100) + '%',
|
||||||
|
'from', new Date(candidates[0].timestamp * 1000).toLocaleTimeString())
|
||||||
|
stableCallback(candidates[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start streaming and collecting
|
||||||
|
console.log('[reading-position] 🎯 Starting stabilized position collector (window:', windowMs, 'ms)')
|
||||||
|
streamStop = startReadingPositionStream(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey,
|
||||||
|
articleIdentifier,
|
||||||
|
(pos) => {
|
||||||
|
if (hasEmitted) return
|
||||||
|
if (!pos) {
|
||||||
|
console.log('[reading-position] 📥 Received null position')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pos.position <= 0.05 || pos.position >= 1) {
|
||||||
|
console.log('[reading-position] 🚫 Ignoring position', Math.round(pos.position * 100) + '% (outside 5%-100% range)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[reading-position] 📥 Received position candidate:', Math.round(pos.position * 100) + '%',
|
||||||
|
'from', new Date(pos.timestamp * 1000).toLocaleTimeString())
|
||||||
|
candidates.push(pos)
|
||||||
|
|
||||||
|
// Schedule one-shot emission if not already scheduled
|
||||||
|
if (!timer) {
|
||||||
|
console.log('[reading-position] ⏰ Starting', windowMs, 'ms stabilization timer')
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
emitStable()
|
||||||
|
if (streamStop) streamStop()
|
||||||
|
}, windowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (streamStop) {
|
||||||
|
streamStop()
|
||||||
|
streamStop = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStable: (cb) => {
|
||||||
|
stableCallback = cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load reading position from Nostr (kind 39802)
|
* Load reading position from Nostr (kind 39802)
|
||||||
|
* @deprecated Use startReadingPositionStream for non-blocking behavior
|
||||||
|
* Returns current local position immediately (or null) and starts background sync
|
||||||
*/
|
*/
|
||||||
export async function loadReadingPosition(
|
export async function loadReadingPosition(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -149,101 +286,29 @@ export async function loadReadingPosition(
|
|||||||
): Promise<ReadingPosition | null> {
|
): Promise<ReadingPosition | null> {
|
||||||
const dTag = generateDTag(articleIdentifier)
|
const dTag = generateDTag(articleIdentifier)
|
||||||
|
|
||||||
// Check local event store first
|
let initial: ReadingPosition | null = null
|
||||||
try {
|
try {
|
||||||
const localEvent = await firstValueFrom(
|
const localEvent = await firstValueFrom(
|
||||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getReadingProgressContent(localEvent)
|
const content = getReadingProgressContent(localEvent)
|
||||||
if (content) {
|
if (content) initial = content
|
||||||
// Fetch from relays in background to get any updates
|
|
||||||
relayPool
|
|
||||||
.subscription(RELAYS, {
|
|
||||||
kinds: [READING_PROGRESS_KIND],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': [dTag]
|
|
||||||
})
|
|
||||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Ignore errors and fetch from relays
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from relays
|
// Start background sync (fire-and-forget; no timeout)
|
||||||
const result = await fetchFromRelays(
|
relayPool
|
||||||
relayPool,
|
.subscription(RELAYS, {
|
||||||
eventStore,
|
kinds: [READING_PROGRESS_KIND],
|
||||||
pubkey,
|
authors: [pubkey],
|
||||||
READING_PROGRESS_KIND,
|
'#d': [dTag]
|
||||||
dTag,
|
})
|
||||||
getReadingProgressContent
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
)
|
.subscribe()
|
||||||
|
|
||||||
return result || null
|
return initial
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
hasResolved = true
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
const sub = relayPool
|
|
||||||
.subscription(RELAYS, {
|
|
||||||
kinds: [kind],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': [dTag]
|
|
||||||
})
|
|
||||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
|
||||||
.subscribe({
|
|
||||||
complete: async () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true
|
|
||||||
try {
|
|
||||||
const event = await firstValueFrom(
|
|
||||||
eventStore.replaceable(kind, pubkey, dTag)
|
|
||||||
)
|
|
||||||
if (event) {
|
|
||||||
const content = parser(event)
|
|
||||||
resolve(content || null)
|
|
||||||
} else {
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
sub.unsubscribe()
|
|
||||||
}, 3000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,10 +276,10 @@ class ReadingProgressController {
|
|||||||
// Process new events
|
// Process new events
|
||||||
processReadingProgress(events, readsMap)
|
processReadingProgress(events, readsMap)
|
||||||
|
|
||||||
// Convert back to progress map (naddr -> progress)
|
// Convert back to progress map (id -> progress). Include both articles and external URLs.
|
||||||
const newProgressMap = new Map<string, number>()
|
const newProgressMap = new Map<string, number>()
|
||||||
for (const [id, item] of readsMap.entries()) {
|
for (const [id, item] of readsMap.entries()) {
|
||||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
if (item.readingProgress !== undefined) {
|
||||||
newProgressMap.set(id, item.readingProgress)
|
newProgressMap.set(id, item.readingProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export async function fetchAllReads(
|
|||||||
source: 'bookmark',
|
source: 'bookmark',
|
||||||
type: 'article',
|
type: 'article',
|
||||||
readingProgress: 0,
|
readingProgress: 0,
|
||||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
readingTimestamp: bookmark.created_at ?? undefined
|
||||||
}
|
}
|
||||||
readsMap.set(coordinate, item)
|
readsMap.set(coordinate, item)
|
||||||
if (onItem) emitItem(item)
|
if (onItem) emitItem(item)
|
||||||
|
|||||||
@@ -75,90 +75,82 @@ export interface UserSettings {
|
|||||||
ttsDefaultSpeed?: number // default: 2.1
|
ttsDefaultSpeed?: number // default: 2.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming settings loader (non-blocking, EOSE-driven)
|
||||||
|
* Seeds from local eventStore, streams relay updates to store in background
|
||||||
|
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||||
|
*/
|
||||||
|
export function startSettingsStream(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
relays: string[],
|
||||||
|
onSettings: (settings: UserSettings | null) => void
|
||||||
|
): () => void {
|
||||||
|
// 1) Seed from local replaceable immediately and watch for updates
|
||||||
|
const storeSub = eventStore
|
||||||
|
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||||
|
.subscribe((event: NostrEvent | undefined) => {
|
||||||
|
if (!event) {
|
||||||
|
onSettings(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = getAppDataContent<UserSettings>(event)
|
||||||
|
onSettings(content || null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||||
|
const networkSub = relayPool
|
||||||
|
.subscription(relays, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [SETTINGS_IDENTIFIER]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
// Caller manages lifecycle
|
||||||
|
return () => {
|
||||||
|
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||||
|
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
|
||||||
|
* Returns current local settings immediately (or null if not present) and starts background sync.
|
||||||
|
*/
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
eventStore: IEventStore,
|
eventStore: IEventStore,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<UserSettings | null> {
|
): Promise<UserSettings | null> {
|
||||||
|
let initial: UserSettings | null = null
|
||||||
// First, check if we already have settings in the local event store
|
|
||||||
try {
|
try {
|
||||||
const localEvent = await firstValueFrom(
|
const localEvent = await firstValueFrom(
|
||||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getAppDataContent<UserSettings>(localEvent)
|
const content = getAppDataContent<UserSettings>(localEvent)
|
||||||
|
initial = content || null
|
||||||
// Still fetch from relays in the background to get any updates
|
|
||||||
relayPool
|
|
||||||
.subscription(relays, {
|
|
||||||
kinds: [APP_DATA_KIND],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': [SETTINGS_IDENTIFIER]
|
|
||||||
})
|
|
||||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
return content || null
|
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch {
|
||||||
// Ignore local store errors
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local store, fetch from relays
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let hasResolved = false
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (!hasResolved) {
|
|
||||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
|
||||||
hasResolved = true
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
const sub = relayPool
|
// Start background sync (fire-and-forget; no timeout)
|
||||||
.subscription(relays, {
|
relayPool
|
||||||
kinds: [APP_DATA_KIND],
|
.subscription(relays, {
|
||||||
authors: [pubkey],
|
kinds: [APP_DATA_KIND],
|
||||||
'#d': [SETTINGS_IDENTIFIER]
|
authors: [pubkey],
|
||||||
})
|
'#d': [SETTINGS_IDENTIFIER]
|
||||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
})
|
||||||
.subscribe({
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
complete: async () => {
|
.subscribe()
|
||||||
clearTimeout(timeout)
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true
|
|
||||||
try {
|
|
||||||
const event = await firstValueFrom(
|
|
||||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
|
||||||
)
|
|
||||||
if (event) {
|
|
||||||
const content = getAppDataContent<UserSettings>(event)
|
|
||||||
resolve(content || null)
|
|
||||||
} else {
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Error loading settings:', err)
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error('❌ Settings subscription error:', err)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTimeout(() => {
|
return initial
|
||||||
sub.unsubscribe()
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
.reading-time svg { font-size: 0.875rem; }
|
.reading-time svg { font-size: 0.875rem; }
|
||||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
||||||
.highlight-indicator svg { font-size: 0.875rem; }
|
.highlight-indicator svg { font-size: 0.875rem; }
|
||||||
|
.highlight-indicator.clickable { cursor: pointer; transition: all 0.2s ease; }
|
||||||
|
.highlight-indicator.clickable:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
|
||||||
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||||
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||||
/* Ensure font inheritance */
|
/* Ensure font inheritance */
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
|
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
.profile-avatar-button {
|
||||||
min-width: 33px;
|
min-width: 33px;
|
||||||
min-height: 33px;
|
min-height: 33px;
|
||||||
width: 33px;
|
width: 33px;
|
||||||
@@ -108,10 +110,27 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
/* Mobile touch target improvements */
|
||||||
.profile-avatar svg { font-size: 1rem; }
|
@media (max-width: 768px) {
|
||||||
|
.profile-avatar-button {
|
||||||
|
min-width: var(--min-touch-target);
|
||||||
|
min-height: var(--min-touch-target);
|
||||||
|
width: var(--min-touch-target);
|
||||||
|
height: var(--min-touch-target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-button:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.profile-avatar-button svg { font-size: 1rem; }
|
||||||
|
|
||||||
.sidebar-header-bar .toggle-sidebar-btn {
|
.sidebar-header-bar .toggle-sidebar-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export interface Bookmark {
|
|||||||
export interface IndividualBookmark {
|
export interface IndividualBookmark {
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
created_at: number
|
// Timestamp when the content was created (from the content event itself)
|
||||||
|
created_at: number | null
|
||||||
pubkey: string
|
pubkey: string
|
||||||
kind: number
|
kind: number
|
||||||
tags: string[][]
|
tags: string[][]
|
||||||
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
|
|||||||
type: 'event' | 'article' | 'web'
|
type: 'event' | 'article' | 'web'
|
||||||
isPrivate?: boolean
|
isPrivate?: boolean
|
||||||
encryptedContent?: string
|
encryptedContent?: string
|
||||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
|
||||||
added_at?: number
|
|
||||||
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
|
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
|
||||||
sourceKind?: number
|
sourceKind?: number
|
||||||
// The 'd' tag value from kind 30003 bookmark sets
|
// The 'd' tag value from kind 30003 bookmark sets
|
||||||
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
|
|||||||
setTitle?: string
|
setTitle?: string
|
||||||
setDescription?: string
|
setDescription?: string
|
||||||
setImage?: string
|
setImage?: string
|
||||||
|
// Timestamp of the bookmark list event (best proxy for "when bookmarked")
|
||||||
|
// Note: This is imperfect - it's when the list was last updated, not necessarily when this item was added
|
||||||
|
listUpdatedAt?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveAccount {
|
export interface ActiveAccount {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmark
|
|||||||
import ResolvedMention from '../components/ResolvedMention'
|
import ResolvedMention from '../components/ResolvedMention'
|
||||||
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
|
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
|
||||||
|
|
||||||
export const formatDate = (timestamp: number) => {
|
export const formatDate = (timestamp: number | null | undefined) => {
|
||||||
|
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
return formatDistanceToNow(date, { addSuffix: true })
|
return formatDistanceToNow(date, { addSuffix: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ultra-compact date format for tight spaces (e.g., compact view)
|
// Ultra-compact date format for tight spaces (e.g., compact view)
|
||||||
export const formatDateCompact = (timestamp: number) => {
|
export const formatDateCompact = (timestamp: number | null | undefined) => {
|
||||||
|
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
@@ -85,9 +87,8 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
|||||||
|
|
||||||
// Sorting and grouping for bookmarks
|
// Sorting and grouping for bookmarks
|
||||||
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||||
return items
|
const getSortTime = (b: IndividualBookmark) => b.created_at ?? b.listUpdatedAt ?? -Infinity
|
||||||
.slice()
|
return items.slice().sort((a, b) => getSortTime(b) - getSortTime(a))
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||||
@@ -122,10 +123,7 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
|
|||||||
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
|
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
|
||||||
if (!bookmark.created_at) return false
|
if (!bookmark.created_at) return false
|
||||||
// If timestamp is missing or equals current time (within 1 second), consider it invalid
|
// If timestamp is missing or equals current time (within 1 second), consider it invalid
|
||||||
const now = Math.floor(Date.now() / 1000)
|
return true
|
||||||
const createdAt = Math.floor(bookmark.created_at)
|
|
||||||
// If created_at is within 1 second of now, it's likely missing/placeholder
|
|
||||||
return Math.abs(createdAt - now) > 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark sets helpers (kind 30003)
|
// Bookmark sets helpers (kind 30003)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
|||||||
summary,
|
summary,
|
||||||
image,
|
image,
|
||||||
readingProgress: 0,
|
readingProgress: 0,
|
||||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
readingTimestamp: bookmark.created_at ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
linksMap.set(url, item)
|
linksMap.set(url, item)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
|||||||
source: 'bookmark',
|
source: 'bookmark',
|
||||||
type: 'article',
|
type: 'article',
|
||||||
readingProgress: 0,
|
readingProgress: 0,
|
||||||
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
readingTimestamp: bookmark.created_at ?? undefined,
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
image,
|
image,
|
||||||
|
|||||||
Reference in New Issue
Block a user