mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ba620967 | ||
|
|
56f2d33e93 | ||
|
|
312c742969 | ||
|
|
0781c4ebfc | ||
|
|
85f4cd3590 | ||
|
|
89bc6258b1 | ||
|
|
534b628aea | ||
|
|
317d2e0b53 | ||
|
|
9ea69589fa | ||
|
|
89eaa97d30 | ||
|
|
0283405fb5 | ||
|
|
5eade913d1 | ||
|
|
15a7129b6d | ||
|
|
b9e17e0982 | ||
|
|
1be8c62c94 | ||
|
|
e2bf243b01 | ||
|
|
85d816b2a7 | ||
|
|
623bee4632 | ||
|
|
e68b97bde8 | ||
|
|
ca32dfca51 | ||
|
|
9de8b00d5d | ||
|
|
033ef5e995 | ||
|
|
c986b0d517 | ||
|
|
1729a5b066 | ||
|
|
c6186ea84e | ||
|
|
c798376411 | ||
|
|
e83c301e6a | ||
|
|
2c0aee3fe4 | ||
|
|
d0f043fb5a | ||
|
|
039b988869 | ||
|
|
d285003e1d | ||
|
|
530abeeb33 | ||
|
|
3ac6954cb7 | ||
|
|
1c0f619a47 | ||
|
|
0fcfd200a4 | ||
|
|
e01c8d33fc | ||
|
|
51c0f7d923 | ||
|
|
8c79b5fd75 | ||
|
|
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 | ||
|
|
366e10b23a | ||
|
|
bb66823915 | ||
|
|
f09973c858 | ||
|
|
d03726801d | ||
|
|
164e941a1f | ||
|
|
6def58f128 | ||
|
|
347e23ff6f | ||
|
|
934768ebf2 | ||
|
|
60e9ede9cf | ||
|
|
c70e6bc2aa | ||
|
|
ab8665815b | ||
|
|
1929b50892 | ||
|
|
160dca628d | ||
|
|
c04ba0c787 | ||
|
|
479d9314bd | ||
|
|
b9d5e501f4 | ||
|
|
43e0dd76c4 | ||
|
|
dc9a49e895 | ||
|
|
3200bdf378 | ||
|
|
2254586960 | ||
|
|
18c78c19be | ||
|
|
167d5f2041 | ||
|
|
cce7507e50 | ||
|
|
e83d4dbcdb | ||
|
|
a5bdde68fc | ||
|
|
5551cc3a55 | ||
|
|
145ff138b0 | ||
|
|
5bd5686805 | ||
|
|
d2c1a16ca6 | ||
|
|
b8242312b5 | ||
|
|
96ef227f79 | ||
|
|
30ed5fb436 | ||
|
|
42d7143845 | ||
|
|
f02bc21faf | ||
|
|
0f5d42465d | ||
|
|
004367bab6 | ||
|
|
312adea9f9 | ||
|
|
a081b26333 | ||
|
|
51e48804fe | ||
|
|
e08ce0e477 | ||
|
|
2791c69ebe | ||
|
|
96451e6173 | ||
|
|
d20cc684c3 | ||
|
|
4316c46a4d | ||
|
|
e382310c88 | ||
|
|
e6b99490dd | ||
|
|
09ee05861d | ||
|
|
205988a6b0 | ||
|
|
8012752a39 | ||
|
|
c3302da11d | ||
|
|
60e1e3c821 | ||
|
|
6c2247249a | ||
|
|
33a31df2b4 | ||
|
|
f9dda1c5d4 | ||
|
|
6522a2871c | ||
|
|
f39b926e7b | ||
|
|
144cf5cbd1 | ||
|
|
4b9de7cd07 | ||
|
|
2be58332bb | ||
|
|
6fc93cbd0f | ||
|
|
5df426a863 | ||
|
|
8ca4671bea | ||
|
|
ad1a808c6d | ||
|
|
ae118a0581 | ||
|
|
3cddcd850e | ||
|
|
cadf4dcb48 | ||
|
|
47d257faaf | ||
|
|
f542cee4cc | ||
|
|
8274eb26c2 | ||
|
|
35018fef91 | ||
|
|
1fd08bb64a | ||
|
|
d953542c93 | ||
|
|
8c0b73ad0c | ||
|
|
a5d2ed8b07 | ||
|
|
67fec91ab3 | ||
|
|
868fe68ce2 | ||
|
|
66c4bfc449 | ||
|
|
29918f78f9 | ||
|
|
18fcf6064e | ||
|
|
35766d5691 | ||
|
|
7450ba4251 | ||
|
|
95c770c083 | ||
|
|
14a7e1138e | ||
|
|
9c45c71c8a | ||
|
|
23b9224272 | ||
|
|
bcd4a12542 | ||
|
|
d82e22ce1c | ||
|
|
ea5c173745 | ||
|
|
a214c487cc | ||
|
|
43f56fc29a | ||
|
|
cfbc3efeeb | ||
|
|
bb9e98ff16 | ||
|
|
073bb3867f | ||
|
|
1ac7fb26b2 | ||
|
|
a551234a29 | ||
|
|
227f062456 | ||
|
|
6c42ee88ea | ||
|
|
fc138f3ceb | ||
|
|
831f701c04 | ||
|
|
94b9d89225 | ||
|
|
2793a6dd44 | ||
|
|
9086692e29 | ||
|
|
f8c4bbb99c | ||
|
|
b14842c6fe | ||
|
|
7cdf0673bd | ||
|
|
bbed20d679 | ||
|
|
7594d30fd2 | ||
|
|
67506d9040 | ||
|
|
e2d0bc2acf | ||
|
|
2283f4ec08 | ||
|
|
463ac8f44c | ||
|
|
e2de6f2d91 | ||
|
|
fdb52fe3b2 | ||
|
|
ae14064822 | ||
|
|
5526bfc425 | ||
|
|
b3f4b03229 | ||
|
|
b92f5716dc | ||
|
|
177f8c1e70 | ||
|
|
0407769206 | ||
|
|
eb75e7722d | ||
|
|
81aa414d2e | ||
|
|
c82fb65745 | ||
|
|
cc1b9f042f | ||
|
|
c2bf4b4a9a | ||
|
|
13a47e4fdc | ||
|
|
24b652847c | ||
|
|
c623dc8d84 | ||
|
|
31987010b8 | ||
|
|
b3206d5e79 | ||
|
|
34f44c59b5 | ||
|
|
a51fbd25d7 | ||
|
|
95f6949ab7 | ||
|
|
1e613bd2a2 | ||
|
|
95b882b0d1 | ||
|
|
be00f1434d | ||
|
|
568890e131 | ||
|
|
f000ac3be1 | ||
|
|
2fed1cc6e7 | ||
|
|
4bdcfcaeb4 | ||
|
|
a5494ba15c | ||
|
|
64aad42be3 | ||
|
|
3673849a9a | ||
|
|
c6795f7c18 | ||
|
|
b27f26b639 | ||
|
|
975399e293 | ||
|
|
53b8356373 | ||
|
|
8c5225b271 | ||
|
|
dfac7a5089 | ||
|
|
9fe09b813b | ||
|
|
ea30c136f2 | ||
|
|
623856ffe9 | ||
|
|
d08071def2 | ||
|
|
556e8f2f7d | ||
|
|
9ab6847501 | ||
|
|
31afe3792e | ||
|
|
ebe8ecf63b | ||
|
|
c418000a0c | ||
|
|
15fd19f6a4 | ||
|
|
2a44b4e3c0 | ||
|
|
aa7807e3d2 | ||
|
|
359d3d0dd6 | ||
|
|
d40b3c0048 | ||
|
|
7b4ca50b16 | ||
|
|
76e001aba4 | ||
|
|
0b42aeb383 | ||
|
|
a4554e5176 | ||
|
|
2e844fc26b | ||
|
|
8c0a4cac16 | ||
|
|
c6eccc9589 | ||
|
|
2e5536c331 | ||
|
|
fc025b9579 | ||
|
|
88db14c352 |
@@ -2,4 +2,4 @@
|
||||
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.
|
||||
612
CHANGELOG.md
612
CHANGELOG.md
@@ -7,6 +7,558 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.17] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Setting to control auto-scroll to reading position
|
||||
- New toggle in Settings > Reading Experience
|
||||
- Allows users to disable automatic scroll restoration
|
||||
- Defaults to enabled (preserves existing behavior)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Blockquote styling on mobile devices
|
||||
- Added equal right padding to match left padding (2rem on both sides)
|
||||
- Prevents awkward text cutoff on narrow screens
|
||||
- Timestamp clicks in highlight cards now navigate within app
|
||||
- Articles (kind:30023) open via `/a/{naddr}` route
|
||||
- External URLs open via `/r/{encodedUrl}` route
|
||||
- Previously opened external search portal (ants.sh)
|
||||
- Highlight automatically scrolls into view with sidebar open
|
||||
- Hero images now properly extend edge-to-edge on mobile
|
||||
- Adjusted negative margins to match new reader padding
|
||||
- Image bleeds to screen edges while text maintains comfortable margins
|
||||
- Article relay links now open via `/a/` path instead of `/r/`
|
||||
- Ensures nostr-native articles route correctly
|
||||
- External links continue to use `/r/` path
|
||||
|
||||
### Changed
|
||||
|
||||
- Mobile reader padding increased for better readability
|
||||
- Horizontal padding increased from 0.5rem to 1rem
|
||||
- Title, summary, and body text now properly aligned
|
||||
- More comfortable reading experience on small screens
|
||||
- Reading position save interval reduced from 3s to 1s
|
||||
- More frequent auto-saves during active reading
|
||||
- Better preservation of reading progress
|
||||
|
||||
## [0.10.16] - 2025-10-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position auto-save now works correctly during continuous scrolling
|
||||
- Fixed critical bug where save timer was cleared when tracking toggled
|
||||
- Timer now persists across tracking state changes
|
||||
- Saves fire reliably every 3 seconds during active reading
|
||||
- Throttle mechanism now works as intended
|
||||
- Reading position tracking stability improved
|
||||
- Tracking state no longer toggles erratically
|
||||
- Content stability checks refined to prevent false negatives
|
||||
- Infinite loop fixed in position save handler
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading position save mechanism changed from debounce to throttle
|
||||
- Ensures saves happen at regular 3-second intervals during continuous scrolling
|
||||
- Previous debounce approach could skip saves during slow continuous scrolling
|
||||
- More predictable save behavior for users
|
||||
- Simplified reading position logic by removing unused complexity
|
||||
- Removed 5% delta requirement for scheduling saves
|
||||
- Removed unnecessary state tracking (lastSavedPosition, hasSavedOnce, lastSavedAtRef)
|
||||
- Cleaner, more maintainable code
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights now scroll into view when clicked from `/my/highlights` page
|
||||
- Navigation state properly passes highlight ID and openHighlights flag
|
||||
- Works for both article links and external URL links
|
||||
|
||||
## [0.10.15] - 2025-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading position restore now uses pre-loaded data from controller
|
||||
- No longer fetches position from scratch when opening articles
|
||||
- Uses position already loaded and displayed on bookmark cards
|
||||
- Faster restore with no network wait
|
||||
- Simpler code without stabilization window complexity
|
||||
- Reading position scroll animation restored to smooth behavior
|
||||
- Changed from instant jump back to smooth animated scroll
|
||||
- Better user experience when restoring position
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position no longer saves 0% during back navigation on mobile
|
||||
- Removed save-on-unmount behavior that was error-prone
|
||||
- Browser scroll-to-top during back gesture no longer overwrites progress
|
||||
- Auto-save with 3-second debounce is sufficient for normal reading
|
||||
- Prevents accidental reset of reading progress when navigating away
|
||||
|
||||
## [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 `/my/reading-list` to `/my/bookmarks`
|
||||
- Router updated to handle `/my/reading-list` → `/my/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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Profile pages now display all writings correctly
|
||||
- Events are now stored in eventStore as they stream in from relays
|
||||
- `fetchBlogPostsFromAuthors` now accepts `eventStore` parameter like other fetch functions
|
||||
- Ensures all writings appear on `/p/` routes, not just the first few
|
||||
- Background fetching of highlights and writings uses consistent patterns
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified profile background fetching logic for better maintainability
|
||||
- Extracted relay URLs to variable for clarity
|
||||
- Consistent error handling patterns across fetch functions
|
||||
- Clearer comments about no-limit fetching behavior
|
||||
|
||||
## [0.10.6] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Text-to-speech reliability improvements
|
||||
- Chunking support for long-form content to prevent WebSpeech API cutoffs
|
||||
- Automatic chunk-based resumption for interrupted playback
|
||||
- Better handling of content exceeding browser TTS limits
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tab switching regression on `/my` page
|
||||
- Resolved infinite update loop caused by circular dependency in `useCallback` hooks
|
||||
- Tab navigation now properly updates UI when URL changes
|
||||
- Removed `loadedTabs` from dependency arrays to prevent re-render cycles
|
||||
- Explore page data loading patterns
|
||||
- Implemented subscribe-first, non-blocking loading model
|
||||
- Removed all timeouts in favor of immediate subscription and progressive hydration
|
||||
- Contacts, writings, and highlights now stream results as they arrive
|
||||
- Nostrverse content loads in background without blocking UI
|
||||
- Text-to-speech handler cleanup
|
||||
- Removed no-op self-assignment in rate change handler
|
||||
|
||||
## [0.10.4] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Web Share Target support for PWA (system-level share integration)
|
||||
- Boris can now receive shared URLs from other apps on mobile and desktop
|
||||
- Implements POST-based Web Share Target API per Chrome standards
|
||||
- Service worker intercepts share requests and redirects to handler route
|
||||
- ShareTargetHandler component auto-saves shared URLs as web bookmarks
|
||||
- Android compatibility with URL extraction from text field when url param is missing
|
||||
- Automatic navigation to bookmarks list after successful save
|
||||
- Login prompt when sharing while logged out
|
||||
|
||||
### Changed
|
||||
|
||||
- Manifest now includes `share_target` configuration for system share menu integration
|
||||
- Service worker handles POST requests to `/share-target` endpoint
|
||||
- Added `/share-target` route for processing incoming shared content
|
||||
|
||||
## [0.10.3] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Content filtering setting to hide articles posted by bots
|
||||
- New "Hide content posted by bots" checkbox in Explore settings (enabled by default)
|
||||
- Filters articles where author's profile name or display_name contains "bot" (case-insensitive)
|
||||
- Applies to both Explore page and Me section writings
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved all linting and type checking issues
|
||||
- Added missing React Hook dependencies to `useMemo` and `useEffect`
|
||||
- Wrapped loader functions in `useCallback` to prevent unnecessary re-renders
|
||||
- Removed unused variables (`queryTime`, `startTime`, `allEvents`)
|
||||
- All ESLint warnings and TypeScript errors now resolved
|
||||
|
||||
## [0.10.2] - 2025-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- Text-to-speech (TTS) speaker language selection mode
|
||||
- New "Speaker language" dropdown in TTS settings (system or content)
|
||||
- Detects content language using tinyld for accurate voice matching
|
||||
- Falls back to system language when content detection unavailable
|
||||
- Top 10 languages featured in dropdown for quick access
|
||||
- TTS example text section in settings
|
||||
- Test TTS voices directly in the settings panel
|
||||
- Uses Boris mission statement as example text
|
||||
- Real-time speaker selection testing
|
||||
|
||||
### Changed
|
||||
|
||||
- TTS language selection now uses "Speaker language" terminology
|
||||
- Distinguishes between American English (en-US) and British English (en-GB)
|
||||
- Improved language detection with content-aware voice selection
|
||||
- Streamlined dropdown for better UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- TTS voice detection and selection logic
|
||||
- Proper empty catch block handling instead of silently failing
|
||||
- Consistent use of `setting-select` class for dropdown styling
|
||||
- Improved dropdown spacing with adequate padding-right
|
||||
|
||||
## [0.10.0] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Centralized bookmark loading with streaming and auto-decrypt
|
||||
- Bookmarks now load progressively with streaming updates
|
||||
- Auto-decrypt bookmarks as they arrive from relays
|
||||
- Individual decrypt buttons for encrypted bookmark events
|
||||
- Centralized bookmark controller for consistent loading across the app
|
||||
- Enhanced debug page with comprehensive diagnostics
|
||||
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||
- Live performance timing with stopwatch display
|
||||
- Bookmark loading and decryption diagnostics
|
||||
- Real-time bunker logs with filtering and clearing
|
||||
- Version and git commit footer
|
||||
- Bunker (NIP-46) authentication support
|
||||
- Support for remote signing via Nostr Connect protocol
|
||||
- Bunker URI input with validation and error handling
|
||||
- Automatic reconnection on app restore with proper permissions
|
||||
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved bookmark loading performance
|
||||
- Non-blocking, progressive bookmark updates via callback pattern
|
||||
- Batched background hydration using EventLoader and AddressLoader
|
||||
- Shorter timeouts for debug page bookmark loading
|
||||
- Sequential decryption instead of concurrent to avoid queue issues
|
||||
- Enhanced bunker error messages
|
||||
- Formatted error messages with signer suggestions
|
||||
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||
- Better error handling for missing signer extensions
|
||||
- Centralized bookmark loading architecture
|
||||
- Single shared bookmark controller for consistent loading
|
||||
- Unified bookmark loading with streaming and auto-decrypt
|
||||
- Consolidated bookmark loading into single centralized function
|
||||
|
||||
### Fixed
|
||||
|
||||
- NIP-46 bunker signing and decryption
|
||||
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||
- Bunker relays added to relay pool for signing requests
|
||||
- Proper setup of pool and relays before bunker reconnection
|
||||
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||
- Wait for bunker relay connections before marking signer ready
|
||||
- Validate bunker URI (remote must differ from user pubkey)
|
||||
- Accept remote===pubkey for Amber compatibility
|
||||
- Bookmark loading and decryption
|
||||
- Bookmarks load and complete properly with streaming
|
||||
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||
- Include decrypted private bookmarks in sidebar
|
||||
- Skip background event fetching when there are too many IDs
|
||||
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||
- Make controller onEvent non-blocking for queryEvents completion
|
||||
- Proper timeout handling for bookmark decryption (no hanging)
|
||||
- Smart encryption detection with consistent padlock display
|
||||
- Sequential decryption instead of concurrent to avoid queue issues
|
||||
- Add extraRelays to EventLoader and AddressLoader
|
||||
- TypeScript and linting errors throughout
|
||||
- Replace empty catch blocks with warnings
|
||||
- Fix explicit any types
|
||||
- Add missing useEffect dependencies
|
||||
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||
|
||||
### Performance
|
||||
|
||||
- Non-blocking NIP-46 operations
|
||||
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||
- Non-blocking bookmark decryption with sequential processing
|
||||
- Make controller onEvent non-blocking for queryEvents completion
|
||||
- Optimized bookmark loading
|
||||
- Batched background hydration using EventLoader and AddressLoader
|
||||
- Progressive, non-blocking bookmark loading with streaming
|
||||
- Shorter timeouts for debug page bookmark loading
|
||||
- Remove artificial delays from bookmark decryption
|
||||
|
||||
### Refactored
|
||||
|
||||
- Centralized bookmark controller architecture
|
||||
- Extract bookmark streaming helpers and centralize loading
|
||||
- Consolidated bookmark loading into single function
|
||||
- Remove deprecated bookmark service files
|
||||
- Share bookmark controller between components
|
||||
- Debug page organization
|
||||
- Extract VersionFooter component to eliminate duplication
|
||||
- Structured sections with proper layout and styling
|
||||
- Apply settings page styling structure
|
||||
- Simplified bunker implementation following applesauce patterns
|
||||
- Clean up bunker implementation for better maintainability
|
||||
- Import RELAYS from central config (DRY principle)
|
||||
- Update RELAYS list with relay.nsec.app
|
||||
|
||||
### Documentation
|
||||
|
||||
- Comprehensive Amber.md documentation
|
||||
- Amethyst-style bookmarks section
|
||||
- Bunker decrypt investigation summary
|
||||
- Critical queue disabling requirement
|
||||
- NIP-46 setup and troubleshooting
|
||||
|
||||
## [0.9.1] - 2025-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- Video embedding for nostr-native content
|
||||
- Detect and embed `<video>...</video>` blocks (including nested `<source>`)
|
||||
- Detect and embed `<img src="…(mp4|webm|ogg|mov|avi|mkv|m4v)">` tags
|
||||
- Detect and embed bare video file URLs and platform-classified video links
|
||||
- Media display settings
|
||||
- New "Render video links as embeds" setting (defaults to enabled)
|
||||
- New "Full-width images" display option
|
||||
- Dedicated "Media Display" settings section
|
||||
- Article view improvements
|
||||
- Center images by default in reader
|
||||
- Writings list sorted by publication date (newest first)
|
||||
|
||||
### Changed
|
||||
|
||||
- Enable media display options by default for a better out‑of‑the‑box experience
|
||||
- Constrain video player to reader width to prevent horizontal overflow
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent double video player rendering when both processor and panel attempted to embed
|
||||
- Remove text artifacts and broken tags when converting markdown image/video URLs
|
||||
- Improved URL regex and robust tag replacement
|
||||
- Avoid injecting unknown img props from markdown renderer
|
||||
- Resolved remaining ESLint and TypeScript issues
|
||||
|
||||
### Performance
|
||||
|
||||
- Optimized Support page loading with instant display and skeletons
|
||||
|
||||
## [0.9.0] - 2025-01-20
|
||||
|
||||
### Added
|
||||
|
||||
- User relay list integration (NIP-65) and blocked relays (NIP-51)
|
||||
- Automatically loads user's relay list from kind 10002 events
|
||||
- Supports blocked relay filtering from kind 10006 mute lists
|
||||
- Integrates with existing relay pool for seamless user experience
|
||||
- Relay list debug section in Debug component
|
||||
- Enhanced debugging capabilities for relay list loading
|
||||
- Detailed logging for relay query diagnostics
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved relay list loading performance
|
||||
- Added streaming callback to relay list service for faster results
|
||||
- User relay list now streams into pool immediately and finalizes after blocked relays
|
||||
- Made relay list loading non-blocking in App.tsx
|
||||
- Enhanced relay URL handling
|
||||
- Normalized relay URLs to match applesauce-relay internal format
|
||||
- Removed relay.dergigi.com from default relays
|
||||
- Use user's relay list exclusively when logged in
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved all linting issues across the codebase
|
||||
- Fixed TypeScript type issues in relayListService
|
||||
- Replaced any types with proper NostrEvent types
|
||||
- Improved type safety and code quality
|
||||
- Cleaned up temporary test relays from hardcoded list
|
||||
- Removed non-relay console.log statements and debug output
|
||||
|
||||
### Technical
|
||||
|
||||
- Enhanced relay initialization logging for better diagnostics
|
||||
- Improved error handling and timeout management for relay queries
|
||||
- Better separation of concerns between relay loading and application startup
|
||||
|
||||
## [0.8.6] - 2025-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- React Hooks violations in NostrMentionLink component
|
||||
- Fixed useEffect dependency warnings by removing isMounted from dependencies
|
||||
- Reverted to inline mount tracking with useRef for safer lifecycle handling
|
||||
|
||||
## [0.8.4] - 2024-10-20
|
||||
|
||||
### Added
|
||||
@@ -108,7 +660,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- `/me/bookmarks` tab now displays in cards view only
|
||||
- `/my/bookmarks` tab now displays in cards view only
|
||||
- Removed view mode toggle buttons (compact, large) from bookmarks tab
|
||||
- Cards view provides optimal bookmark browsing experience
|
||||
- Grouping toggle (grouped/flat) still available
|
||||
@@ -221,7 +773,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent "No highlights yet" flash on `/me/highlights` page
|
||||
- Prevent "No highlights yet" flash on `/my/highlights` page
|
||||
- Force React to remount tab content when switching tabs for proper state management
|
||||
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
|
||||
- Show skeleton placeholders while highlights are loading for better UX
|
||||
@@ -429,7 +981,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Filter icons colored when active (blue for most, green for completed)
|
||||
- URL routing support for reading progress filters
|
||||
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||
- Reads and Links tabs on `/me` page
|
||||
- Reads and Links tabs on `/my` page
|
||||
- Reads tab shows nostr-native articles with reading progress
|
||||
- Links tab shows external URLs with reading progress
|
||||
- Both tabs populate instantly from bookmarks for fast loading
|
||||
@@ -475,7 +1027,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Bookmark filter buttons by content type (articles, videos, images, web links)
|
||||
- Filter bookmarks by their content type on bookmarks sidebar
|
||||
- Filters also available on `/me` page bookmarks tab
|
||||
- Filters also available on `/my` page bookmarks tab
|
||||
- Separate filter for external articles with link icon
|
||||
- Multiple filters can be active simultaneously
|
||||
- Private Bookmarks section for encrypted legacy bookmarks
|
||||
@@ -489,7 +1041,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Better categorization of bookmark types
|
||||
- Bookmark filter button styling refined
|
||||
- Reduced whitespace around bookmark filters for cleaner layout
|
||||
- Dramatically reduced whitespace on both sidebar and `/me` page
|
||||
- Dramatically reduced whitespace on both sidebar and `/my` page
|
||||
- Lock icon removed from individual bookmarks
|
||||
- Encryption status now indicated by section grouping
|
||||
- Cleaner bookmark item appearance
|
||||
@@ -656,7 +1208,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Camera icon for image bookmarks
|
||||
- Sticky note icon for text-only bookmarks without URLs
|
||||
- Bookmark grouping and sections
|
||||
- Grouped sections in sidebar and `/me` reading-list
|
||||
- Grouped sections in sidebar and `/my` reading-list
|
||||
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
|
||||
- Grouping and sorting helpers for organizing bookmark sections
|
||||
- Adaptive text color for publication date over hero images
|
||||
@@ -719,7 +1271,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmark button visibility across all pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/my`, and `/support` pages
|
||||
- Only hidden on settings page or when scrolling down while reading
|
||||
- Prevents users from getting stuck without navigation options
|
||||
- Mobile highlights button behavior at page top
|
||||
@@ -900,7 +1452,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Highlights tab on `/explore` page
|
||||
- View highlights from friends and followed users
|
||||
- Tab structure matching `/me` and profile pages
|
||||
- Tab structure matching `/my` and profile pages
|
||||
- Grid layout for highlights with cards
|
||||
- Highlights shown first, writings second
|
||||
- Clicking highlight opens source article and scrolls to position
|
||||
@@ -1057,7 +1609,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Writings tab on `/me` page to display user's published articles
|
||||
- Writings tab on `/my` page to display user's published articles
|
||||
- Comprehensive headline styling (h1-h6) with Tailwind typography
|
||||
- List styling for ordered and unordered lists in articles
|
||||
- Blockquote styling with indentation and italics
|
||||
@@ -1080,7 +1632,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Horizontal overflow from code blocks and wide content on mobile
|
||||
- Settings view now mobile-friendly with proper width constraints
|
||||
- Long relay URLs no longer cause horizontal overflow on mobile
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/my pages
|
||||
- Video titles now show filename instead of 'Error Loading Content'
|
||||
- AddBookmarkModal z-index issue fixed using React Portal
|
||||
- Highlight matching for text spanning multiple DOM nodes/inline elements
|
||||
@@ -1135,7 +1687,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- YouTube video metadata extraction with title, description, and captions
|
||||
- Responsive video player with aspect ratio support
|
||||
- Thumbnail images in compact view
|
||||
- URL routing for /me page tabs
|
||||
- URL routing for /my page tabs
|
||||
- Bookmark navigation in reading list
|
||||
- Video duration display for video URLs
|
||||
- Three-dot menu for videos with open/native/copy/share actions
|
||||
@@ -1165,7 +1717,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Style
|
||||
|
||||
- Hide tab counts on mobile for /me page
|
||||
- Hide tab counts on mobile for /my page
|
||||
- Remove max-width on main pane, constrain reader instead
|
||||
- Full width layout for videos
|
||||
- Reader-video specific styles
|
||||
@@ -1178,11 +1730,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Prism.js syntax highlighting for code blocks
|
||||
- Inline image rendering in nostr-native blog posts
|
||||
- Image placeholders on blog post cards in `/explore`
|
||||
- Caching on `/me` page for faster loading
|
||||
- Caching on `/my` page for faster loading
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading List on `/me` now uses the same components as the bookmarks sidebar
|
||||
- Reading List on `/my` now uses the same components as the bookmarks sidebar
|
||||
- Improve bookmarks sidebar visual design
|
||||
- Make article menu button more subtle by removing border
|
||||
|
||||
@@ -1202,8 +1754,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
|
||||
- Two-pane layout for `/me` page with article sources and highlights
|
||||
- `/my` page with tabbed layout featuring Highlights, Reading List, and Library tabs
|
||||
- Two-pane layout for `/my` page with article sources and highlights
|
||||
- Custom FontAwesome Pro books icon for Archive tab
|
||||
- CompactButton component for highlight cards
|
||||
- Instant mark-as-read functionality with checkmark animation and read status checking
|
||||
@@ -1212,7 +1764,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Rename Library tab to Archive
|
||||
- Move highlight timestamp to top-right corner of cards
|
||||
- Replace username with AuthorCard component on `/me` page
|
||||
- Replace username with AuthorCard component on `/my` page
|
||||
- Use user's custom highlight color for Highlights tab
|
||||
- Render library articles using BlogPostCard component for consistency
|
||||
- Use faBooks icon for Mark as Read button
|
||||
@@ -1232,12 +1784,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Style
|
||||
|
||||
- Match `/me` profile card width to highlight cards
|
||||
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
|
||||
- Match `/my` profile card width to highlight cards
|
||||
- Improve My page mobile tabs and avoid overlap with sidebar buttons
|
||||
- Reduce margins/paddings to make highlight cards more compact
|
||||
- Tighten vertical spacing on highlight cards
|
||||
- Left-align text inside author card
|
||||
- Constrain `/me` page content width to match author card (600px)
|
||||
- Constrain `/my` page content width to match author card (600px)
|
||||
- Improve tab border styling for dark theme
|
||||
- Make relay indicator match CompactButton (same look as menu)
|
||||
- Align relay indicator within footer with symmetric spacing
|
||||
@@ -1342,7 +1894,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Highlights: merge remote results after local for article/url
|
||||
- Explore: always query remote relays after local; stream merge into UI
|
||||
- Improve mobile touch targets for highlight icons
|
||||
- Color `/me` highlights with "my highlights" color setting
|
||||
- Color `/my` highlights with "my highlights" color setting
|
||||
|
||||
### Performance
|
||||
|
||||
@@ -1374,7 +1926,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
- Styled to match relay indicator (subtle, same size)
|
||||
- Removes highlights from UI immediately after deletion request
|
||||
- `/me` page showing user's recent highlights
|
||||
- `/my` page showing user's recent highlights
|
||||
- Accessible by clicking profile picture in bookmark sidebar
|
||||
- Displays all highlights created by the logged-in user
|
||||
- Uses same rendering as Settings and Explore pages
|
||||
@@ -2103,7 +2655,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...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.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.1]: https://github.com/dergigi/boris/compare/v0.10.0...v0.10.1
|
||||
[0.10.0]: https://github.com/dergigi/boris/compare/v0.9.1...v0.10.0
|
||||
[0.9.1]: https://github.com/dergigi/boris/compare/v0.9.0...v0.9.1
|
||||
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
|
||||
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||
- **Progress**: Reading progress indicator with completion state.
|
||||
- **Text‑to‑Speech**: Listen to articles with browser‑native TTS; play/pause/stop controls with adjustable speed (0.8–1.6x).
|
||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.8.4",
|
||||
"version": "0.10.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.8.4",
|
||||
"version": "0.10.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -35,6 +35,7 @@
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tinyld": "^1.3.4",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11215,6 +11216,22 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyld": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
|
||||
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tinyld": "bin/tinyld.js",
|
||||
"tinyld-heavy": "bin/tinyld-heavy.js",
|
||||
"tinyld-light": "bin/tinyld-light.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.10.0",
|
||||
"npm": ">= 6.12.0",
|
||||
"yarn": ">= 1.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.8.6",
|
||||
"version": "0.10.18",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -38,6 +38,7 @@
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tinyld": "^1.3.4",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
"background_color": "#0b1220",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "link"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
|
||||
220
src/App.tsx
220
src/App.tsx
@@ -8,17 +8,20 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import type { NostrEvent } from 'nostr-tools'
|
||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Debug from './components/Debug'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
import { DebugBus } from './utils/debugBus'
|
||||
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||
import { Bookmark } from './types/bookmarks'
|
||||
import { bookmarkController } from './services/bookmarkController'
|
||||
import { contactsController } from './services/contactsController'
|
||||
@@ -92,7 +95,7 @@ function AppRoutes({
|
||||
|
||||
// Load bookmarks
|
||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||
}
|
||||
|
||||
// Load contacts
|
||||
@@ -157,6 +160,10 @@ function AppRoutes({
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/share-target"
|
||||
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||
/>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
@@ -230,11 +237,11 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
path="/my"
|
||||
element={<Navigate to="/my/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
path="/my/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -246,7 +253,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
path="/my/bookmarks"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -258,7 +265,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads"
|
||||
path="/my/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -270,7 +277,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads/:filter"
|
||||
path="/my/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -282,7 +289,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
path="/my/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -294,7 +301,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links/:filter"
|
||||
path="/my/links/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -306,7 +313,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
path="/my/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -341,6 +348,18 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/e/:eventId"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/debug"
|
||||
element={
|
||||
@@ -385,16 +404,9 @@ function App() {
|
||||
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = pool.publish(relays, event as any)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (result && typeof (result as any).subscribe === 'function') {
|
||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||
}
|
||||
// Return an already-resolved promise so upstream await finishes immediately
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||
// Fire-and-forget publish; do not block callers
|
||||
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -417,14 +429,10 @@ function App() {
|
||||
|
||||
if (account) {
|
||||
accounts.setActive(activeId)
|
||||
} else {
|
||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||
}
|
||||
} else {
|
||||
// No active account ID in localStorage
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
}
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
@@ -493,61 +501,27 @@ function App() {
|
||||
try {
|
||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||
recreatedSigner.relays = mergedRelays
|
||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||
} catch (err) { /* ignore */ }
|
||||
|
||||
// Replace the signer on the account
|
||||
nostrConnectAccount.signer = recreatedSigner
|
||||
|
||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||
// Fire-and-forget publish for bunker: trigger but don't wait for completion
|
||||
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||
try {
|
||||
let method: string | undefined
|
||||
const content = (event as { content?: unknown })?.content
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
||||
method = parsed?.method
|
||||
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
||||
}
|
||||
const summary = {
|
||||
relays,
|
||||
kind: (event as { kind?: number })?.kind,
|
||||
method,
|
||||
// include tags array for debugging (NIP-46 expects method tag)
|
||||
tags: (event as { tags?: unknown })?.tags,
|
||||
contentLength: typeof content === 'string' ? content.length : undefined
|
||||
}
|
||||
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||
// Fire-and-forget publish: trigger the publish but do not return the
|
||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||
const result = originalPublish(relays, event)
|
||||
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||
}
|
||||
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
||||
// Return a benign object so callers that probe for a "subscribe" property
|
||||
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
||||
return {} as unknown as never
|
||||
}
|
||||
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
||||
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
||||
try {
|
||||
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||
return originalSubscribe(relays, filters)
|
||||
}
|
||||
|
||||
|
||||
// Just ensure the signer is listening for responses - don't call connect() again
|
||||
// The fromBunkerURI already connected with permissions during login
|
||||
if (!nostrConnectAccount.signer.listening) {
|
||||
await nostrConnectAccount.signer.open()
|
||||
} else {
|
||||
// Signer already listening
|
||||
}
|
||||
|
||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||
@@ -557,7 +531,7 @@ function App() {
|
||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||
// Ignore reconnect errors
|
||||
}
|
||||
|
||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||
@@ -597,17 +571,130 @@ function App() {
|
||||
// Mark this account as reconnected
|
||||
reconnectedAccounts.add(account.id)
|
||||
} catch (error) {
|
||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||
console.error('Failed to open signer:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle user relay list and blocked relays when account changes
|
||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||
const pubkey = account.pubkey
|
||||
|
||||
// Bunker relays (if any)
|
||||
let bunkerRelays: string[] = []
|
||||
if (account.type === 'nostr-connect') {
|
||||
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||
const signerData = nostrConnectAccount.toJSON().signer
|
||||
bunkerRelays = signerData.relays || []
|
||||
}
|
||||
|
||||
|
||||
// Start with hardcoded + bunker relays immediately (non-blocking)
|
||||
const initialRelays = computeRelaySet({
|
||||
hardcoded: RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: [],
|
||||
blocked: [],
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
|
||||
|
||||
// Apply initial set immediately
|
||||
applyRelaySetToPool(pool, initialRelays)
|
||||
|
||||
// Prepare keep-alive helper
|
||||
const updateKeepAlive = () => {
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||
}
|
||||
const activeRelays = getActiveRelayUrls(pool)
|
||||
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||
next: () => {},
|
||||
error: () => {}
|
||||
})
|
||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||
}
|
||||
|
||||
// Begin loading blocked relays in background
|
||||
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||
|
||||
// Stream user relay list; apply immediately on first/updated event
|
||||
loadUserRelayList(pool, pubkey, {
|
||||
onUpdate: (userRelays) => {
|
||||
const interimRelays = computeRelaySet({
|
||||
hardcoded: HARDCODED_RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelays,
|
||||
blocked: [],
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
|
||||
applyRelaySetToPool(pool, interimRelays)
|
||||
updateKeepAlive()
|
||||
}
|
||||
}).then(async (userRelayList) => {
|
||||
const blockedRelays = await blockedPromise.catch(() => [])
|
||||
|
||||
const finalRelays = computeRelaySet({
|
||||
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelayList,
|
||||
blocked: blockedRelays,
|
||||
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||
})
|
||||
|
||||
applyRelaySetToPool(pool, finalRelays)
|
||||
|
||||
updateKeepAlive()
|
||||
|
||||
// Update address loader with new relays
|
||||
const activeRelays = getActiveRelayUrls(pool)
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: activeRelays
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
}).catch((error) => {
|
||||
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
||||
// Continue with initial relay set on error - no need to change anything
|
||||
})
|
||||
} else {
|
||||
// User logged out - reset to hardcoded relays
|
||||
|
||||
applyRelaySetToPool(pool, RELAYS)
|
||||
|
||||
|
||||
// Update keep-alive subscription
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||
}
|
||||
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||
next: () => {},
|
||||
error: () => {}
|
||||
})
|
||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||
|
||||
// Reset address loader
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: RELAYS
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
}
|
||||
})
|
||||
|
||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||
// This prevents disconnection when no other subscriptions are active
|
||||
// Create a minimal subscription that never completes to keep connections alive
|
||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||
next: () => {}, // No-op, we don't care about events
|
||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
||||
next: () => {},
|
||||
error: () => {}
|
||||
})
|
||||
|
||||
// Store subscription for cleanup
|
||||
@@ -630,6 +717,7 @@ function App() {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
bunkerReconnectSub.unsubscribe()
|
||||
userRelaysSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
|
||||
@@ -6,18 +6,26 @@ import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { isKnownBot } from '../config/bots'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
hideBotByName?: boolean // default true
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||
|
||||
// Hide bot authors by name/display_name
|
||||
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const publishedDate = post.published || post.event.created_at
|
||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
@@ -42,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
state={{
|
||||
previewData: {
|
||||
title: post.title,
|
||||
image: post.image,
|
||||
summary: post.summary,
|
||||
published: post.published
|
||||
}
|
||||
}}
|
||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||
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 { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
@@ -58,8 +58,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
// Resolve author profile using applesauce
|
||||
const authorProfile = useEventModel(Models.ProfileModel, [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
|
||||
const getAuthorDisplayName = () => {
|
||||
@@ -135,7 +133,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
@@ -145,8 +142,19 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { articleImage, ...compactProps } = sharedProps
|
||||
const compactProps = {
|
||||
bookmark,
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import React, { useRef, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -13,12 +14,12 @@ import { ViewMode } from './Bookmarks'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
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 AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import LoginOptions from './LoginOptions'
|
||||
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||
return saved === 'flat' ? 'flat' : 'grouped'
|
||||
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||
})
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
@@ -120,12 +121,24 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
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[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||
}
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||
const sections = useMemo(() => {
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.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
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
// Get all filtered bookmarks for empty state checks
|
||||
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 bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// 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
|
||||
})
|
||||
})
|
||||
const filteredBookmarks = useMemo(() =>
|
||||
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||
[allIndividualBookmarks, selectedFilter]
|
||||
)
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -285,6 +317,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
@@ -296,13 +335,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -9,7 +9,7 @@ import RichContent from '../RichContent'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,7 +18,6 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
@@ -34,7 +33,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
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 (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -103,19 +124,17 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
const isNote = bookmark.kind === 1
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||
|
||||
// Calculate progress color (matching BlogPostCard logic)
|
||||
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||
|
||||
// Calculate progress color
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
}
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl(extractedUrls[0])
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
} else if (isNote) {
|
||||
navigate(`/e/${bookmark.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
@@ -64,12 +64,16 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<span className="bookmark-type-compact">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
{displayText && (
|
||||
{displayText ? (
|
||||
<div className="compact-text">
|
||||
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
|
||||
<code>{bookmark.id.slice(0, 12)}...</code>
|
||||
</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 */}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import RichContent from '../RichContent'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,7 +18,6 @@ interface LargeViewProps {
|
||||
getIconForUrlType: IconGetter
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
@@ -35,7 +34,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getIconForUrlType,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
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 (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -136,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{eventNevent && (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
|
||||
{/* CTA removed */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import { useEventLoader } from '../hooks/useEventLoader'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks
|
||||
}) => {
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
@@ -52,20 +53,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showMe = location.pathname.startsWith('/my')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
const eventId = eventIdParam
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/me/links') ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
const meTab = location.pathname === '/my' ? 'highlights' :
|
||||
location.pathname === '/my/highlights' ? 'highlights' :
|
||||
location.pathname === '/my/bookmarks' ? 'bookmarks' :
|
||||
location.pathname.startsWith('/my/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/my/links') ? 'links' :
|
||||
location.pathname === '/my/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
@@ -85,7 +87,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
// Track previous location for going back from settings/my/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
@@ -228,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -255,6 +258,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load event if /e/:eventId route is used
|
||||
useEventLoader({
|
||||
eventId,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
})
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
|
||||
@@ -4,14 +4,15 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ContentSkeleton } from './Skeletons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
@@ -42,9 +43,10 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import TTSControls from './TTSControls'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -74,6 +76,7 @@ interface ContentPanelProps {
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -101,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
isHighlightsCollapsed = false,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
@@ -130,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
currentUserPubkey,
|
||||
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({
|
||||
onHighlightClick,
|
||||
@@ -141,8 +150,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
// Reading position tracking - only for text content that's loaded and long enough
|
||||
// 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
|
||||
const articleIdentifier = useMemo(() => {
|
||||
@@ -150,6 +167,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return generateArticleIdentifier(selectedUrl)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Use refs for content to avoid recreating callback on every content change
|
||||
const htmlRef = useRef(html)
|
||||
const markdownRef = useRef(markdown)
|
||||
useEffect(() => {
|
||||
htmlRef.current = html
|
||||
markdownRef.current = markdown
|
||||
}, [html, markdown])
|
||||
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
@@ -160,7 +185,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
|
||||
// Check if content is long enough to track reading progress
|
||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,12 +205,39 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||
console.error('[reading-position] Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||
|
||||
const { progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
// Delay enabling position tracking to ensure content is stable
|
||||
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
|
||||
|
||||
// Reset tracking when article changes
|
||||
useEffect(() => {
|
||||
setIsTrackingEnabled(false)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Enable/disable tracking based on content state
|
||||
useEffect(() => {
|
||||
if (!isTextContent) {
|
||||
// Disable tracking if content is not suitable
|
||||
if (isTrackingEnabled) {
|
||||
setIsTrackingEnabled(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTrackingEnabled) {
|
||||
// Wait 500ms after content loads before enabling tracking
|
||||
const timer = setTimeout(() => {
|
||||
setIsTrackingEnabled(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isTextContent, isTrackingEnabled])
|
||||
|
||||
const { progressPercentage, suppressSavesFor } = useReadingPosition({
|
||||
enabled: isTrackingEnabled,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
@@ -205,59 +257,82 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
useEffect(() => {
|
||||
}, [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(() => {
|
||||
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(() => {
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
return
|
||||
}
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
return
|
||||
}
|
||||
if (settings?.autoScrollToReadingPosition === false) {
|
||||
return
|
||||
}
|
||||
if (!isTrackingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier
|
||||
)
|
||||
// Only attempt restore once per article (after tracking is enabled)
|
||||
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
// Article was completed, start from top
|
||||
} else {
|
||||
// Position was too early, skip restore
|
||||
}
|
||||
// Mark as attempted using composite key
|
||||
hasAttemptedRestoreRef.current = restoreKey
|
||||
|
||||
// Get the saved position from the controller (already loaded and displayed on card)
|
||||
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||
|
||||
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(1500)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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) {
|
||||
// Allow saves immediately since no scroll happened
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadPosition()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
// Perform smooth animated restore
|
||||
window.scrollTo({
|
||||
top: targetTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNow) {
|
||||
saveNow()
|
||||
}
|
||||
}
|
||||
}, [saveNow, selectedUrl])
|
||||
// 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 1s throttle already captures position during reading
|
||||
// 3. Position state may not reflect actual reading position during navigation
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -320,6 +395,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Extract plain text for TTS
|
||||
const baseHtml = useMemo(() => {
|
||||
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
|
||||
return finalHtml || html || ''
|
||||
}, [markdown, renderedMarkdownHtml, finalHtml, html])
|
||||
|
||||
const articleText = useMemo(() => {
|
||||
const parts: string[] = []
|
||||
if (title) parts.push(title)
|
||||
if (summary) parts.push(summary)
|
||||
if (baseHtml) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = baseHtml
|
||||
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
if (txt) parts.push(txt)
|
||||
}
|
||||
return parts.join('. ')
|
||||
}, [title, summary, baseHtml])
|
||||
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
@@ -357,7 +451,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (!currentArticle) return null
|
||||
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||
const relayHints = activeRelays.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3)
|
||||
|
||||
@@ -463,7 +558,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
if (articleLinks) {
|
||||
// For regular notes (kind:1), open via /e/ path
|
||||
if (currentArticle?.kind === 1) {
|
||||
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
|
||||
window.open(borisUrl, '_blank', 'noopener,noreferrer')
|
||||
} else if (articleLinks) {
|
||||
// For articles, use search portal
|
||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
@@ -550,7 +650,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
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)
|
||||
}
|
||||
@@ -579,9 +685,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||
hasRead = hasRead || archiveController.isMarked(naddr)
|
||||
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
|
||||
} catch (e) {
|
||||
console.warn('[archive][content] encode naddr failed', e)
|
||||
// Silently ignore encoding errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -593,7 +698,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Also check archiveController
|
||||
const ctrl = archiveController.isMarked(selectedUrl)
|
||||
hasRead = hasRead || ctrl
|
||||
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
@@ -674,7 +778,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (dTag) {
|
||||
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||
archiveController.mark(naddr)
|
||||
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[archive][content] optimistic article mark failed', err)
|
||||
@@ -686,7 +789,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
relayPool
|
||||
)
|
||||
archiveController.mark(selectedUrl)
|
||||
console.log('[archive][content] optimistic mark url', selectedUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
@@ -731,16 +833,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -761,7 +862,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
{isTextContent && articleText && (
|
||||
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||
</div>
|
||||
)}
|
||||
{isExternalVideo ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
@@ -846,10 +953,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -861,10 +970,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -898,13 +1009,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleSearchExternalUrl}
|
||||
|
||||
@@ -114,6 +114,12 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
|
||||
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
|
||||
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
|
||||
|
||||
// Relay list loading state
|
||||
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
|
||||
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
|
||||
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
|
||||
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
|
||||
|
||||
// Deduplicated reading progress from controller
|
||||
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
|
||||
@@ -127,6 +133,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
loadHighlights?: { startTime: number }
|
||||
loadReadingProgress?: { startTime: number }
|
||||
loadMarkAsRead?: { startTime: number }
|
||||
loadRelayList?: { startTime: number }
|
||||
}>({})
|
||||
|
||||
// Web of Trust state
|
||||
@@ -427,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadHighlights(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadHighlights, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
@@ -648,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
100,
|
||||
eventStore || undefined
|
||||
)
|
||||
|
||||
setWritingPosts(posts)
|
||||
@@ -776,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) => {
|
||||
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
|
||||
@@ -791,11 +803,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadReadingProgress(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadReadingProgress, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
|
||||
|
||||
const finalMap = readingProgressController.getProgressMap()
|
||||
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||
@@ -864,11 +872,7 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const totalEvents = kind7Events.length + kind17Events.length
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadMarkAsRead(elapsed)
|
||||
setLiveTiming(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { loadMarkAsRead, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||
} catch (err) {
|
||||
@@ -886,6 +890,66 @@ const Debug: React.FC<DebugProps> = ({
|
||||
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
|
||||
}
|
||||
|
||||
const handleLoadRelayList = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load relay list')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingRelayList(true)
|
||||
setRelayListEvents([])
|
||||
setTLoadRelayList(null)
|
||||
setTFirstRelayList(null)
|
||||
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
|
||||
|
||||
const start = performance.now()
|
||||
let firstEventTime: number | null = null
|
||||
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
|
||||
|
||||
const { queryEvents } = await import('../services/dataFetch')
|
||||
|
||||
// Query for kind:10002 (relay list)
|
||||
const events = await queryEvents(relayPool, {
|
||||
kinds: [10002],
|
||||
authors: [activeAccount.pubkey],
|
||||
limit: 10
|
||||
}, {
|
||||
onEvent: (evt) => {
|
||||
if (firstEventTime === null) {
|
||||
firstEventTime = performance.now() - start
|
||||
setTFirstRelayList(Math.round(firstEventTime))
|
||||
}
|
||||
setRelayListEvents(prev => [...prev, evt])
|
||||
}
|
||||
})
|
||||
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setTLoadRelayList(elapsed)
|
||||
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
|
||||
|
||||
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
|
||||
|
||||
// Log details about the events
|
||||
events.forEach((event, index) => {
|
||||
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
|
||||
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load relay list:', err)
|
||||
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setIsLoadingRelayList(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearRelayList = () => {
|
||||
setRelayListEvents([])
|
||||
setTLoadRelayList(null)
|
||||
setTFirstRelayList(null)
|
||||
DebugBus.info('debug', 'Cleared relay list data')
|
||||
}
|
||||
|
||||
const handleLoadFriendsList = async () => {
|
||||
if (!relayPool || !activeAccount?.pubkey) {
|
||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||
@@ -1698,6 +1762,72 @@ const Debug: React.FC<DebugProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Relay List Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
|
||||
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadRelayList}
|
||||
disabled={isLoadingRelayList || !relayPool || !activeAccount}
|
||||
>
|
||||
{isLoadingRelayList ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Relay List'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearRelayList}
|
||||
disabled={relayListEvents.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-3 text-sm">
|
||||
<Stat label="total" value={tLoadRelayList} />
|
||||
<Stat label="first event" value={tFirstRelayList} />
|
||||
</div>
|
||||
{relayListEvents.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{relayListEvents.map((evt, idx) => {
|
||||
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
|
||||
|
||||
return (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Kind: {evt.kind}</div>
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
<div>Relays: {relayTags.length}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
|
||||
{relayTags.map((tag, tagIdx) => (
|
||||
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
|
||||
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web of Trust Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Web of Trust</h3>
|
||||
|
||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
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 { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { nip19, NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
// Contacts are managed via controller subscription
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
// import { KINDS } from '../config/kinds'
|
||||
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
// Accessors from Helpers (currently unused here)
|
||||
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -55,27 +57,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||
const hasHydratedRef = useRef(false)
|
||||
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||
// Remove unused loading state to avoid warnings
|
||||
|
||||
// Reading progress state (naddr -> progress 0-1)
|
||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Load cached content from event store (instant display)
|
||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
|
||||
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}), [])
|
||||
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||
// event,
|
||||
// title: getArticleTitle(event) || 'Untitled',
|
||||
// summary: getArticleSummary(event),
|
||||
// image: getArticleImage(event),
|
||||
// published: getArticlePublished(event),
|
||||
// author: event.pubkey
|
||||
// }), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +108,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to contacts stream and mirror into local state
|
||||
useEffect(() => {
|
||||
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||
setFollowedPubkeys(new Set(contacts))
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
// Ensure contacts controller is started for the active account (non-blocking)
|
||||
useEffect(() => {
|
||||
if (relayPool && activeAccount?.pubkey) {
|
||||
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
|
||||
}
|
||||
}, [relayPool, activeAccount?.pubkey])
|
||||
|
||||
// Subscribe to nostrverse highlights controller for global stream
|
||||
useEffect(() => {
|
||||
const apply = (incoming: Highlight[]) => {
|
||||
@@ -230,242 +248,95 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// begin load, but do not block rendering
|
||||
setLoading(true)
|
||||
// Load initial data and refresh on triggers
|
||||
const loadData = useCallback(() => {
|
||||
if (!relayPool) return
|
||||
|
||||
// If not logged in, only fetch nostrverse content with streaming posts
|
||||
if (!activeAccount) {
|
||||
// Logged out: rely entirely on centralized controllers; do not fetch here
|
||||
setLoading(false)
|
||||
}
|
||||
// Seed from cache for instant UI
|
||||
if (activeAccount) {
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cached && cached.length > 0) setHighlights(cached)
|
||||
}
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||
}
|
||||
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Seed with cached content from event store (instant display)
|
||||
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
||||
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
||||
setHighlights(prev => {
|
||||
const all = dedupeHighlightsById([...prev, ...merged])
|
||||
return all.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
|
||||
// Seed with cached writings from event store
|
||||
if (cachedWritings.length > 0) {
|
||||
setBlogPosts(prev => {
|
||||
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
||||
return all.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
setLoading(true)
|
||||
|
||||
// At this point, we have seeded any available data; lift the loading state
|
||||
setLoading(false)
|
||||
try {
|
||||
// Prepare parallel fetches
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(
|
||||
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||
fetchNostrverseBlogPosts(
|
||||
relayPool,
|
||||
activeAccount?.pubkey || '',
|
||||
(partial) => {
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
setBlogPosts((prev) => {
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
|
||||
// If exists, only replace if this one is newer
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) {
|
||||
return prev // Keep existing (newer or same)
|
||||
}
|
||||
// Replace with newer version
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
// New post, add it
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
setBlogPosts((prev) => {
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Add existing posts
|
||||
for (const p of prev) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
byKey.set(key, p)
|
||||
}
|
||||
|
||||
// Merge in new posts (keeping newer versions)
|
||||
for (const post of all) {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
byKey.set(key, post)
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(byKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
|
||||
// Fetch highlights
|
||||
fetchHighlightsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
(highlight) => {
|
||||
setHighlights((prev) => {
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (removed blocking error for empty contacts)
|
||||
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(contacts)
|
||||
// Use centralized writingsController for my posts (non-blocking)
|
||||
// pull from writingsController; no need to store promise
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
setHasLoadedMine(true)
|
||||
const nostrversePostsPromise = visibility.nostrverse
|
||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
||||
// Stream nostrverse posts too when logged in
|
||||
setBlogPosts(prev => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) return prev
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
}
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
})
|
||||
: Promise.resolve([] as BlogPostPreview[])
|
||||
|
||||
// Fire non-blocking fetches and merge as they resolve
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
||||
.then((friendsPosts) => {
|
||||
relayUrls,
|
||||
50,
|
||||
eventStore || undefined,
|
||||
(post) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
||||
// Pre-cache profiles in background
|
||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||
return sorted
|
||||
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||
.then((friendsHighlights) => {
|
||||
setHighlights(prev => {
|
||||
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
|
||||
return sorted
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
nostrversePostsPromise.then((nostrversePosts) => {
|
||||
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||
}
|
||||
).then((nostrversePosts) => {
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
}).catch(() => {})
|
||||
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
.then((nostriverseHighlights) => {
|
||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
// loading is already turned off after seeding
|
||||
}
|
||||
}
|
||||
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
}, [loadData, refreshTrigger])
|
||||
|
||||
// Kick off friends fetches reactively when contacts arrive
|
||||
useEffect(() => {
|
||||
if (!relayPool) return
|
||||
if (followedPubkeys.size === 0) return
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(followedPubkeys)
|
||||
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
// Pre-cache profiles in background
|
||||
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||
}, 100, eventStore).then((friendsPosts) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
|
||||
setHighlights(prev => {
|
||||
const merged = dedupeHighlightsById([...prev, highlight])
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||
}, eventStore || undefined).then((friendsHighlights) => {
|
||||
setHighlights(prev => {
|
||||
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}).catch(() => {})
|
||||
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
|
||||
|
||||
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||
useEffect(() => {
|
||||
@@ -509,7 +380,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||
})
|
||||
}).catch(() => {})
|
||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
||||
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
.then((nostriverseHighlights) => {
|
||||
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||
}).catch(() => {})
|
||||
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
|
||||
|
||||
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||
useEffect(() => {
|
||||
@@ -586,6 +462,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Hide bot authors by profile display name if setting enabled
|
||||
if (settings?.hideBotArticlesByName !== false) {
|
||||
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||
// Keep list intact here; individual cards will render null if author is a bot
|
||||
}
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
@@ -604,7 +486,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
return { ...post, level }
|
||||
})
|
||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||
|
||||
// Helper to get reading progress for a post
|
||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||
@@ -641,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -653,6 +537,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
readingProgress={getReadingProgress(post)}
|
||||
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -701,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<FontAwesomeIcon icon={faPersonHiking} />
|
||||
Explore
|
||||
</h1>
|
||||
|
||||
@@ -773,7 +658,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={activeTab}>
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
@@ -150,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
// Update the highlight with all relays after successful sync
|
||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
||||
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||
const updatedHighlight = {
|
||||
...highlight,
|
||||
publishedRelays: RELAYS,
|
||||
publishedRelays: getActiveRelayUrls(relayPool),
|
||||
isLocalOnly: false,
|
||||
isOfflineCreated: false
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [highlight, onHighlightUpdate])
|
||||
}, [highlight, onHighlightUpdate, relayPool])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
@@ -212,19 +212,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
// Pass highlight ID in navigation state to trigger scroll
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
// Navigate to external URL
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||
// Navigate to external URL with highlight ID to trigger scroll
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getHighlightLinks = () => {
|
||||
// Encode the highlight event itself (kind 9802) as a nevent
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||
const relayHints = activeRelays.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
@@ -260,7 +272,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
const targetRelays = getActiveRelayUrls(relayPool)
|
||||
|
||||
|
||||
await relayPool.publish(targetRelays, event)
|
||||
@@ -328,7 +340,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
|
||||
// Fallback: show all relays we queried (where this was likely fetched from)
|
||||
const relayNames = RELAYS.map(url =>
|
||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||
const relayNames = activeRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
@@ -420,7 +433,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
// Navigate within app using same logic as handleItemClick
|
||||
if (highlight.eventReference) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
if (parts.length === 3 && parts[0] === '30023') {
|
||||
const [, pubkey, identifier] = parts
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore,
|
||||
settings
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onRefresh={onRefresh}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
|
||||
onRefresh?: () => void
|
||||
onToggleCollapse: () => void
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
onToggleHighlights,
|
||||
onRefresh,
|
||||
onToggleCollapse,
|
||||
onHighlightVisibilityChange
|
||||
onHighlightVisibilityChange,
|
||||
isMobile = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="highlights-header">
|
||||
@@ -101,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
|
||||
<div className="login-content">
|
||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||
<p className="login-description">
|
||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
||||
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
|
||||
</p>
|
||||
|
||||
<div className="login-buttons">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
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 { IEventStore } from 'applesauce-core'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
@@ -23,7 +24,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
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 { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
@@ -42,7 +43,7 @@ interface MeProps {
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
|
||||
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||
@@ -57,7 +58,7 @@ const Me: React.FC<MeProps> = ({
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const activeTab = propActiveTab || 'highlights'
|
||||
|
||||
// Only for own profile
|
||||
const viewingPubkey = activeAccount?.pubkey
|
||||
@@ -129,13 +130,6 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
// Sync filter state with URL changes
|
||||
useEffect(() => {
|
||||
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||
@@ -150,15 +144,15 @@ const Me: React.FC<MeProps> = ({
|
||||
setReadingProgressFilter(filter)
|
||||
if (activeTab === 'reads') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/reads', { replace: true })
|
||||
navigate('/my/reads', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/reads/${filter}`, { replace: true })
|
||||
navigate(`/my/reads/${filter}`, { replace: true })
|
||||
}
|
||||
} else if (activeTab === 'links') {
|
||||
if (filter === 'all') {
|
||||
navigate('/me/links', { replace: true })
|
||||
navigate('/my/links', { replace: true })
|
||||
} else {
|
||||
navigate(`/me/links/${filter}`, { replace: true })
|
||||
navigate(`/my/links/${filter}`, { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,15 +199,15 @@ const Me: React.FC<MeProps> = ({
|
||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Tab-specific loading functions
|
||||
const loadHighlightsTab = async () => {
|
||||
const loadHighlightsTab = useCallback(async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
// Highlights come from controller subscription (sync effect handles it)
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
setLoading(false)
|
||||
}
|
||||
}, [viewingPubkey])
|
||||
|
||||
const loadWritingsTab = async () => {
|
||||
const loadWritingsTab = useCallback(async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
try {
|
||||
@@ -230,28 +224,29 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load writings:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
const loadReadingListTab = async () => {
|
||||
const loadReadingListTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||
|
||||
try {
|
||||
setLoadedTabs(prev => {
|
||||
const hasBeenLoaded = prev.has('bookmarks')
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
// Bookmarks come from centralized loading in App.tsx
|
||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading list:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
return new Set(prev).add('bookmarks')
|
||||
})
|
||||
|
||||
// Always turn off loading after a tick
|
||||
setTimeout(() => setLoading(false), 0)
|
||||
}, [viewingPubkey, activeAccount])
|
||||
|
||||
const loadReadsTab = async () => {
|
||||
const loadReadsTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reads')
|
||||
let hasBeenLoaded = false
|
||||
setLoadedTabs(prev => {
|
||||
hasBeenLoaded = prev.has('reads')
|
||||
return prev
|
||||
})
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
@@ -270,12 +265,16 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load reads:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||
|
||||
const loadLinksTab = async () => {
|
||||
const loadLinksTab = useCallback(async () => {
|
||||
if (!viewingPubkey || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('links')
|
||||
let hasBeenLoaded = false
|
||||
setLoadedTabs(prev => {
|
||||
hasBeenLoaded = prev.has('links')
|
||||
return prev
|
||||
})
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
@@ -310,10 +309,10 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load links:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||
|
||||
// Load active tab data
|
||||
useEffect(() => {
|
||||
const loadActiveTab = useCallback(() => {
|
||||
if (!viewingPubkey || !activeTab) {
|
||||
setLoading(false)
|
||||
return
|
||||
@@ -336,7 +335,7 @@ const Me: React.FC<MeProps> = ({
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
@@ -346,8 +345,11 @@ const Me: React.FC<MeProps> = ({
|
||||
loadLinksTab()
|
||||
break
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
|
||||
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveTab()
|
||||
}, [loadActiveTab])
|
||||
|
||||
// Sync myHighlights from controller
|
||||
useEffect(() => {
|
||||
@@ -417,7 +419,7 @@ const Me: React.FC<MeProps> = ({
|
||||
const mockEvent = {
|
||||
id: item.id,
|
||||
pubkey: item.author || '',
|
||||
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||
created_at: item.readingTimestamp || 0,
|
||||
kind: 1,
|
||||
tags: [] as string[][],
|
||||
content: item.title || item.url || 'Untitled',
|
||||
@@ -564,30 +566,21 @@ const Me: React.FC<MeProps> = ({
|
||||
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||
: []
|
||||
|
||||
// Debug logs for archive filter issues
|
||||
if (readingProgressFilter === 'archive') {
|
||||
const ids = Array.from(new Set([
|
||||
...archiveController.getMarkedIds(),
|
||||
...readingProgressController.getMarkedAsReadIds()
|
||||
]))
|
||||
const readIds = new Set(reads.map(i => i.id))
|
||||
const matches = ids.filter(id => readIds.has(id))
|
||||
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
|
||||
console.log('[archive][me] counts', {
|
||||
reads: reads.length,
|
||||
filteredReads: filteredReads.length,
|
||||
links: links.length,
|
||||
linksWithProgress: linksWithProgress.length,
|
||||
filteredLinks: filteredLinks.length,
|
||||
markedIds: ids.length,
|
||||
sampleMarked: ids.slice(0, 3),
|
||||
matches: matches.length,
|
||||
nonMatches
|
||||
})
|
||||
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[] }> =
|
||||
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-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
@@ -629,7 +622,7 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
@@ -684,7 +677,7 @@ const Me: React.FC<MeProps> = ({
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
@@ -850,6 +843,7 @@ const Me: React.FC<MeProps> = ({
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={getWritingReadingProgress(post)}
|
||||
hideBotByName={settings.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -873,15 +867,15 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
onClick={() => navigate('/my/highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
|
||||
data-tab="bookmarks"
|
||||
onClick={() => navigate('/my/bookmarks')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
@@ -889,7 +883,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
onClick={() => navigate('/my/reads')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Reads</span>
|
||||
@@ -897,7 +891,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||
data-tab="links"
|
||||
onClick={() => navigate('/me/links')}
|
||||
onClick={() => navigate('/my/links')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span className="tab-label">Links</span>
|
||||
@@ -905,7 +899,7 @@ const Me: React.FC<MeProps> = ({
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/me/writings')}
|
||||
onClick={() => navigate('/my/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
@@ -6,9 +6,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
@@ -20,6 +18,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
|
||||
interface ProfileProps {
|
||||
relayPool: RelayPool
|
||||
@@ -57,6 +57,15 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
[pubkey]
|
||||
)
|
||||
|
||||
// Sort writings by publication date, newest first
|
||||
const sortedWritings = useMemo(() => {
|
||||
return cachedWritings.slice().sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}, [cachedWritings])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -94,28 +103,17 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
})
|
||||
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Background fetch to populate event store (non-blocking)
|
||||
// Background fetch via controllers to populate event store
|
||||
useEffect(() => {
|
||||
if (!pubkey || !relayPool || !eventStore) return
|
||||
|
||||
// Start controllers to fetch and populate event store
|
||||
// Controllers handle streaming, deduplication, and storage
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||
|
||||
// Fetch highlights in background
|
||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||
.then(() => {
|
||||
// Highlights fetched
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
|
||||
})
|
||||
|
||||
// Fetch writings in background (no limit for single user profile)
|
||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
||||
.then(writings => {
|
||||
writings.forEach(w => eventStore.add(w.event))
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
||||
})
|
||||
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
|
||||
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
@@ -168,7 +166,7 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
}
|
||||
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
||||
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
@@ -209,13 +207,13 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return cachedWritings.length === 0 ? (
|
||||
return sortedWritings.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles written yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{cachedWritings.map((post) => (
|
||||
{sortedWritings.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
||||
settings?: UserSettings
|
||||
highlights?: Highlight[]
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightCountClick?: () => void
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlightCount,
|
||||
settings,
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightCountClick
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image)
|
||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function RouteDebug() {
|
||||
// Unexpected during deep-link refresh tests
|
||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||
} else {
|
||||
console.debug('[RouteDebug]', info)
|
||||
// silent
|
||||
}
|
||||
}, [location, matchArticle])
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||
import ExploreSettings from './Settings/ExploreSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import TTSSettings from './Settings/TTSSettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import VersionFooter from './VersionFooter'
|
||||
|
||||
@@ -39,9 +41,16 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
fullWidthImages: true,
|
||||
renderVideoLinksAsEmbeds: true,
|
||||
syncReadingPosition: true,
|
||||
autoScrollToReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
ttsUseSystemLanguage: false,
|
||||
ttsDetectContentLanguage: true,
|
||||
ttsLanguageMode: 'content',
|
||||
ttsDefaultSpeed: 2.1,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -169,8 +178,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
|
||||
@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||
<input
|
||||
id="hideBotArticlesByName"
|
||||
type="checkbox"
|
||||
checked={settings.hideBotArticlesByName !== false}
|
||||
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Hide content posted by bots</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="autoScrollToReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.autoScrollToReadingPosition !== false}
|
||||
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-scroll to saved reading position</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||
<input
|
||||
|
||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface MediaDisplaySettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Media Display</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||
<input
|
||||
id="fullWidthImages"
|
||||
type="checkbox"
|
||||
checked={settings.fullWidthImages === true}
|
||||
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Full-width images in articles</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||
<input
|
||||
id="renderVideoLinksAsEmbeds"
|
||||
type="checkbox"
|
||||
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Render video links as embeds</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaDisplaySettings
|
||||
@@ -33,7 +33,13 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
// If it's an internal route (starts with /), navigate directly
|
||||
if (url.startsWith('/')) {
|
||||
navigate(url)
|
||||
} else {
|
||||
// External URL: wrap with /r/ path
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
@@ -151,7 +157,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
{', '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -161,6 +167,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{', and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
|
||||
86
src/components/Settings/TTSSettings.tsx
Normal file
86
src/components/Settings/TTSSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import TTSControls from '../TTSControls'
|
||||
|
||||
interface TTSSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
|
||||
|
||||
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const currentSpeed = settings.ttsDefaultSpeed || 2.1
|
||||
|
||||
const handleCycleSpeed = () => {
|
||||
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
|
||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Text-to-Speech</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Playback Speed</label>
|
||||
<div className="setting-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="article-menu-btn"
|
||||
onClick={handleCycleSpeed}
|
||||
title="Cycle speed"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGauge} />
|
||||
<span>{currentSpeed}x</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Speaker language</label>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
value={settings.ttsLanguageMode || 'content'}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
onUpdate({
|
||||
ttsLanguageMode: value,
|
||||
ttsUseSystemLanguage: value === 'system',
|
||||
ttsDetectContentLanguage: value === 'content'
|
||||
})
|
||||
}}
|
||||
className="setting-select"
|
||||
>
|
||||
<option value="system">System Language</option>
|
||||
<option value="content">Content (auto-detect)</option>
|
||||
<option disabled>────────────</option>
|
||||
<option value="en-US">English (American)</option>
|
||||
<option value="en-GB">English (British)</option>
|
||||
<option value="zh">Mandarin Chinese</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="hi">Hindi</option>
|
||||
<option value="ar">Arabic</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="de">German</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ru">Russian</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||
{EXAMPLE_TEXT}
|
||||
</div>
|
||||
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TTSSettings
|
||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
|
||||
interface ShareTargetHandlerProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming shared URLs from the Web Share Target API.
|
||||
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||
*/
|
||||
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const { showToast } = useToast()
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleSharedContent = async () => {
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(location.search)
|
||||
const link = params.get('link')
|
||||
const title = params.get('title')
|
||||
const text = params.get('text')
|
||||
|
||||
// Validate we have a URL
|
||||
if (!link) {
|
||||
showToast('No URL to save')
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// If no active account, wait for login
|
||||
if (!activeAccount) {
|
||||
setWaitingForLogin(true)
|
||||
showToast('Please log in to save this bookmark')
|
||||
return
|
||||
}
|
||||
|
||||
// We have account and URL, proceed with saving
|
||||
if (!processing) {
|
||||
setProcessing(true)
|
||||
try {
|
||||
await createWebBookmark(
|
||||
link,
|
||||
title || undefined,
|
||||
text || undefined,
|
||||
undefined,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
getActiveRelayUrls(relayPool)
|
||||
)
|
||||
showToast('Bookmark saved!')
|
||||
navigate('/my/links')
|
||||
} catch (err) {
|
||||
console.error('Failed to save shared bookmark:', err)
|
||||
showToast('Failed to save bookmark')
|
||||
navigate('/')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSharedContent()
|
||||
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
|
||||
|
||||
// Show waiting for login state
|
||||
if (waitingForLogin && !activeAccount) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Waiting for login...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show processing state
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||
<p className="text-lg">Saving bookmark...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return (
|
||||
<>
|
||||
<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 && (
|
||||
<div
|
||||
className="profile-avatar"
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/my')}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<IconButton
|
||||
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 && (
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
|
||||
|
||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupporters = async () => {
|
||||
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
|
||||
if (zappers.length > 0) {
|
||||
const pubkeys = zappers.map(z => z.pubkey)
|
||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||
// Fetch profiles in background without blocking
|
||||
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
|
||||
}
|
||||
|
||||
setSupporters(zappers)
|
||||
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
loadSupporters()
|
||||
}, [relayPool, eventStore, settings])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{supporters.length === 0 ? (
|
||||
{loading ? (
|
||||
<>
|
||||
{/* Loading Skeletons */}
|
||||
<div className="mb-16 md:mb-20">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Legends
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Supporters
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : supporters.length === 0 ? (
|
||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||
</div>
|
||||
@@ -231,5 +249,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
||||
)
|
||||
}
|
||||
|
||||
interface SupporterSkeletonProps {
|
||||
isWhale: boolean
|
||||
}
|
||||
|
||||
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
{/* Avatar Skeleton */}
|
||||
<div
|
||||
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
|
||||
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Whale Badge Skeleton */}
|
||||
{isWhale && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-border)',
|
||||
borderColor: 'var(--color-bg)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Total Skeleton */}
|
||||
<div className="mt-2 text-center space-y-1">
|
||||
<div
|
||||
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
<div
|
||||
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Support
|
||||
|
||||
|
||||
113
src/components/TTSControls.tsx
Normal file
113
src/components/TTSControls.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTextToSpeech } from '../hooks/useTextToSpeech'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { detect } from 'tinyld'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
defaultLang?: string
|
||||
className?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||
|
||||
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
|
||||
const {
|
||||
supported, speaking, paused,
|
||||
speak, pause, resume,
|
||||
rate, setRate
|
||||
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
|
||||
|
||||
const canPlay = supported && text?.trim().length > 0
|
||||
|
||||
const resolvedSystemLang = useMemo(() => {
|
||||
const mode = settings?.ttsLanguageMode
|
||||
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
|
||||
return navigator?.language?.split('-')[0]
|
||||
}
|
||||
return undefined
|
||||
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
|
||||
|
||||
const detectContentLang = useMemo(() => {
|
||||
const mode = settings?.ttsLanguageMode
|
||||
if (mode) return mode === 'content'
|
||||
return settings?.ttsDetectContentLanguage !== false
|
||||
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
|
||||
|
||||
const specificLang = useMemo(() => {
|
||||
const mode = settings?.ttsLanguageMode
|
||||
// If mode is not 'system' or 'content', it's a specific language code
|
||||
if (mode && mode !== 'system' && mode !== 'content') {
|
||||
return mode
|
||||
}
|
||||
return undefined
|
||||
}, [settings?.ttsLanguageMode])
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!canPlay) return
|
||||
|
||||
if (!speaking) {
|
||||
let langOverride: string | undefined
|
||||
|
||||
// Priority: specific language > content detection > system language
|
||||
if (specificLang) {
|
||||
langOverride = specificLang
|
||||
} else if (detectContentLang && text) {
|
||||
try {
|
||||
const lang = detect(text)
|
||||
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||
} catch (err) {
|
||||
// ignore detection errors
|
||||
}
|
||||
}
|
||||
if (!langOverride && resolvedSystemLang) {
|
||||
langOverride = resolvedSystemLang
|
||||
}
|
||||
speak(text, langOverride)
|
||||
} else if (paused) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCycleSpeed = () => {
|
||||
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||
const next = SPEED_OPTIONS[nextIndex]
|
||||
setRate(next)
|
||||
}
|
||||
|
||||
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
|
||||
|
||||
if (!supported) return null
|
||||
|
||||
return (
|
||||
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="article-menu-btn"
|
||||
onClick={handlePlayPause}
|
||||
title={playLabel}
|
||||
disabled={!canPlay}
|
||||
>
|
||||
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="article-menu-btn"
|
||||
onClick={handleCycleSpeed}
|
||||
title="Cycle speed"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGauge} />
|
||||
<span>{rate}x</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TTSControls
|
||||
|
||||
@@ -387,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -413,6 +418,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
164
src/components/VideoEmbedProcessor.tsx
Normal file
164
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useMemo, forwardRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
|
||||
interface VideoEmbedProcessorProps {
|
||||
html: string
|
||||
renderVideoLinksAsEmbeds: boolean
|
||||
className?: string
|
||||
onMouseUp?: (e: React.MouseEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that processes HTML content and optionally embeds video links
|
||||
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
|
||||
*/
|
||||
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||
html,
|
||||
renderVideoLinksAsEmbeds,
|
||||
className,
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
}, ref) => {
|
||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||
const { processedHtml, videoUrls } = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return { processedHtml: html, videoUrls: [] }
|
||||
}
|
||||
|
||||
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||
let result = html
|
||||
|
||||
const collectedUrls: string[] = []
|
||||
let placeholderIndex = 0
|
||||
|
||||
// 1) Replace entire <video>...</video> blocks when they reference a video URL
|
||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||
const videoBlocks = result.match(videoBlockPattern) || []
|
||||
videoBlocks.forEach((block) => {
|
||||
// Try src on <video>
|
||||
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 {
|
||||
// Try nested <source>
|
||||
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) {
|
||||
collectedUrls.push(url)
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
result = result.replace(new RegExp(escaped, 'g'), placeholder)
|
||||
placeholderIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// 2) Replace entire <img ...> tags if their src points to a video
|
||||
const imgTagPattern = /<img[^>]*>/gi
|
||||
const allImgTags = result.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]) {
|
||||
const videoUrl = srcMatch[1]
|
||||
collectedUrls.push(videoUrl)
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
|
||||
placeholderIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
|
||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||
const allUrls: string[] = result.match(allUrlPattern) || []
|
||||
const platformVideoUrls = allUrls.filter(url => {
|
||||
// include URLs classified as video and not already collected
|
||||
const classification = classifyUrl(url)
|
||||
return classification.type === 'video' && !collectedUrls.includes(url)
|
||||
})
|
||||
|
||||
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||
|
||||
let finalHtml = result
|
||||
remainingUrls.forEach((url) => {
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
collectedUrls.push(url)
|
||||
placeholderIndex++
|
||||
})
|
||||
|
||||
// Return both processed HTML and collected URLs (in the same order as placeholders)
|
||||
return {
|
||||
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
|
||||
videoUrls: collectedUrls
|
||||
}
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
// If no video embedding is enabled, just render the HTML normally
|
||||
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Split the HTML by video placeholders and render with embedded players
|
||||
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||
{parts.map((part, index) => {
|
||||
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||
if (videoMatch) {
|
||||
const videoIndex = parseInt(videoMatch[1])
|
||||
const videoUrl = videoUrls[videoIndex]
|
||||
if (videoUrl) {
|
||||
return (
|
||||
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Regular HTML content - only render if not empty
|
||||
if (part.trim()) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
|
||||
|
||||
export default VideoEmbedProcessor
|
||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||
* These are accounts known to be bots or automated services
|
||||
*/
|
||||
export const BOT_PUBKEYS = new Set([
|
||||
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a pubkey corresponds to a known bot
|
||||
*/
|
||||
export function isKnownBot(pubkey: string): boolean {
|
||||
return BOT_PUBKEYS.has(pubkey)
|
||||
}
|
||||
@@ -11,12 +11,11 @@ export const RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
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 { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -36,7 +51,18 @@ export function useArticleLoader({
|
||||
setCurrentArticle,
|
||||
settings
|
||||
}: UseArticleLoaderProps) {
|
||||
const location = useLocation()
|
||||
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(() => {
|
||||
mountedRef.current = true
|
||||
@@ -44,67 +70,204 @@ export function useArticleLoader({
|
||||
if (!relayPool || !naddr) return
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
if (previewData) {
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from store or relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.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)
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
} else {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode naddr to filter
|
||||
const decoded = nip19.decode(naddr)
|
||||
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 {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
const le = latestEvent as NostrEvent | null
|
||||
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(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings
|
||||
)
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
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) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load article:', err)
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setReaderContent({
|
||||
title: 'Error Loading Article',
|
||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
@@ -120,7 +283,19 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Intentionally excluding setter functions from dependencies to prevent race conditions
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [naddr, relayPool, settings])
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
previewData,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
])
|
||||
}
|
||||
|
||||
132
src/hooks/useEventLoader.ts
Normal file
132
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { eventManager } from '../services/eventManager'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
|
||||
interface UseEventLoaderProps {
|
||||
eventId?: string
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
export function useEventLoader({
|
||||
eventId,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
}: UseEventLoaderProps) {
|
||||
const displayEvent = useCallback((event: NostrEvent) => {
|
||||
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||
const escapedContent = event.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br />')
|
||||
|
||||
// Initial title
|
||||
let title = `Note (${event.kind})`
|
||||
if (event.kind === 1) {
|
||||
title = `Note by @${event.pubkey.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
// Emit immediately
|
||||
const baseContent: ReadableContent = {
|
||||
url: '',
|
||||
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
|
||||
title,
|
||||
published: event.created_at
|
||||
}
|
||||
setReaderContent(baseContent)
|
||||
|
||||
// Background: resolve author profile for kind:1 and update title
|
||||
if (event.kind === 1 && eventStore) {
|
||||
(async () => {
|
||||
try {
|
||||
let resolved = ''
|
||||
|
||||
// First, try to get from event store cache
|
||||
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
||||
if (storedProfile) {
|
||||
try {
|
||||
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in event store, fetch from relays
|
||||
if (!resolved && relayPool) {
|
||||
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
||||
if (profiles && profiles.length > 0) {
|
||||
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||
try {
|
||||
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
||||
}
|
||||
} catch {
|
||||
// ignore profile failures; keep fallback title
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [setReaderContent, relayPool, eventStore])
|
||||
|
||||
// Initialize event manager with services
|
||||
useEffect(() => {
|
||||
eventManager.setServices(eventStore || null, relayPool || null)
|
||||
}, [eventStore, relayPool])
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return
|
||||
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
|
||||
setIsCollapsed(false)
|
||||
|
||||
// Fetch using event manager (handles cache, deduplication, and retry)
|
||||
let cancelled = false
|
||||
|
||||
eventManager.fetchEvent(eventId).then(
|
||||
(event) => {
|
||||
if (!cancelled) {
|
||||
displayEvent(event)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) {
|
||||
const errorContent: ReadableContent = {
|
||||
url: '',
|
||||
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||
title: 'Error'
|
||||
}
|
||||
setReaderContent(errorContent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export function useExternalUrlLoader({
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
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
|
||||
const urlFilter = useMemo(() => {
|
||||
@@ -70,6 +72,7 @@ export function useExternalUrlLoader({
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
@@ -83,6 +86,7 @@ export function useExternalUrlLoader({
|
||||
const content = await fetchReadableContent(url)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
setReaderContent(content)
|
||||
setReaderLoading(false)
|
||||
@@ -114,6 +118,7 @@ export function useExternalUrlLoader({
|
||||
url,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
if (seen.has(highlight.id)) return
|
||||
seen.add(highlight.id)
|
||||
@@ -131,13 +136,13 @@ export function useExternalUrlLoader({
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: filename,
|
||||
@@ -154,9 +159,20 @@ export function useExternalUrlLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Intentionally excluding setter functions from dependencies to prevent race conditions
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, relayPool, eventStore, cachedUrlHighlights])
|
||||
}, [
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
cachedUrlHighlights,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setSelectedUrl,
|
||||
setHighlights,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setHighlightsLoading
|
||||
])
|
||||
|
||||
// Keep UI highlights synced with cached store updates without reloading content
|
||||
useEffect(() => {
|
||||
@@ -169,8 +185,6 @@ export function useExternalUrlLoader({
|
||||
const next = [...additions, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
// setHighlights is intentionally excluded from dependencies - it's stable
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedUrlHighlights, url])
|
||||
}, [cachedUrlHighlights, url, setHighlights])
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear previous render immediately to avoid showing stale content while processing
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
|
||||
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||
}
|
||||
|
||||
@@ -18,63 +17,58 @@ export const useReadingPosition = ({
|
||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000,
|
||||
completionHoldMs = 2000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const positionRef = useRef(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hasSavedOnce = useRef(false)
|
||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const suppressUntilRef = useRef<number>(0)
|
||||
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
|
||||
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
|
||||
|
||||
// Debounced save function
|
||||
// Suppress auto-saves for a given duration (used after programmatic restore)
|
||||
const suppressSavesFor = useCallback((ms: number) => {
|
||||
const until = Date.now() + ms
|
||||
suppressUntilRef.current = until
|
||||
}, [])
|
||||
|
||||
// Throttled save function - saves at 1s intervals during scrolling
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't save if position hasn't changed significantly (less than 1%)
|
||||
// But always save if we've reached 100% (completion)
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||
const isInitialSave = !hasSavedOnce.current
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
||||
// Not significant enough to save
|
||||
// Always save instantly when we reach completion (1.0)
|
||||
if (currentPosition === 1 && !lastSaved100Ref.current) {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSaved100Ref.current = true
|
||||
onSave(1)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
// Always update the pending position (latest position to save)
|
||||
pendingPositionRef.current = currentPosition
|
||||
|
||||
// Throttle: only schedule a save if one isn't already pending
|
||||
// This ensures saves happen at regular 1s intervals during continuous scrolling
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
return // Already have a save scheduled, don't reset the timer
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
const THROTTLE_MS = 1000
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
hasSavedOnce.current = true
|
||||
onSave(currentPosition)
|
||||
}, autoSaveInterval)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
|
||||
// Immediate save function
|
||||
const saveNow = useCallback(() => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Cancel any pending saves
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// Save the latest position, not the one from when timer was scheduled
|
||||
const positionToSave = pendingPositionRef.current
|
||||
onSave(positionToSave)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Always allow immediate save (including 0%)
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
onSave(position)
|
||||
}, [syncEnabled, onSave, position])
|
||||
}, THROTTLE_MS)
|
||||
}, [syncEnabled, onSave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -88,28 +82,27 @@ export const useReadingPosition = ({
|
||||
const windowHeight = window.innerHeight
|
||||
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
|
||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||
const maxScroll = documentHeight - windowHeight
|
||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
|
||||
// If we're within 5px of the bottom, consider it 100%
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
|
||||
// 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))
|
||||
|
||||
// Only log on significant changes (every 5%) to avoid flooding console
|
||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
||||
const newPercent = Math.floor(clampedProgress * 20)
|
||||
if (prevPercent !== newPercent) {
|
||||
// Position threshold crossed
|
||||
}
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||
if (Date.now() >= suppressUntilRef.current) {
|
||||
scheduleSave(clampedProgress)
|
||||
}
|
||||
// Note: Suppression is silent to avoid log spam during scrolling
|
||||
|
||||
// Completion detection with 2s hold at 100%
|
||||
if (!hasTriggeredComplete.current) {
|
||||
@@ -152,25 +145,20 @@ export const useReadingPosition = ({
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
|
||||
// Clear save timer on unmount
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
|
||||
// Only clear completion timer since that's tied to the current scroll session
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
}
|
||||
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
hasSavedOnce.current = false
|
||||
lastSavedPosition.current = 0
|
||||
lastSaved100Ref.current = false
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
completionTimerRef.current = null
|
||||
@@ -182,6 +170,6 @@ export const useReadingPosition = ({
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow
|
||||
suppressSavesFor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
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 { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
@@ -16,30 +16,28 @@ interface UseSettingsParams {
|
||||
}
|
||||
|
||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||
const [settings, setSettings] = useState<UserSettings>({})
|
||||
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
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(() => {
|
||||
if (!relayPool || !pubkey || !eventStore) return
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAndWatch()
|
||||
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
// Start settings stream: seed from store, stream updates to store in background
|
||||
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
// Also watch store reactively for any further updates
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
stopNetwork()
|
||||
}
|
||||
}, [relayPool, pubkey, eventStore])
|
||||
|
||||
// Apply settings to document
|
||||
@@ -73,6 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
// Set image max-width based on full-width setting
|
||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||
|
||||
}
|
||||
|
||||
applyStyles()
|
||||
|
||||
288
src/hooks/useTextToSpeech.ts
Normal file
288
src/hooks/useTextToSpeech.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
// Web Speech API types
|
||||
type SpeechSynthesisVoice = {
|
||||
name: string
|
||||
voiceURI: string
|
||||
lang: string
|
||||
localService: boolean
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface UseTTSOptions {
|
||||
defaultLang?: string
|
||||
defaultRate?: number
|
||||
defaultPitch?: number
|
||||
defaultVolume?: number
|
||||
}
|
||||
|
||||
export interface UseTTS {
|
||||
supported: boolean
|
||||
speaking: boolean
|
||||
paused: boolean
|
||||
voices: SpeechSynthesisVoice[]
|
||||
voice: SpeechSynthesisVoice | null
|
||||
rate: number
|
||||
pitch: number
|
||||
volume: number
|
||||
setVoice: (v: SpeechSynthesisVoice | null) => void
|
||||
setRate: (r: number) => void
|
||||
setPitch: (p: number) => void
|
||||
setVolume: (v: number) => void
|
||||
speak: (text: string, langOverride?: string) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
|
||||
const supported = !!synth
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
||||
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
|
||||
const [speaking, setSpeaking] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [rate, setRate] = useState(options.defaultRate ?? 2.1)
|
||||
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
|
||||
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
|
||||
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||
const spokenTextRef = useRef<string>('')
|
||||
const charIndexRef = useRef<number>(0)
|
||||
// Chunking state to reliably speak long texts from web URLs
|
||||
const chunksRef = useRef<string[]>([])
|
||||
const chunkIndexRef = useRef<number>(0)
|
||||
const globalOffsetRef = useRef<number>(0)
|
||||
const langRef = useRef<string | undefined>(undefined)
|
||||
|
||||
// Update rate when defaultRate option changes
|
||||
useEffect(() => {
|
||||
if (options.defaultRate !== undefined) {
|
||||
setRate(options.defaultRate)
|
||||
}
|
||||
}, [options.defaultRate])
|
||||
|
||||
// Load voices (async in many browsers)
|
||||
useEffect(() => {
|
||||
if (!supported) return
|
||||
const load = () => {
|
||||
const v = synth!.getVoices()
|
||||
setVoices(v)
|
||||
if (!voice && v.length) {
|
||||
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||
setVoice(byLang || v[0] || null)
|
||||
}
|
||||
}
|
||||
load()
|
||||
const handleVoicesChanged = () => load()
|
||||
synth!.addEventListener('voiceschanged', handleVoicesChanged)
|
||||
return () => {
|
||||
synth!.removeEventListener('voiceschanged', handleVoicesChanged)
|
||||
}
|
||||
}, [supported, defaultLang, voice, synth])
|
||||
|
||||
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
|
||||
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
||||
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||
const resolvedLang = langOverride || voice?.lang || defaultLang
|
||||
u.lang = resolvedLang
|
||||
if (langOverride) {
|
||||
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
||||
if (match) {
|
||||
u.voice = match
|
||||
} else if (voice) {
|
||||
u.voice = voice
|
||||
}
|
||||
} else if (voice) {
|
||||
u.voice = voice
|
||||
}
|
||||
u.rate = rate
|
||||
u.pitch = pitch
|
||||
u.volume = volume
|
||||
|
||||
const self = u
|
||||
|
||||
u.onstart = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
setSpeaking(true)
|
||||
setPaused(false)
|
||||
}
|
||||
u.onpause = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
setPaused(true)
|
||||
}
|
||||
u.onresume = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
setPaused(false)
|
||||
}
|
||||
u.onend = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
// Continue with next chunk if available
|
||||
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||
if (hasMore) {
|
||||
chunkIndexRef.current++
|
||||
charIndexRef.current += self.text.length
|
||||
const nextChunk = chunksRef.current[chunkIndexRef.current]
|
||||
const nextUtterance = createUtterance(nextChunk, langRef.current)
|
||||
utteranceRef.current = nextUtterance
|
||||
synth!.speak(nextUtterance)
|
||||
} else {
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
}
|
||||
}
|
||||
u.onerror = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
}
|
||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||
if (utteranceRef.current !== self) return
|
||||
if (typeof ev.charIndex === 'number') {
|
||||
const newIndex = globalOffsetRef.current + ev.charIndex
|
||||
if (newIndex > charIndexRef.current) {
|
||||
charIndexRef.current = newIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return u
|
||||
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
|
||||
|
||||
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= maxLen) return [normalized]
|
||||
const sentences = normalized.split(/(?<=[.!?])\s+/)
|
||||
const chunks: string[] = []
|
||||
let current = ''
|
||||
for (const s of sentences) {
|
||||
if ((current + (current ? ' ' : '') + s).length > maxLen) {
|
||||
if (current) chunks.push(current)
|
||||
if (s.length > maxLen) {
|
||||
// Hard split very long sentence
|
||||
for (let i = 0; i < s.length; i += maxLen) {
|
||||
chunks.push(s.slice(i, i + maxLen))
|
||||
}
|
||||
current = ''
|
||||
} else {
|
||||
current = s
|
||||
}
|
||||
} else {
|
||||
current = current ? `${current} ${s}` : s
|
||||
}
|
||||
}
|
||||
if (current) chunks.push(current)
|
||||
return chunks
|
||||
}, [])
|
||||
|
||||
const startSpeakingChunks = useCallback((text: string) => {
|
||||
chunksRef.current = splitIntoChunks(text)
|
||||
chunkIndexRef.current = 0
|
||||
globalOffsetRef.current = 0
|
||||
const first = chunksRef.current[0] || ''
|
||||
const u = createUtterance(first, langRef.current)
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
}, [createUtterance, splitIntoChunks, synth])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!supported) return
|
||||
synth!.cancel()
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
charIndexRef.current = 0
|
||||
spokenTextRef.current = ''
|
||||
chunksRef.current = []
|
||||
chunkIndexRef.current = 0
|
||||
globalOffsetRef.current = 0
|
||||
}, [supported, synth])
|
||||
|
||||
const speak = useCallback((text: string, langOverride?: string) => {
|
||||
if (!supported || !text?.trim()) return
|
||||
synth!.cancel()
|
||||
spokenTextRef.current = text
|
||||
charIndexRef.current = 0
|
||||
langRef.current = langOverride
|
||||
startSpeakingChunks(text)
|
||||
}, [supported, synth, startSpeakingChunks])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
synth!.pause()
|
||||
setPaused(true)
|
||||
}
|
||||
}, [supported, synth])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && synth!.paused) {
|
||||
synth!.resume()
|
||||
setPaused(false)
|
||||
}
|
||||
}, [supported, synth])
|
||||
|
||||
// Update rate in real-time: while speaking, restart from last boundary with new rate.
|
||||
useEffect(() => {
|
||||
if (!supported) return
|
||||
if (!utteranceRef.current) return
|
||||
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
|
||||
synth!.cancel()
|
||||
// restart chunked from current global index
|
||||
spokenTextRef.current = remainingText
|
||||
charIndexRef.current = 0
|
||||
// keep current language selection; no change needed here
|
||||
startSpeakingChunks(remainingText)
|
||||
return
|
||||
}
|
||||
|
||||
if (utteranceRef.current) {
|
||||
utteranceRef.current.rate = rate
|
||||
}
|
||||
}, [rate, supported, synth, startSpeakingChunks])
|
||||
|
||||
const updateRate = useCallback((newRate: number) => {
|
||||
setRate(newRate)
|
||||
if (!supported) return
|
||||
if (!utteranceRef.current) return
|
||||
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
synth!.cancel()
|
||||
const u = createUtterance(remainingText)
|
||||
// ensure the new rate is applied immediately on the new utterance
|
||||
u.rate = newRate
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
} else if (utteranceRef.current) {
|
||||
utteranceRef.current.rate = newRate
|
||||
}
|
||||
}, [supported, synth, createUtterance])
|
||||
|
||||
// stop TTS when unmounting
|
||||
useEffect(() => stop, [stop])
|
||||
|
||||
return useMemo(() => ({
|
||||
supported,
|
||||
speaking,
|
||||
paused,
|
||||
voices,
|
||||
voice,
|
||||
rate,
|
||||
setRate: updateRate,
|
||||
pitch, setPitch,
|
||||
volume, setVolume,
|
||||
setVoice,
|
||||
speak, pause, resume, stop
|
||||
}), [supported, speaking, paused, voices, voice, rate, updateRate, pitch, volume, setVoice, speak, pause, resume, stop])
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register Service Worker for PWA functionality (production only)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.register('/sw.js')
|
||||
.then(registration => {
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
window.dispatchEvent(updateAvailable)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
@@ -35,14 +34,12 @@ class ArchiveController {
|
||||
if (!this.markedIds.has(id)) {
|
||||
this.markedIds.add(id)
|
||||
this.emit()
|
||||
console.log('[archive] mark() added', id.slice(0, 48))
|
||||
}
|
||||
}
|
||||
|
||||
unmark(id: string): void {
|
||||
if (this.markedIds.delete(id)) {
|
||||
this.emit()
|
||||
console.log('[archive] unmark() removed', id.slice(0, 48))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +58,7 @@ class ArchiveController {
|
||||
reset(): void {
|
||||
this.generation++
|
||||
if (this.timelineSubscription) {
|
||||
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
|
||||
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
this.markedIds = new Set()
|
||||
@@ -80,13 +77,11 @@ class ArchiveController {
|
||||
const startGen = this.generation
|
||||
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[archive] start() skipped - already loaded for pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as loaded immediately (fetch runs non-blocking)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
console.log('[archive] start() begin for pubkey:', pubkey.slice(0, 12), '...')
|
||||
|
||||
// Handlers for streaming queries
|
||||
const handleUrlReaction = (evt: NostrEvent) => {
|
||||
@@ -95,7 +90,6 @@ class ArchiveController {
|
||||
if (!rTag) return
|
||||
this.markedIds.add(rTag)
|
||||
this.emit()
|
||||
console.log('[archive] mark url:', rTag)
|
||||
}
|
||||
|
||||
const handleEventReaction = (evt: NostrEvent) => {
|
||||
@@ -110,7 +104,6 @@ class ArchiveController {
|
||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||
this.markedIds.add(naddr)
|
||||
this.emit()
|
||||
console.log('[archive] mark naddr via a-tag:', naddr.slice(0, 24), '...')
|
||||
return
|
||||
}
|
||||
} catch { /* ignore malformed a-tag */ }
|
||||
@@ -118,14 +111,13 @@ class ArchiveController {
|
||||
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||
if (!eTag) return
|
||||
this.pendingEventIds.add(eTag)
|
||||
console.log('[archive] pending event id:', eTag)
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream kind:17 and kind:7 in parallel
|
||||
const [kind17, kind7] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
|
||||
])
|
||||
|
||||
if (startGen !== this.generation) return
|
||||
@@ -133,27 +125,23 @@ class ArchiveController {
|
||||
// Include EOSE events
|
||||
kind17.forEach(handleUrlReaction)
|
||||
kind7.forEach(handleEventReaction)
|
||||
console.log('[archive] EOSE sizes kind17:', kind17.length, 'kind7:', kind7.length, 'pendingEventIds:', this.pendingEventIds.size)
|
||||
|
||||
if (this.pendingEventIds.size > 0) {
|
||||
// Fetch referenced articles (kind:30023) and map event IDs to naddr
|
||||
const ids = Array.from(this.pendingEventIds)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
|
||||
console.log('[archive] fetched articles for mapping:', articleEvents.length)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
|
||||
for (const article of articleEvents) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
console.log('[archive] mark naddr:', naddr.slice(0, 24), '...')
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
}
|
||||
this.emit()
|
||||
}
|
||||
console.log('[archive] total marked ids:', this.markedIds.size)
|
||||
|
||||
// Try immediate mapping via eventStore for any still-pending e-ids
|
||||
if (this.pendingEventIds.size > 0) {
|
||||
@@ -167,7 +155,6 @@ class ArchiveController {
|
||||
if (dTag) {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
console.log('[archive] map via eventStore naddr:', naddr.slice(0, 24), '...')
|
||||
}
|
||||
} else {
|
||||
stillPending.add(eId)
|
||||
@@ -178,7 +165,7 @@ class ArchiveController {
|
||||
if (stillPending.size > 0) {
|
||||
// Subscribe to future 30023 arrivals to finalize mapping
|
||||
if (this.timelineSubscription) {
|
||||
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
|
||||
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
|
||||
@@ -193,16 +180,14 @@ class ArchiveController {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||
this.markedIds.add(naddr)
|
||||
this.pendingEventIds.delete(evt.id)
|
||||
console.log('[archive] map via timeline naddr:', naddr.slice(0, 24), '...')
|
||||
this.emit()
|
||||
} catch (e) { console.warn('[archive] map via timeline encode error', e) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-blocking fetch; ignore errors here
|
||||
console.warn('[archive] start() error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
// Define relays to query - use union of relay hints from naddr and configured relays
|
||||
// This avoids failures when naddr contains stale/unreachable relay hints
|
||||
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
||||
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||
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
|
||||
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 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) {
|
||||
throw new Error('Article not found')
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers, EventStore } from 'applesauce-core'
|
||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { EventPointer } from 'nostr-tools/nip19'
|
||||
import { merge } from 'rxjs'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import {
|
||||
@@ -64,11 +60,8 @@ class BookmarkController {
|
||||
}> = new Map()
|
||||
private isLoading = false
|
||||
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 relayPool: RelayPool | null = null
|
||||
|
||||
onRawEvent(cb: RawEventCallback): () => void {
|
||||
this.rawEventListeners.push(cb)
|
||||
@@ -117,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[],
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.eventLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,71 +128,146 @@ class BookmarkController {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert IDs to EventPointers
|
||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||
|
||||
// Use EventLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
// Fetch events using local-first queryEvents
|
||||
await queryEvents(
|
||||
this.relayPool,
|
||||
{ ids: unique },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
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()
|
||||
}
|
||||
|
||||
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 }>,
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
if (coords.length === 0) return
|
||||
if (coords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert coordinates to AddressPointers
|
||||
const pointers = coords.map(c => ({
|
||||
kind: c.kind,
|
||||
pubkey: c.pubkey,
|
||||
identifier: c.identifier
|
||||
}))
|
||||
|
||||
// Use AddressLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
||||
next: (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)
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - AddressLoader handles retries
|
||||
// Group by kind and pubkey for efficient batching
|
||||
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||
|
||||
for (const coord of coords) {
|
||||
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 || '')
|
||||
}
|
||||
|
||||
// Kick off all queries in parallel (fire-and-forget)
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
for (const [kind, byPubkey] of filtersByKind) {
|
||||
for (const [pubkey, identifiers] of byPubkey) {
|
||||
// Separate empty and non-empty identifiers
|
||||
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||
|
||||
// Fetch events with non-empty d-tags
|
||||
if (nonEmptyIdentifiers.length > 0) {
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||
if (hasEmptyIdentifier) {
|
||||
promises.push(
|
||||
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(
|
||||
@@ -244,42 +312,58 @@ class BookmarkController {
|
||||
})
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
const deduped = dedupeBookmarksById(allItems)
|
||||
|
||||
// Separate hex IDs from coordinates
|
||||
// Separate hex IDs from coordinates for fetching
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
coordinates.push(i.id)
|
||||
// Request hydration for all items that don't have content yet
|
||||
deduped.forEach(i => {
|
||||
// If item has no content, we need to fetch it
|
||||
if (!i.content || i.content.length === 0) {
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to build and emit bookmarks
|
||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
||||
// This preserves the original public/private split while still getting all the content
|
||||
const allBookmarks = [
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
|
||||
]
|
||||
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
content: b.content || ''
|
||||
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
|
||||
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
|
||||
}))
|
||||
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.map(b => ({
|
||||
...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 = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||
created_at: newestCreatedAt || 0,
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||
@@ -295,7 +379,7 @@ class BookmarkController {
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
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 onProgress = () => emitBookmarks(idToEvent)
|
||||
@@ -310,10 +394,14 @@ class BookmarkController {
|
||||
}
|
||||
})
|
||||
|
||||
// Kick off batched hydration (streaming, non-blocking)
|
||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||
// Kick off hydration (streaming, non-blocking, local-first)
|
||||
// Fire-and-forget - don't await, let it run in background
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||
// 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) {
|
||||
console.error('Failed to build bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
@@ -324,8 +412,13 @@ class BookmarkController {
|
||||
relayPool: RelayPool
|
||||
activeAccount: unknown
|
||||
accountManager: { getActive: () => unknown }
|
||||
eventStore?: EventStore
|
||||
}): Promise<void> {
|
||||
const { relayPool, activeAccount, accountManager } = options
|
||||
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||
|
||||
// Store references for hydration
|
||||
this.relayPool = relayPool
|
||||
this.externalEventStore = eventStore || null
|
||||
|
||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||
return
|
||||
@@ -336,16 +429,6 @@ class BookmarkController {
|
||||
// Increment generation to cancel any in-flight hydration
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
}
|
||||
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
|
||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
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>()
|
||||
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 prev = byD.get(d)
|
||||
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[] = []
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
out.push(...setsAndNamedLists)
|
||||
// Add web bookmarks as individual events
|
||||
// Add deduplicated web bookmarks as individual events
|
||||
out.push(...webBookmarks)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays?: string[]
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface EventPointer {
|
||||
id: string
|
||||
relays?: string[]
|
||||
author?: string
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface ApplesauceBookmarks {
|
||||
@@ -67,48 +71,44 @@ export const processApplesauceBookmarks = (
|
||||
): IndividualBookmark[] => {
|
||||
if (!bookmarks) return []
|
||||
|
||||
console.log('[BOOKMARK_TS] processApplesauceBookmarks called with parentCreatedAt:', parentCreatedAt, 'isPrivate:', isPrivate)
|
||||
|
||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||
const allItems: IndividualBookmark[] = []
|
||||
|
||||
// Process notes (EventPointer[])
|
||||
if (applesauceBookmarks.notes) {
|
||||
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.notes.length, 'notes with timestamp:', parentCreatedAt || 0)
|
||||
applesauceBookmarks.notes.forEach((note: EventPointer) => {
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: note.created_at ?? null,
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process articles (AddressPointer[])
|
||||
if (applesauceBookmarks.articles) {
|
||||
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.articles.length, 'articles with timestamp:', parentCreatedAt || 0)
|
||||
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
|
||||
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
|
||||
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: article.created_at ?? null,
|
||||
pubkey: article.pubkey,
|
||||
kind: article.kind, // Usually 30023 for long-form articles
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -119,33 +119,32 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: 0, // Hashtags don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['t', hashtag]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process URLs (string[])
|
||||
if (applesauceBookmarks.urls) {
|
||||
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.urls.length, 'URLs with timestamp:', parentCreatedAt || 0)
|
||||
applesauceBookmarks.urls.forEach((url: string) => {
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: parentCreatedAt || 0,
|
||||
created_at: 0, // URLs don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['r', url]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: parentCreatedAt || 0
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -154,20 +153,24 @@ export const processApplesauceBookmarks = (
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
const processed = bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || parentCreatedAt || 0,
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || parentCreatedAt || 0
|
||||
}))
|
||||
.map((bookmark: BookmarkData) => {
|
||||
return {
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at ?? null,
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
@@ -189,6 +192,9 @@ export function hydrateItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all events with content get parsed content for proper rendering
|
||||
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||
|
||||
return {
|
||||
...item,
|
||||
pubkey: ev.pubkey || item.pubkey,
|
||||
@@ -196,13 +202,12 @@ export function hydrateItems(
|
||||
created_at: ev.created_at || item.created_at,
|
||||
kind: ev.kind || item.kind,
|
||||
tags: ev.tags || item.tags,
|
||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
||||
parsedContent: parsedContent || item.parsedContent
|
||||
}
|
||||
})
|
||||
.filter(item => {
|
||||
// Filter out bookmark list events (they're containers, not content)
|
||||
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
|
||||
console.log('[BOOKMARK_TS] After hydration - id:', item.id, 'kind:', item.kind, 'isBookmarkListEvent:', isBookmarkListEvent, 'content:', item.content?.substring(0, 50))
|
||||
return !isBookmarkListEvent
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ export async function collectBookmarksFromEvents(
|
||||
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
|
||||
|
||||
for (const evt of bookmarkListEvents) {
|
||||
console.log('[BOOKMARK_TS] Processing bookmark event', evt.id, 'kind:', evt.kind, 'created_at:', evt.created_at)
|
||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
@@ -134,29 +133,36 @@ export async function collectBookmarksFromEvents(
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
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({
|
||||
id: evt.id,
|
||||
id: webBookmarkId,
|
||||
content: evt.content || '',
|
||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
created_at: evt.created_at ?? null,
|
||||
pubkey: evt.pubkey,
|
||||
kind: evt.kind,
|
||||
tags: evt.tags || [],
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
sourceKind: 39701,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
setImage,
|
||||
listUpdatedAt: evt.created_at ?? null
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||
|
||||
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
|
||||
...processedPub.map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
|
||||
162
src/services/eventManager.ts
Normal file
162
src/services/eventManager.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { createEventLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (event: NostrEvent) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized event manager for event fetching and caching
|
||||
* Handles deduplication of concurrent requests and coordinate with relay pool
|
||||
*/
|
||||
class EventManager {
|
||||
private eventStore: IEventStore | null = null
|
||||
private relayPool: RelayPool | null = null
|
||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||
|
||||
// Track pending requests to deduplicate and resolve all at once
|
||||
private pendingRequests = new Map<string, PendingRequest[]>()
|
||||
|
||||
// Safety timeout for event fetches (ms)
|
||||
private fetchTimeoutMs = 12000
|
||||
// Retry policy
|
||||
private maxAttempts = 4
|
||||
private baseBackoffMs = 700
|
||||
|
||||
/**
|
||||
* Initialize the event manager with event store and relay pool
|
||||
*/
|
||||
setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void {
|
||||
this.eventStore = eventStore
|
||||
this.relayPool = relayPool
|
||||
|
||||
// Recreate loader when services change
|
||||
if (relayPool) {
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: eventStore || undefined
|
||||
})
|
||||
|
||||
// Retry any pending requests now that we have a loader
|
||||
this.retryAllPending()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached event from event store
|
||||
*/
|
||||
getCachedEvent(eventId: string): NostrEvent | null {
|
||||
if (!this.eventStore) return null
|
||||
return this.eventStore.getEvent(eventId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an event by ID, returning a promise
|
||||
* Automatically deduplicates concurrent requests for the same event
|
||||
*/
|
||||
fetchEvent(eventId: string): Promise<NostrEvent> {
|
||||
// Check cache first
|
||||
const cached = this.getCachedEvent(eventId)
|
||||
if (cached) {
|
||||
return Promise.resolve(cached)
|
||||
}
|
||||
|
||||
return new Promise<NostrEvent>((resolve, reject) => {
|
||||
// Check if we're already fetching this event
|
||||
if (this.pendingRequests.has(eventId)) {
|
||||
// Add to existing request queue
|
||||
this.pendingRequests.get(eventId)!.push({ resolve, reject })
|
||||
return
|
||||
}
|
||||
|
||||
// Start a new fetch request
|
||||
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
||||
this.fetchFromRelayWithRetry(eventId, 1)
|
||||
})
|
||||
}
|
||||
|
||||
private resolvePending(eventId: string, event: NostrEvent): void {
|
||||
const requests = this.pendingRequests.get(eventId) || []
|
||||
this.pendingRequests.delete(eventId)
|
||||
requests.forEach(req => req.resolve(event))
|
||||
}
|
||||
|
||||
private rejectPending(eventId: string, error: Error): void {
|
||||
const requests = this.pendingRequests.get(eventId) || []
|
||||
this.pendingRequests.delete(eventId)
|
||||
requests.forEach(req => req.reject(error))
|
||||
}
|
||||
|
||||
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
|
||||
// If no loader yet, schedule retry
|
||||
if (!this.relayPool || !this.eventLoader) {
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(eventId)) {
|
||||
this.fetchFromRelayWithRetry(eventId, attempt)
|
||||
}
|
||||
}, this.baseBackoffMs)
|
||||
return
|
||||
}
|
||||
|
||||
let delivered = false
|
||||
const subscription = this.eventLoader({ id: eventId }).subscribe({
|
||||
next: (event: NostrEvent) => {
|
||||
delivered = true
|
||||
clearTimeout(timeoutId)
|
||||
this.resolvePending(eventId, event)
|
||||
subscription.unsubscribe()
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
clearTimeout(timeoutId)
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
// 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()
|
||||
},
|
||||
complete: () => {
|
||||
// Completed without next - consider not found, but retry a few times
|
||||
if (!delivered) {
|
||||
clearTimeout(timeoutId)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// Safety timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!delivered) {
|
||||
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||
subscription.unsubscribe()
|
||||
this.fetchFromRelayWithRetry(eventId, attempt + 1)
|
||||
} else {
|
||||
subscription.unsubscribe()
|
||||
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||
}
|
||||
}
|
||||
}, this.fetchTimeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all pending requests after relay pool becomes available
|
||||
*/
|
||||
private retryAllPending(): void {
|
||||
const pendingIds = Array.from(this.pendingRequests.keys())
|
||||
pendingIds.forEach(eventId => {
|
||||
this.fetchFromRelayWithRetry(eventId, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const eventManager = new EventManager()
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface BlogPostPreview {
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @param onPost - Optional callback for streaming posts
|
||||
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||
* @param eventStore - Optional event store to persist fetched events
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
@@ -29,7 +30,8 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
pubkeys: string[],
|
||||
relayUrls: string[],
|
||||
onPost?: (post: BlogPostPreview) => void,
|
||||
limit: number | null = 100
|
||||
limit: number | null = 100,
|
||||
eventStore?: IEventStore
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
@@ -45,12 +47,17 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||
|
||||
await queryEvents(
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
filter,
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
// Store in event store immediately if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
@@ -73,6 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
)
|
||||
|
||||
// Store all events in event store if provided (safety net for any missed during streaming)
|
||||
if (eventStore) {
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
|
||||
@@ -8,8 +8,6 @@ import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
||||
|
||||
/**
|
||||
* Shared highlights controller
|
||||
* Manages the user's highlights centrally, similar to bookmarkController
|
||||
@@ -68,37 +66,10 @@ class HighlightsController {
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load highlights for a user
|
||||
* Streams results and stores in event store
|
||||
* Always fetches ALL highlights to ensure completeness
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
@@ -124,15 +95,12 @@ class HighlightsController {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
// Fetch ALL highlights without limits (no since filter)
|
||||
// This ensures we get complete results for profile/my pages
|
||||
const filter = {
|
||||
kinds: [KINDS.Highlights],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -179,12 +147,6 @@ class HighlightsController {
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||
this.currentHighlights = []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
@@ -30,8 +29,8 @@ export async function fetchReadArticles(
|
||||
try {
|
||||
// Fetch kind:7 and kind:17 reactions in parallel
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
|
||||
])
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
@@ -115,8 +114,7 @@ export async function fetchReadArticlesWithData(
|
||||
|
||||
const articleEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||
{ relayUrls: RELAYS }
|
||||
{ kinds: [KINDS.BlogPost], ids: eventIds }
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
|
||||
@@ -2,8 +2,8 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { getActiveRelayUrls } from './relayManager'
|
||||
|
||||
const ARCHIVE_EMOJI = '📚'
|
||||
|
||||
@@ -35,7 +35,6 @@ export async function createEventReaction(
|
||||
]
|
||||
if (options?.aCoord) {
|
||||
tags.push(['a', options.aCoord])
|
||||
console.log('[archive] createEventReaction add a-tag:', options.aCoord)
|
||||
}
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
@@ -49,7 +48,7 @@ export async function createEventReaction(
|
||||
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||
|
||||
|
||||
return signed
|
||||
@@ -99,7 +98,7 @@ export async function createWebsiteReaction(
|
||||
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||
|
||||
|
||||
return signed
|
||||
@@ -122,7 +121,7 @@ export async function deleteReaction(
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
const signed = await factory.sign(draft)
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||
return signed
|
||||
}
|
||||
|
||||
@@ -146,7 +145,7 @@ export async function hasMarkedEventAsRead(
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.req(getActiveRelayUrls(relayPool), filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
@@ -199,7 +198,7 @@ export async function hasMarkedWebsiteAsRead(
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.req(getActiveRelayUrls(relayPool), filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
|
||||
@@ -75,10 +75,17 @@ export function processReadingProgress(
|
||||
continue
|
||||
}
|
||||
} else if (dTag.startsWith('url:')) {
|
||||
// It's a URL with base64url encoding
|
||||
const encoded = dTag.replace('url:', '')
|
||||
// It's a URL. We support both raw URLs and base64url-encoded URLs.
|
||||
const value = dTag.slice(4)
|
||||
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
|
||||
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
|
||||
itemType = 'external'
|
||||
} catch (e) {
|
||||
|
||||
@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
if (naddrOrUrl.startsWith('nostr:')) {
|
||||
return naddrOrUrl.replace('nostr:', '')
|
||||
}
|
||||
// For URLs, use base64url encoding (URL-safe)
|
||||
return btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
|
||||
return naddrOrUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,8 +135,136 @@ export async function saveReadingPosition(
|
||||
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) {
|
||||
stableCallback(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
stableCallback(candidates[0])
|
||||
}
|
||||
|
||||
// Start streaming and collecting
|
||||
streamStop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
articleIdentifier,
|
||||
(pos) => {
|
||||
if (hasEmitted) return
|
||||
if (!pos) {
|
||||
return
|
||||
}
|
||||
if (pos.position <= 0.05 || pos.position >= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
candidates.push(pos)
|
||||
|
||||
// Schedule one-shot emission if not already scheduled
|
||||
if (!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)
|
||||
* @deprecated Use startReadingPositionStream for non-blocking behavior
|
||||
* Returns current local position immediately (or null) and starts background sync
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
@@ -149,101 +274,29 @@ export async function loadReadingPosition(
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
// Check local event store first
|
||||
let initial: ReadingPosition | null = null
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingProgressContent(localEvent)
|
||||
if (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
|
||||
}
|
||||
if (content) initial = content
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors and fetch from relays
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const result = await fetchFromRelays(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
READING_PROGRESS_KIND,
|
||||
dTag,
|
||||
getReadingProgressContent
|
||||
)
|
||||
|
||||
return result || null
|
||||
}
|
||||
|
||||
// Helper function to fetch from relays with timeout
|
||||
async function fetchFromRelays(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
dTag: string,
|
||||
parser: (event: NostrEvent) => ReadingPosition | undefined
|
||||
): Promise<ReadingPosition | null> {
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
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)
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return initial
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { processReadingProgress } from './readingDataProcessor'
|
||||
import { ReadItem } from './readsService'
|
||||
import { ARCHIVE_EMOJI } from './reactionService'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
console.log('[readingProgress] Module loaded')
|
||||
|
||||
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
@@ -176,17 +174,14 @@ class ReadingProgressController {
|
||||
const { relayPool, eventStore, pubkey, force = false } = params
|
||||
const startGeneration = this.generation
|
||||
|
||||
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
|
||||
|
||||
// Skip if already loaded for this pubkey and not forcing
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[readingProgress] Already loaded for pubkey, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent starts
|
||||
if (this.isLoading) {
|
||||
console.log('[readingProgress] Already loading, skipping concurrent start')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -212,7 +207,6 @@ class ReadingProgressController {
|
||||
this.timelineSubscription = null
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Setting up eventStore subscription...')
|
||||
const timeline$ = eventStore.timeline({
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
@@ -223,20 +217,17 @@ class ReadingProgressController {
|
||||
if (!Array.isArray(localEvents) || localEvents.length === 0) return
|
||||
this.processEvents(localEvents)
|
||||
})
|
||||
console.log('[readingProgress] EventStore subscription ready - updates streaming')
|
||||
|
||||
// Mark as loaded immediately - queries run in background non-blocking
|
||||
this.lastLoadedPubkey = pubkey
|
||||
|
||||
// Query reading progress from relays in background (non-blocking, fire-and-forget)
|
||||
console.log('[readingProgress] Starting background relay query for reading progress...')
|
||||
queryEvents(relayPool, {
|
||||
kinds: [KINDS.ReadingProgress],
|
||||
authors: [pubkey]
|
||||
}, { relayUrls: RELAYS })
|
||||
})
|
||||
.then((relayEvents) => {
|
||||
if (startGeneration !== this.generation) return
|
||||
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
|
||||
if (relayEvents.length > 0) {
|
||||
relayEvents.forEach(e => eventStore.add(e))
|
||||
this.processEvents(relayEvents)
|
||||
@@ -249,10 +240,8 @@ class ReadingProgressController {
|
||||
})
|
||||
|
||||
// Load mark-as-read reactions in background (non-blocking, streaming)
|
||||
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
|
||||
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
|
||||
.then(() => {
|
||||
console.log('[readingProgress] Mark-as-read reactions loading complete')
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
|
||||
@@ -265,9 +254,6 @@ class ReadingProgressController {
|
||||
this.setLoading(false)
|
||||
}
|
||||
this.isLoading = false
|
||||
console.log('[readingProgress] === LOADED ===')
|
||||
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
|
||||
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,10 +276,10 @@ class ReadingProgressController {
|
||||
// Process new events
|
||||
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>()
|
||||
for (const [id, item] of readsMap.entries()) {
|
||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
||||
if (item.readingProgress !== undefined) {
|
||||
newProgressMap.set(id, item.readingProgress)
|
||||
}
|
||||
}
|
||||
@@ -318,7 +304,6 @@ class ReadingProgressController {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
|
||||
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
|
||||
const seenReactionIds = new Set<string>()
|
||||
|
||||
const handleUrlReaction = (evt: NostrEvent) => {
|
||||
@@ -343,8 +328,8 @@ class ReadingProgressController {
|
||||
|
||||
// Fire queries with onEvent callbacks for streaming behavior
|
||||
const [kind17Events, kind7Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
|
||||
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
|
||||
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
|
||||
])
|
||||
|
||||
if (generation !== this.generation) return
|
||||
@@ -356,7 +341,7 @@ class ReadingProgressController {
|
||||
if (pendingEventIds.size > 0) {
|
||||
// Fetch referenced 30023 events, streaming not required here
|
||||
const ids = Array.from(pendingEventIds)
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
|
||||
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
|
||||
const eventIdToNaddr = new Map<string, string>()
|
||||
for (const article of articleEvents) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
@@ -379,7 +364,6 @@ class ReadingProgressController {
|
||||
this.emitMarkedAsReadChanged()
|
||||
}
|
||||
|
||||
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
|
||||
} catch (err) {
|
||||
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Helpers } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { fetchReadArticles } from './libraryService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
@@ -44,7 +43,7 @@ export async function fetchAllReads(
|
||||
try {
|
||||
// Fetch all data sources in parallel
|
||||
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
|
||||
fetchReadArticles(relayPool, userPubkey)
|
||||
])
|
||||
|
||||
@@ -77,7 +76,7 @@ export async function fetchAllReads(
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
readingTimestamp: bookmark.created_at ?? undefined
|
||||
}
|
||||
readsMap.set(coordinate, item)
|
||||
if (onItem) emitItem(item)
|
||||
@@ -130,8 +129,7 @@ export async function fetchAllReads(
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
||||
{ relayUrls: RELAYS }
|
||||
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
|
||||
)
|
||||
|
||||
// Merge event data into ReadItems and emit
|
||||
|
||||
180
src/services/relayListService.ts
Normal file
180
src/services/relayListService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
export interface UserRelayInfo {
|
||||
url: string
|
||||
mode?: 'read' | 'write' | 'both'
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user's relay list from kind 10002 (NIP-65)
|
||||
*/
|
||||
export async function loadUserRelayList(
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
options?: {
|
||||
onUpdate?: (relays: UserRelayInfo[]) => void
|
||||
}
|
||||
): Promise<UserRelayInfo[]> {
|
||||
try {
|
||||
|
||||
|
||||
// Try querying with streaming callback for faster results
|
||||
const events: NostrEvent[] = []
|
||||
const eventsMap = new Map<string, NostrEvent>()
|
||||
|
||||
const result = await queryEvents(relayPool, {
|
||||
kinds: [10002],
|
||||
authors: [pubkey],
|
||||
limit: 10
|
||||
}, {
|
||||
onEvent: (evt) => {
|
||||
// Deduplicate by id and keep most recent
|
||||
const existing = eventsMap.get(evt.id)
|
||||
if (!existing || evt.created_at > existing.created_at) {
|
||||
eventsMap.set(evt.id, evt)
|
||||
// Update events array with deduplicated events
|
||||
events.length = 0
|
||||
events.push(...Array.from(eventsMap.values()))
|
||||
|
||||
// Stream immediate updates to caller using the newest event
|
||||
if (options?.onUpdate) {
|
||||
const tags = evt.tags || []
|
||||
const relays: UserRelayInfo[] = []
|
||||
for (const tag of tags) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
const url = tag[1]
|
||||
const mode = (tag[2] as 'read' | 'write' | undefined) || 'both'
|
||||
relays.push({ url, mode })
|
||||
}
|
||||
}
|
||||
if (relays.length > 0) {
|
||||
options.onUpdate(relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Use the streaming results if we got any, otherwise fall back to the full result
|
||||
const finalEvents = events.length > 0 ? events : result
|
||||
|
||||
// Also try a broader query to see if we get any events at all
|
||||
await queryEvents(relayPool, {
|
||||
kinds: [10002],
|
||||
limit: 5
|
||||
})
|
||||
|
||||
|
||||
|
||||
if (finalEvents.length === 0) return []
|
||||
|
||||
// Get most recent event
|
||||
const sortedEvents = finalEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
const relayListEvent = sortedEvents[0]
|
||||
|
||||
const relays: UserRelayInfo[] = []
|
||||
for (const tag of relayListEvent.tags) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
const url = tag[1]
|
||||
const mode = tag[2] as 'read' | 'write' | undefined
|
||||
relays.push({
|
||||
url,
|
||||
mode: mode || 'both'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return relays
|
||||
} catch (error) {
|
||||
console.error('Failed to load user relay list:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads blocked relays from kind 10006 (NIP-51 mute list)
|
||||
*/
|
||||
export async function loadBlockedRelays(
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const events = await queryEvents(relayPool, {
|
||||
kinds: [10006],
|
||||
authors: [pubkey]
|
||||
})
|
||||
|
||||
if (events.length === 0) return []
|
||||
|
||||
// Get most recent event
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const muteListEvent = sortedEvents[0]
|
||||
|
||||
const blocked: string[] = []
|
||||
for (const tag of muteListEvent.tags) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
blocked.push(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
return blocked
|
||||
} catch (error) {
|
||||
console.error('Failed to load blocked relays:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes final relay set by merging inputs and removing blocked relays
|
||||
*/
|
||||
export function computeRelaySet(params: {
|
||||
hardcoded: string[]
|
||||
bunker?: string[]
|
||||
userList?: UserRelayInfo[]
|
||||
blocked?: string[]
|
||||
alwaysIncludeLocal: string[]
|
||||
}): string[] {
|
||||
const {
|
||||
hardcoded,
|
||||
bunker = [],
|
||||
userList = [],
|
||||
blocked = [],
|
||||
alwaysIncludeLocal
|
||||
} = params
|
||||
|
||||
const relaySet = new Set<string>()
|
||||
const blockedSet = new Set(blocked)
|
||||
|
||||
// Helper to check if relay should be included
|
||||
const shouldInclude = (url: string): boolean => {
|
||||
// Always include local relays
|
||||
if (alwaysIncludeLocal.includes(url)) return true
|
||||
// Otherwise check if blocked
|
||||
return !blockedSet.has(url)
|
||||
}
|
||||
|
||||
// Add hardcoded relays
|
||||
for (const url of hardcoded) {
|
||||
if (shouldInclude(url)) relaySet.add(url)
|
||||
}
|
||||
|
||||
// Add bunker relays
|
||||
for (const url of bunker) {
|
||||
if (shouldInclude(url)) relaySet.add(url)
|
||||
}
|
||||
|
||||
// Add user relays (treating 'both' and 'read' as applicable for queries)
|
||||
for (const relay of userList) {
|
||||
if (shouldInclude(relay.url)) relaySet.add(relay.url)
|
||||
}
|
||||
|
||||
// Always ensure local relays are present
|
||||
for (const url of alwaysIncludeLocal) {
|
||||
relaySet.add(url)
|
||||
}
|
||||
|
||||
return Array.from(relaySet)
|
||||
}
|
||||
|
||||
91
src/services/relayManager.ts
Normal file
91
src/services/relayManager.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||
|
||||
/**
|
||||
* Local relays that are always included
|
||||
*/
|
||||
export const ALWAYS_LOCAL_RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869'
|
||||
]
|
||||
|
||||
/**
|
||||
* Hardcoded relays that are always included
|
||||
*/
|
||||
export const HARDCODED_RELAYS = [
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
|
||||
/**
|
||||
* Gets active relay URLs from the relay pool
|
||||
*/
|
||||
export function getActiveRelayUrls(relayPool: RelayPool): string[] {
|
||||
const urls = Array.from(relayPool.relays.keys())
|
||||
return prioritizeLocalRelays(urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a relay URL to match what applesauce-relay stores internally
|
||||
* Adds trailing slash for URLs without a path
|
||||
*/
|
||||
function normalizeRelayUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// If the pathname is empty or just "/", ensure it ends with "/"
|
||||
if (parsed.pathname === '' || parsed.pathname === '/') {
|
||||
return url.endsWith('/') ? url : url + '/'
|
||||
}
|
||||
return url
|
||||
} catch {
|
||||
// If URL parsing fails, return as-is
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a new relay set to the pool: adds missing relays, removes extras
|
||||
*/
|
||||
export function applyRelaySetToPool(
|
||||
relayPool: RelayPool,
|
||||
finalUrls: string[]
|
||||
): void {
|
||||
// Normalize all URLs to match pool's internal format
|
||||
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
||||
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
||||
|
||||
|
||||
|
||||
// Add new relays (use original URLs for adding, not normalized)
|
||||
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
relayPool.group(toAdd)
|
||||
}
|
||||
|
||||
// Remove relays not in target (but always keep local relays)
|
||||
const toRemove: string[] = []
|
||||
for (const url of currentUrls) {
|
||||
// Check if this normalized URL is in the target set
|
||||
if (!normalizedTargetUrls.has(url)) {
|
||||
// Also check if it's a local relay (check both normalized and original forms)
|
||||
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
|
||||
normalizeRelayUrl(localUrl) === url || localUrl === url
|
||||
)
|
||||
if (!isLocal) {
|
||||
toRemove.push(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
if (relay) {
|
||||
relay.close()
|
||||
relayPool.relays.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -58,97 +58,100 @@ export interface UserSettings {
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
// Reading settings
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
fullWidthImages?: boolean // default: false
|
||||
renderVideoLinksAsEmbeds?: boolean // default: false
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
autoScrollToReadingPosition?: boolean // default: true - automatically scroll to saved position when opening article
|
||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||
// Bookmark filtering
|
||||
hideBookmarksWithoutCreationDate?: boolean // default: false
|
||||
// Content filtering
|
||||
hideBotArticlesByName?: boolean // default: true - hide authors whose profile name includes "bot"
|
||||
// TTS language selection
|
||||
ttsUseSystemLanguage?: boolean // default: false
|
||||
ttsDetectContentLanguage?: boolean // default: true
|
||||
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
||||
// Text-to-Speech settings
|
||||
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(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): Promise<UserSettings | null> {
|
||||
|
||||
// First, check if we already have settings in the local event store
|
||||
let initial: UserSettings | null = null
|
||||
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getAppDataContent<UserSettings>(localEvent)
|
||||
|
||||
// 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
|
||||
initial = content || null
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore local store errors
|
||||
} catch {
|
||||
// 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
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 5000)
|
||||
})
|
||||
return initial
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
import { getActiveRelayUrls } from './relayManager'
|
||||
|
||||
/**
|
||||
* Unified write helper: add event to EventStore, detect connectivity,
|
||||
@@ -27,10 +27,13 @@ export async function publishEvent(
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
|
||||
// Get active relay URLs from the pool
|
||||
const activeRelays = getActiveRelayUrls(relayPool)
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
? activeRelays
|
||||
: activeRelays.filter(isLocalRelay)
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
@@ -42,7 +45,7 @@ export async function publishEvent(
|
||||
}
|
||||
|
||||
// Publish to all configured relays in the background (non-blocking)
|
||||
relayPool.publish(RELAYS, event)
|
||||
relayPool.publish(activeRelays, event)
|
||||
.then(() => {
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -10,8 +10,6 @@ const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished
|
||||
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'writings_last_synced'
|
||||
|
||||
/**
|
||||
* Shared writings controller
|
||||
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
|
||||
@@ -71,34 +69,6 @@ class WritingsController {
|
||||
this.emitWritings(this.currentPosts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[writings] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
|
||||
*/
|
||||
@@ -127,6 +97,7 @@ class WritingsController {
|
||||
/**
|
||||
* Load writings for a user (kind:30023)
|
||||
* Streams results and stores in event store
|
||||
* Always fetches ALL writings to ensure completeness
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
@@ -152,15 +123,12 @@ class WritingsController {
|
||||
const seenIds = new Set<string>()
|
||||
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
// Fetch ALL writings without limits (no since filter)
|
||||
// This ensures we get complete results for profile/my pages
|
||||
const filter = {
|
||||
kinds: [KINDS.BlogPost],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -221,12 +189,6 @@ class WritingsController {
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitWritings(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[writings] ❌ Failed to load writings:', error)
|
||||
this.currentPosts = []
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
word-wrap: break-word;
|
||||
text-align: var(--paragraph-alignment, justify);
|
||||
}
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
.login-highlight {
|
||||
background-color: var(--highlight-color-mine, #fde047);
|
||||
color: var(--color-text);
|
||||
color: #000000;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Me page tabs */
|
||||
/* My page tabs */
|
||||
.me-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -71,7 +71,7 @@
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
/* Align highlight list width with profile card width on /my */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Bookmark filters in Me page */
|
||||
/* Bookmark filters in My page */
|
||||
.me-tab-content .bookmark-filters {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
/* Video container - responsive wrapper following react-player docs */
|
||||
.reader-video {
|
||||
position: relative;
|
||||
width: 80vw; /* 80% of viewport width */
|
||||
min-width: 400px; /* Minimum width */
|
||||
max-width: 1000px; /* Maximum width */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
||||
margin: 0 0 1rem 0; /* align with reader padding, no bleed */
|
||||
background: rgb(0 0 0); /* black */
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader.empty { color: var(--color-text-secondary); }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
|
||||
@@ -35,6 +35,8 @@
|
||||
.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 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-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
/* Ensure font inheritance */
|
||||
@@ -54,7 +56,15 @@
|
||||
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
|
||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
||||
.reader .reader-html img, .reader .reader-markdown img {
|
||||
max-width: var(--image-max-width, 100%);
|
||||
max-height: 70vh;
|
||||
height: auto;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Headlines with Tailwind typography */
|
||||
.reader-markdown h1, .reader-html h1 {
|
||||
font-size: 2.25rem; /* text-4xl */
|
||||
@@ -135,7 +145,7 @@
|
||||
}
|
||||
.reader-markdown blockquote, .reader-html blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 0 1rem 2rem;
|
||||
padding: 1rem 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
@@ -222,7 +232,7 @@
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -251,7 +261,7 @@
|
||||
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
|
||||
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
|
||||
.reader-summary-below-image .reader-summary { color: var(--color-text-secondary); font-size: 1rem; line-height: 1.6; margin: 0; }
|
||||
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image { width: calc(100% + 2rem); margin: -0.5rem -1rem 2rem -1rem; min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
|
||||
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
|
||||
.reader-header-overlay .reader-title { font-size: 2rem; line-height: 1.3; }
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
|
||||
@@ -92,7 +94,7 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
.profile-avatar-button {
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
width: 33px;
|
||||
@@ -108,10 +110,27 @@
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar svg { font-size: 1rem; }
|
||||
/* Mobile touch target improvements */
|
||||
@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 {
|
||||
background: transparent;
|
||||
|
||||
34
src/sw.ts
34
src/sw.ts
@@ -98,10 +98,42 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Log fetch errors for debugging (doesn't affect functionality)
|
||||
// Handle Web Share Target POST requests
|
||||
sw.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Handle POST to /share-target (Web Share Target API)
|
||||
if (event.request.method === 'POST' && url.pathname === '/share-target') {
|
||||
event.respondWith((async () => {
|
||||
const formData = await event.request.formData()
|
||||
const title = (formData.get('title') || '').toString()
|
||||
const text = (formData.get('text') || '').toString()
|
||||
// Accept multiple possible field names just in case different casings are used
|
||||
let link = (
|
||||
formData.get('link') ||
|
||||
formData.get('Link') ||
|
||||
formData.get('url') ||
|
||||
''
|
||||
).toString()
|
||||
|
||||
// Android often omits url param, extract from text
|
||||
if (!link && text) {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s]+/)
|
||||
if (urlMatch) {
|
||||
link = urlMatch[0]
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
if (link) queryParams.set('link', link)
|
||||
if (title) queryParams.set('title', title)
|
||||
if (text) queryParams.set('text', text)
|
||||
|
||||
return Response.redirect(`/share-target?${queryParams.toString()}`, 303)
|
||||
})())
|
||||
return
|
||||
}
|
||||
|
||||
// Don't interfere with WebSocket connections (relay traffic)
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface Bookmark {
|
||||
export interface IndividualBookmark {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
// Timestamp when the content was created (from the content event itself)
|
||||
created_at: number | null
|
||||
pubkey: string
|
||||
kind: number
|
||||
tags: string[][]
|
||||
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
|
||||
type: 'event' | 'article' | 'web'
|
||||
isPrivate?: boolean
|
||||
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)
|
||||
sourceKind?: number
|
||||
// The 'd' tag value from kind 30003 bookmark sets
|
||||
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
|
||||
setTitle?: string
|
||||
setDescription?: 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 {
|
||||
|
||||
@@ -4,13 +4,15 @@ import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmark
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// 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)
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
// 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 now = new Date()
|
||||
|
||||
@@ -85,9 +87,8 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
|
||||
// Sorting and grouping for bookmarks
|
||||
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
const getSortTime = (b: IndividualBookmark) => b.created_at ?? b.listUpdatedAt ?? -Infinity
|
||||
return items.slice().sort((a, b) => getSortTime(b) - getSortTime(a))
|
||||
}
|
||||
|
||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||
@@ -122,10 +123,7 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
|
||||
if (!bookmark.created_at) return false
|
||||
// If timestamp is missing or equals current time (within 1 second), consider it invalid
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
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
|
||||
return true
|
||||
}
|
||||
|
||||
// Bookmark sets helpers (kind 30003)
|
||||
|
||||
@@ -51,7 +51,7 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
summary,
|
||||
image,
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||
readingTimestamp: bookmark.created_at ?? undefined
|
||||
}
|
||||
|
||||
linksMap.set(url, item)
|
||||
|
||||
@@ -49,7 +49,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
source: 'bookmark',
|
||||
type: 'article',
|
||||
readingProgress: 0,
|
||||
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||
readingTimestamp: bookmark.created_at ?? undefined,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
|
||||
@@ -114,6 +114,17 @@ export default defineConfig({
|
||||
background_color: '#0b1220',
|
||||
orientation: 'any',
|
||||
categories: ['productivity', 'social', 'utilities'],
|
||||
// Web Share Target configuration so the installed PWA shows up in the system share sheet
|
||||
share_target: {
|
||||
action: '/share-target',
|
||||
method: 'POST',
|
||||
enctype: 'multipart/form-data',
|
||||
params: {
|
||||
title: 'title',
|
||||
text: 'text',
|
||||
url: 'link'
|
||||
}
|
||||
},
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
|
||||
Reference in New Issue
Block a user