mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
414 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e1bc3419 | ||
|
|
4ec34a0379 | ||
|
|
aec2dcb75c | ||
|
|
5bdc435f5d | ||
|
|
db46edd39e | ||
|
|
c9739f804d | ||
|
|
eeb44e344f | ||
|
|
a6674610b8 | ||
|
|
6ae3decafb | ||
|
|
00da638e81 | ||
|
|
f04c0a401e | ||
|
|
f5e9f164f5 | ||
|
|
589ac17114 | ||
|
|
8d3510947c | ||
|
|
08a8f5623a | ||
|
|
e85ccdc7da | ||
|
|
d0e7f146fb | ||
|
|
efdb33eb31 | ||
|
|
0abbe62515 | ||
|
|
ab0972dd29 | ||
|
|
83fbb26e03 | ||
|
|
e8ce928ec6 | ||
|
|
1a01e14702 | ||
|
|
aab8176987 | ||
|
|
5a8b885d25 | ||
|
|
c129b24352 | ||
|
|
d98d750268 | ||
|
|
8262b2bf24 | ||
|
|
b99f36c0c5 | ||
|
|
dfe37a260e | ||
|
|
2a42f1de53 | ||
|
|
cea2d0eda2 | ||
|
|
ef05974a72 | ||
|
|
5a6ac628d2 | ||
|
|
826f07544e | ||
|
|
911215c0fb | ||
|
|
43ed41bfae | ||
|
|
81597fbb6d | ||
|
|
cc722c2599 | ||
|
|
c20682fbe8 | ||
|
|
cfa6dc4400 | ||
|
|
851cecf18c | ||
|
|
d4c67485a2 | ||
|
|
381fd05c90 | ||
|
|
60c4ef55c0 | ||
|
|
0b7891419b | ||
|
|
aeedc622b1 | ||
|
|
6f5b87136b | ||
|
|
1ac0c719a2 | ||
|
|
c9ce5442e0 | ||
|
|
c28052720e | ||
|
|
d0f942c495 | ||
|
|
907ef82efb | ||
|
|
415ff04345 | ||
|
|
683ea27526 | ||
|
|
fa052483b2 | ||
|
|
0ae9e6321e | ||
|
|
5623f2e595 | ||
|
|
2c94c1e3f0 | ||
|
|
19dc2f70f2 | ||
|
|
5013ccc552 | ||
|
|
29eed3395f | ||
|
|
d6da27c634 | ||
|
|
5551b52bce | ||
|
|
af7eb48893 | ||
|
|
51ce79f13a | ||
|
|
bcfc04c35c | ||
|
|
d6911b2acb | ||
|
|
2a869f11e0 | ||
|
|
deab9974fa | ||
|
|
49872337f3 | ||
|
|
389b4de0eb | ||
|
|
959ccac857 | ||
|
|
78c58693a5 | ||
|
|
ab81fe5030 | ||
|
|
6bae30070e | ||
|
|
1f6a904717 | ||
|
|
9379475d1c | ||
|
|
77a5f4bd2a | ||
|
|
4fa01231cd | ||
|
|
1cd85507a7 | ||
|
|
b6f151c711 | ||
|
|
e3d924f3fc | ||
|
|
5914df23d3 | ||
|
|
2083c2b8c6 | ||
|
|
35a8411d9b | ||
|
|
15b3b5b990 | ||
|
|
ad56acb712 | ||
|
|
2f5fe87fc8 | ||
|
|
d313c71e24 | ||
|
|
903b4a4ec1 | ||
|
|
a511b25b87 | ||
|
|
e920cf9477 | ||
|
|
708a1bfd54 | ||
|
|
51842f55bf | ||
|
|
52991f8e20 | ||
|
|
e3cd4454b4 | ||
|
|
78bc1f46dd | ||
|
|
c8cd1e6e66 | ||
|
|
5254697fe2 | ||
|
|
13462efaed | ||
|
|
1df00fbfda | ||
|
|
c2e220a1f2 | ||
|
|
00740aab6d | ||
|
|
e12d67cc5f | ||
|
|
e12aaa2b6c | ||
|
|
9880a9ae34 | ||
|
|
603db680f2 | ||
|
|
ae0471946e | ||
|
|
a48308d57d | ||
|
|
f67b358148 | ||
|
|
46a0a3da1f | ||
|
|
c92a620ea8 | ||
|
|
34de372509 | ||
|
|
a422084949 | ||
|
|
bd0e075984 | ||
|
|
38f4b69d48 | ||
|
|
9d1d944daf | ||
|
|
e56461cb12 | ||
|
|
f6b6747f09 | ||
|
|
180c26c47a | ||
|
|
78da0cb3e4 | ||
|
|
3d74c25c7d | ||
|
|
f46f55705b | ||
|
|
205591602d | ||
|
|
c6a42e0304 | ||
|
|
688d4285e3 | ||
|
|
9f806afc45 | ||
|
|
1282e778c6 | ||
|
|
6fd40f2ff6 | ||
|
|
6ac40c8a17 | ||
|
|
92145af2bb | ||
|
|
1ebaf7ccd2 | ||
|
|
5d22819ae3 | ||
|
|
6761b1861e | ||
|
|
1d989eae76 | ||
|
|
33d6e5882d | ||
|
|
0a62924b78 | ||
|
|
e2472606dd | ||
|
|
6f04b8f513 | ||
|
|
a8ad346c5d | ||
|
|
465c24ed3a | ||
|
|
04dea350a4 | ||
|
|
29c4bcb69b | ||
|
|
23ea7f352b | ||
|
|
36b35367f1 | ||
|
|
183463c817 | ||
|
|
7fb91e71f1 | ||
|
|
717f094984 | ||
|
|
c69e50d3bb | ||
|
|
4e4d719d94 | ||
|
|
d453a6439c | ||
|
|
5dfa6ba3ae | ||
|
|
f2d2883eee | ||
|
|
84001d1b83 | ||
|
|
b7a390cf89 | ||
|
|
59d9179642 | ||
|
|
68301cd20f | ||
|
|
4d6b7e1a46 | ||
|
|
95fe9b548f | ||
|
|
e86ae9f05e | ||
|
|
2124be83c3 | ||
|
|
a8bb17d4cd | ||
|
|
a886a68822 | ||
|
|
76bdbc670d | ||
|
|
c16ce1fc7e | ||
|
|
a578d67b1e | ||
|
|
25d1ead9f5 | ||
|
|
ae5ea66dd2 | ||
|
|
cf5f8fae16 | ||
|
|
d9c46e602a | ||
|
|
4d980bf91c | ||
|
|
cb3b0e38e9 | ||
|
|
fbf5c455ca | ||
|
|
ed5decf3e9 | ||
|
|
44a7e6ae2c | ||
|
|
f52b94d72a | ||
|
|
d0833b5ed4 | ||
|
|
2f20b393bc | ||
|
|
13fa6cd485 | ||
|
|
e6e7240cd5 | ||
|
|
c1ff3b44d1 | ||
|
|
0577f862fd | ||
|
|
883cb352ff | ||
|
|
238cc9bc00 | ||
|
|
1800ee324e | ||
|
|
7d2dac2f1a | ||
|
|
7875f1d0bd | ||
|
|
d9263e07d1 | ||
|
|
9a345a7347 | ||
|
|
55d1af3bf9 | ||
|
|
feb3134b65 | ||
|
|
7d222e099f | ||
|
|
59436b5b9e | ||
|
|
2e08954e83 | ||
|
|
9cb1791a3a | ||
|
|
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 |
@@ -2,4 +2,4 @@
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Keep files below 210 lines.
|
||||
Keep files below 420 lines.
|
||||
21
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
21
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: fetching data from relays
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Fetching Data with Controllers
|
||||
|
||||
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.
|
||||
@@ -3,6 +3,8 @@ description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Mobile-First UI/UX
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
|
||||
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||
|
||||
844
CHANGELOG.md
844
CHANGELOG.md
@@ -7,7 +7,792 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.1] - 2025-10-20
|
||||
## [0.10.29] - 2025-11-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Full-width images setting now uses width instead of max-width
|
||||
- Change from --image-max-width CSS variable to --image-width
|
||||
- When enabled, sets images to width: 100% (enlarging small images)
|
||||
- Always constrains with max-width: 100% to prevent overflow
|
||||
- Update mobile responsive styles to respect the setting
|
||||
|
||||
## [0.10.28] - 2025-11-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nostr URI processing in markdown links
|
||||
- Prevent double-processing of markdown to avoid nested links
|
||||
- Add HTTP URL detection to prevent processing nostr URIs in URLs
|
||||
- Use parser-based approach to detect markdown link URLs
|
||||
- Prevent nostr URI replacement inside markdown link URLs
|
||||
- Remove unused variables in nostrUriResolver
|
||||
- Improved blog post rendering with proper link handling
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements from nostrUriResolver
|
||||
- Cleaned up debug logging used during troubleshooting
|
||||
- Cleaner console output in production
|
||||
|
||||
## [0.10.27] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh button to highlights sidebar header
|
||||
- Users can manually refresh highlights panel
|
||||
- Better control over highlights data updates
|
||||
- Image preloading in BlogPostCard for better caching
|
||||
- Images are preloaded when blog posts are displayed
|
||||
- Improved offline access to article images
|
||||
- Preload logged-in user profile image for offline access
|
||||
- User profile picture is cached for offline viewing
|
||||
- Better user experience when network is unavailable
|
||||
- Development Service Worker for testing image caching
|
||||
- Service Worker enabled in development mode
|
||||
- Improved testing capabilities for offline functionality
|
||||
|
||||
### Fixed
|
||||
|
||||
- Service Worker registration error handling
|
||||
- Better error handling for Service Worker registration failures
|
||||
- More robust development mode Service Worker support
|
||||
- Proper error handling for fetch requests in Service Worker
|
||||
- Article loading race conditions
|
||||
- Resolved race condition when loading articles from cache
|
||||
- Cache is checked synchronously before setting loading state
|
||||
- Articles are populated in cache from explore view
|
||||
- Image caching issues
|
||||
- Images are properly preloaded when loading articles from cache
|
||||
- Removed bulk image preloading to prevent resource exhaustion errors
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Scroll position management
|
||||
- Scroll position is reset when switching articles
|
||||
- Save suppression added when resetting scroll position
|
||||
- Reader content is cleared immediately when article changes
|
||||
- React hook ordering issues
|
||||
- useEffect moved before early return in BlogPostCard
|
||||
- Prevents React hooks dependency violations
|
||||
- TypeScript and linting issues
|
||||
- Cache save logic simplified to avoid TypeScript errors
|
||||
- Unused settings parameter marked as intentionally unused
|
||||
- Articles are saved to localStorage cache after loading from relays
|
||||
- Cache is saved immediately when first event is received
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Prevents unnecessary image loading operations
|
||||
- Improved resource utilization
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Removed all debug console output from article cache and service worker
|
||||
- Removed debug logging from useImageCache hook
|
||||
- Cleaner console output in production
|
||||
- Unused refresh button from highlights panel header
|
||||
- Cleaned up unused UI component
|
||||
|
||||
## [0.10.26] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Persist highlight metadata and offline events to localStorage
|
||||
- Highlights created offline are now preserved across sessions
|
||||
- Better data persistence for flight mode highlights
|
||||
- Proper relay response tracking for flight mode
|
||||
- Accurate detection of which relays have received highlight events
|
||||
- Improved flight mode indicator accuracy
|
||||
|
||||
### Changed
|
||||
|
||||
- Implemented proper flight mode detection for highlights
|
||||
- More reliable identification of highlights created offline
|
||||
- Better tracking of highlight publication status
|
||||
- Refactored to use isLocalOnly flag instead of isOfflineCreated
|
||||
- More consistent naming and clearer intent
|
||||
- Improved code maintainability
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show airplane icon for flight mode highlights
|
||||
- Visual indicator now correctly displays for offline-created highlights
|
||||
- Better user feedback for highlights pending publication
|
||||
- Prioritize isLocalOnly check to show airplane icon
|
||||
- Icon display logic now correctly identifies flight mode highlights
|
||||
- Preserve isLocalOnly and publishedRelays in eventToHighlight conversion
|
||||
- Highlight metadata is maintained during event transformations
|
||||
- Flight mode status preserved across data conversions
|
||||
- Use metadata cache to preserve highlight properties across EventStore
|
||||
- Highlights maintain their properties when stored in EventStore
|
||||
- Prevents loss of flight mode metadata
|
||||
- Add fallback logic for detecting flight mode highlights
|
||||
- More robust detection when primary methods fail
|
||||
- Better handling of edge cases
|
||||
- Determine isLocalOnly before publishing, not after
|
||||
- Correct timing for flight mode detection
|
||||
- Prevents incorrect status during highlight creation
|
||||
- Store event in EventStore after updating properties
|
||||
- Ensures all highlight properties are saved correctly
|
||||
- Prevents data loss during highlight creation
|
||||
- Manually set highlight properties after eventToHighlight conversion
|
||||
- Ensures all metadata is properly assigned
|
||||
- Correct property mapping during conversions
|
||||
- Prevent duplicate highlights
|
||||
- Eliminates duplicate highlight entries
|
||||
- Cleaner highlight list display
|
||||
- Publish only to connected relays to avoid long timeouts
|
||||
- Faster highlight publishing
|
||||
- Better user experience when some relays are unavailable
|
||||
- Prevent unnecessary relay queries when article content is cached
|
||||
- Improved performance by avoiding redundant network requests
|
||||
- Better resource utilization
|
||||
- Remove relayPool dependency from content loaders
|
||||
- Cleaner architecture and reduced coupling
|
||||
- Better separation of concerns
|
||||
- Check EventStore before setting loading state
|
||||
- Prevents unnecessary loading states
|
||||
- Better state management
|
||||
- Remove eventStore and setter functions from useEffect dependencies
|
||||
- Fixes React hook dependency issues
|
||||
- Prevents unnecessary re-renders
|
||||
- Replace require() with ES module imports
|
||||
- Modern JavaScript module system
|
||||
- Better compatibility with build tools
|
||||
- Resolve all linting errors and type issues
|
||||
- Improved code quality
|
||||
- Better type safety
|
||||
- Remove unused variables to resolve lint errors
|
||||
- Cleaner codebase
|
||||
- Eliminates lint warnings
|
||||
|
||||
### Performance
|
||||
|
||||
- Remove excessive debug logging for better performance
|
||||
- Reduced overhead from logging operations
|
||||
- Improved application performance
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Cleaner console output
|
||||
- Production-ready code
|
||||
|
||||
## [0.10.25] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Reading progress bar for all bookmark types across all view modes
|
||||
- Consistent progress tracking for articles, videos, and other content types
|
||||
- Visual progress indicator in compact, medium, and large card views
|
||||
- Title display for regular bookmarks/links
|
||||
- Better content identification for web bookmarks
|
||||
- Improved visual hierarchy in bookmark cards
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned medium-sized bookmark cards with left-side thumbnails
|
||||
- More compact and visually appealing card layout
|
||||
- Better use of space with thumbnail positioning
|
||||
- Made bookmark cards significantly more compact
|
||||
- Reduced vertical spacing and improved density
|
||||
- Better information display in limited space
|
||||
- Moved bookmark type icon to bottom right footer
|
||||
- Cleaner card layout with consistent icon positioning
|
||||
- Better visual organization of card elements
|
||||
- Enhanced card layout with author positioning in bottom-left corner
|
||||
- Improved visual hierarchy and content organization
|
||||
- Better balance between content and metadata
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected TypeScript error in content type icon logic
|
||||
- Resolved linting issues in CardView component
|
||||
- Eliminated 0 artefacts in compact view conditional rendering
|
||||
- Ensured consistent reading progress bar thickness in large cards
|
||||
- Fixed reading progress bar display logic for compact view
|
||||
- Ensured empty reading progress bar is always visible for articles
|
||||
- Resolved timestamp and icon display alignment issues
|
||||
- Removed unused variables to resolve linting errors
|
||||
|
||||
### Removed
|
||||
|
||||
- Type icon from medium-sized bookmark cards
|
||||
- Cleaner visual design with reduced clutter
|
||||
- Focus on content rather than type indicators
|
||||
- Text expansion mechanic from medium-sized cards
|
||||
- Simplified interaction model
|
||||
- More consistent card behavior across view modes
|
||||
- URL display from medium-sized bookmark cards
|
||||
- Cleaner card appearance
|
||||
- Better focus on title and content
|
||||
|
||||
## [0.10.24] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Dynamic browser title based on content
|
||||
- Browser title now updates to reflect the current content being viewed
|
||||
- Improves user experience and bookmark identification
|
||||
- Enhanced Links type bookmarks with OpenGraph data
|
||||
- Web bookmarks now include rich OpenGraph metadata
|
||||
- Better visual representation and content preview for web bookmarks
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced custom OpenGraph extraction with fetch-opengraph library
|
||||
- More reliable and standardized OpenGraph data extraction
|
||||
- Better performance and maintainability for web bookmark processing
|
||||
|
||||
### Fixed
|
||||
|
||||
- Description extraction from web bookmark content field
|
||||
- Improved extraction of descriptions from web bookmark content
|
||||
- Better content identification and display for web bookmarks
|
||||
- Resolved all linting and TypeScript issues
|
||||
- Code quality improvements and type safety enhancements
|
||||
- Cleaner codebase with better maintainability
|
||||
|
||||
## [0.10.23] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Video thumbnail support for cover images
|
||||
- Extracts and displays video thumbnails for YouTube and Vimeo content
|
||||
- Improves visual representation of video content in bookmark views
|
||||
- Note content support for direct video URLs
|
||||
- Uses note content as title for direct video URLs when available
|
||||
- Better content identification for video bookmarks
|
||||
- Smart highlight clearing for articles
|
||||
- Automatically clears highlights when switching between articles
|
||||
- Prevents highlight confusion between different articles
|
||||
- Robust highlight loading with fallback mechanisms
|
||||
- Implements multiple fallback strategies for highlight loading
|
||||
- Ensures highlights are loaded even when primary methods fail
|
||||
|
||||
### Changed
|
||||
|
||||
- Home button alignment moved to left next to profile button
|
||||
- Improved navigation layout consistency
|
||||
- Video functionality extracted into dedicated VideoView component
|
||||
- Better code organization and maintainability
|
||||
- Cleaner separation of concerns for video handling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Article loading performance and error handling improvements
|
||||
- Better error handling for failed article loads
|
||||
- Improved loading performance with optimized data fetching
|
||||
- Highlight loading issues for articles
|
||||
- Resolved race conditions between content loaders
|
||||
- Fixed missing relayPool dependency in useEffect
|
||||
- Proper filtering of Nostr article highlights in sidebar
|
||||
- Skeleton loader improvements
|
||||
- Consolidated multiple skeleton loaders in article view
|
||||
- Replaced markdown loading spinner with skeleton
|
||||
- Replaced 'No readable content found' with skeleton loader
|
||||
- Video metadata extraction improvements
|
||||
- Enhanced YouTube and Vimeo metadata extraction
|
||||
- Better handling of video content identification
|
||||
|
||||
## [0.10.22] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Mobile-optimized tab interface for `/my` and `/p/` pages
|
||||
- Tab text labels hidden on mobile screens (≤768px) to save space
|
||||
- Shows only icons for highlights, bookmarks, reads, links, and writings tabs
|
||||
- Maintains full functionality while improving mobile UX
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated brand tagline from "Nostr Bookmarks" to "Read, Highlight, Explore"
|
||||
- Updated across HTML meta tags, PWA manifest, and Open Graph metadata
|
||||
- Better reflects core functionality: reading, highlighting, and exploring content
|
||||
- Reordered bookmarks bar navigation buttons
|
||||
- New order: Home, Explore, Settings (previously: Home, Settings, Explore)
|
||||
- Moved highlight button higher up on screen
|
||||
- Changed position from `bottom: 32px` to `bottom: 80px` for better accessibility
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile sidebar not closing when navigating to profile sections
|
||||
- Fixed issue where clicking "My Reads", "My Highlights", etc. didn't close mobile sidebar
|
||||
- Added mobile sidebar close logic to all navigation buttons
|
||||
- Improved mobile navigation experience
|
||||
- Removed unnecessary versioning from reading progress implementation
|
||||
- Simplified reading progress events by removing `ver` field
|
||||
- Updated cache key to remove version suffix
|
||||
- Cleaner, more maintainable code
|
||||
|
||||
## [0.10.21] - 2025-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position tracking for internal event URLs
|
||||
- Prevents tracking for `nostr-event:` sentinel URLs (internal convention, not a valid Nostr URI per NIP-21)
|
||||
- Fixes "String must be lowercase or uppercase" error when loading base64-encoded event URLs
|
||||
- These internal sentinels are no longer saved to reading position data
|
||||
- Compact bookmark view display
|
||||
- Articles now show title instead of summary in compact view
|
||||
- Extracts article title from NIP-23 metadata tags
|
||||
- Removed unused `articleSummary` prop to keep code DRY
|
||||
- Bookmark deduplication in profile view
|
||||
- Same article appearing in multiple bookmark lists/sets now displays only once
|
||||
- Uses coordinate-based deduplication (`kind:pubkey:identifier`) for articles
|
||||
- Applies `dedupeBookmarksById` when flattening bookmarks from different sources
|
||||
|
||||
## [0.10.20] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Web Bookmarks section now appears first when bookmarks are grouped by source
|
||||
- Provides quicker access to external URL bookmarks
|
||||
- Better organization for mixed bookmark collections
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile scroll position preservation when toggling highlights panel
|
||||
- Opening/closing the highlights sidebar no longer resets scroll to top
|
||||
- Scroll position is saved before locking and restored after unlocking
|
||||
- Uses `requestAnimationFrame` to ensure DOM updates before restoring
|
||||
- Infinite loop in reading position tracking
|
||||
- Fixed "Maximum update depth exceeded" error in `useReadingPosition` hook
|
||||
- Callbacks now stored in refs to avoid dependency array issues
|
||||
- Prevents unnecessary re-renders during scroll tracking
|
||||
- Skeleton loading state for articles with zero highlights
|
||||
- Articles without highlights no longer show infinite loading skeletons
|
||||
- Properly transitions to empty state message
|
||||
- Navigation to bookmarked articles
|
||||
- Clicking bookmarked kind:30023 articles now navigates to `/a/:naddr` route
|
||||
- Fixes issue where clicking showed "Select a bookmark" message instead
|
||||
- Applies to both compact view and bookmark item interactions
|
||||
|
||||
## [0.10.19] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Profile dropdown menu in sidebar header
|
||||
- Click profile picture to access quick navigation menu
|
||||
- Menu items: My Highlights, My Bookmarks, My Reads, My Links, My Writings
|
||||
- Logout option included with separator
|
||||
- Click outside to close
|
||||
- Smooth slide-in animation
|
||||
|
||||
### Changed
|
||||
|
||||
- Profile picture interaction updated
|
||||
- Now triggers dropdown menu instead of navigating to profile page
|
||||
- More efficient access to all profile sections
|
||||
- Collapse buttons repositioned for symmetry
|
||||
- Highlights collapse button moved to left side of header
|
||||
- Both bookmarks and highlights collapse buttons now left-aligned
|
||||
- Consistent visual hierarchy across panels
|
||||
- Grouping toggle button repositioned
|
||||
- Moved from right/center to left side
|
||||
- Now next to support button (orange heart)
|
||||
- Better logical grouping of controls
|
||||
- Collapse button styling standardized
|
||||
- Highlights collapse button now matches bookmarks style
|
||||
- Consistent 33x33px size, border radius, and hover states
|
||||
- Uses native button element for better consistency
|
||||
|
||||
### Removed
|
||||
|
||||
- Redundant logout button from sidebar header
|
||||
- Previously next to Explore button
|
||||
- Now only accessible via profile dropdown menu
|
||||
- Refresh buttons from sidebars
|
||||
- Removed from bookmarks sidebar
|
||||
- Removed from highlights sidebar
|
||||
- Pull-to-refresh functionality remains available
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cleaned up unused component props and parameters
|
||||
- Removed `lastFetchTime` from BookmarkList
|
||||
- Removed `loading` and `onRefresh` from HighlightsPanelHeader
|
||||
- All linting errors resolved
|
||||
- Type checking passing
|
||||
|
||||
## [0.10.18] - 2025-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- User profile routes renamed from `/me` to `/my`
|
||||
- `/my/highlights` - User's highlights
|
||||
- `/my/bookmarks` - User's bookmarks
|
||||
- `/my/reads` - Nostr-native articles with reading progress
|
||||
- `/my/links` - External URLs with reading progress
|
||||
- `/my/writings` - User's published articles
|
||||
- All navigation, tabs, and internal links updated
|
||||
- Documentation updated to reflect new paths
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/my/writings` now displays all user writings
|
||||
- Removed incremental loading with `since` filters from writingsController
|
||||
- Controller now fetches ALL writings without limits
|
||||
- Ensures complete results on profile and my pages
|
||||
- `/my/highlights` now displays all user highlights
|
||||
- Removed incremental loading with `since` filters from highlightsController
|
||||
- Controller now fetches ALL highlights without limits
|
||||
- Consistent behavior across all profile views
|
||||
|
||||
### Refactored
|
||||
|
||||
- Centralized data fetching in controllers
|
||||
- Removed duplicate background fetch logic from Me.tsx and Profile.tsx
|
||||
- All writings fetching now handled by writingsController
|
||||
- All highlights fetching now handled by highlightsController
|
||||
- Single source of truth following controller pattern: stream, dedupe, store, emit
|
||||
- Cleaner, more maintainable code (DRY principle)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -317,7 +1102,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
|
||||
@@ -430,7 +1215,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
|
||||
@@ -638,7 +1423,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
|
||||
@@ -684,7 +1469,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
|
||||
@@ -698,7 +1483,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
|
||||
@@ -865,7 +1650,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
|
||||
@@ -928,7 +1713,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
|
||||
@@ -1109,7 +1894,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
|
||||
@@ -1266,7 +2051,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
|
||||
@@ -1289,7 +2074,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
|
||||
@@ -1344,7 +2129,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
|
||||
@@ -1374,7 +2159,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
|
||||
@@ -1387,11 +2172,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
|
||||
|
||||
@@ -1411,8 +2196,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
|
||||
@@ -1421,7 +2206,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
|
||||
@@ -1441,12 +2226,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
|
||||
@@ -1551,7 +2336,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
|
||||
|
||||
@@ -1583,7 +2368,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
|
||||
@@ -2312,7 +3097,18 @@ 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.10.1...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
|
||||
|
||||
@@ -40,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
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# Tailwind CSS Migration Status
|
||||
|
||||
## ✅ Completed (Core Infrastructure)
|
||||
|
||||
### Phase 1: Setup & Foundation
|
||||
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||
- [x] Enable Tailwind preflight (CSS reset)
|
||||
|
||||
### Phase 2: Base Styles Reconciliation
|
||||
- [x] Add CSS variables for user-settable theme colors
|
||||
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||
- `--reading-font`, `--reading-font-size`
|
||||
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||
- [x] Remove redundant base styles handled by Tailwind
|
||||
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||
|
||||
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||
- [x] Switch from pane-scrolling to document-scrolling
|
||||
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||
- [x] Update `app.css` to remove fixed container heights
|
||||
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||
- [x] Fix reading position tracking to work with document scroll
|
||||
- [x] Maintain mobile overlay behavior
|
||||
|
||||
### Phase 4: Component Migrations
|
||||
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||
- Removed 80+ lines of CSS
|
||||
- Added shimmer animation to Tailwind config
|
||||
- Z-index layering maintained (1102)
|
||||
|
||||
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||
- Mobile hamburger button
|
||||
- Mobile highlights button
|
||||
- Mobile backdrop
|
||||
- Removed 60+ lines of CSS
|
||||
|
||||
- [x] **App Container**: Tailwind utilities
|
||||
- Responsive padding (p-0 md:p-4)
|
||||
- Min-height viewport support
|
||||
|
||||
## 📊 Impact & Metrics
|
||||
|
||||
### Lines of CSS Removed
|
||||
- `global.css`: ~50 lines removed
|
||||
- `reader.css`: ~80 lines removed (progress indicator)
|
||||
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||
- **Total**: ~190 lines removed
|
||||
|
||||
### Key Achievements
|
||||
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||
3. **No Breaking Changes**: All existing functionality preserved
|
||||
4. **Type Safety**: TypeScript checks passing
|
||||
5. **Lint Clean**: ESLint checks passing
|
||||
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||
|
||||
## 🔄 Remaining Work (Incremental)
|
||||
|
||||
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||
|
||||
### High-Value Components
|
||||
- [ ] **ContentPanel** - Large component, high impact
|
||||
- Reader header, meta info, loading states
|
||||
- Mark as read button
|
||||
- Article/video menus
|
||||
|
||||
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||
- Card layouts (compact/cards/large views)
|
||||
- Bookmark metadata display
|
||||
- Interactive states
|
||||
|
||||
- [ ] **HighlightsPanel** - Feature-rich
|
||||
- Header with toggles
|
||||
- Highlight items
|
||||
- Level-based styling
|
||||
|
||||
- [ ] **Settings Components** - Forms & controls
|
||||
- Color pickers
|
||||
- Font selectors
|
||||
- Toggle switches
|
||||
- Sliders
|
||||
|
||||
### CSS Files to Prune
|
||||
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||
- `src/styles/components/cards.css` - Bookmark card styles
|
||||
- `src/styles/components/modals.css` - Modal dialogs
|
||||
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||
|
||||
## 🎯 Migration Strategy
|
||||
|
||||
### For New Components
|
||||
Use Tailwind utilities from the start. Reference:
|
||||
```tsx
|
||||
// Good: Tailwind utilities
|
||||
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||
|
||||
// Avoid: New CSS classes
|
||||
<div className="custom-component">
|
||||
```
|
||||
|
||||
### For Existing Components
|
||||
Migrate incrementally when touching files:
|
||||
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||
2. Replace color/background utilities
|
||||
3. Replace typography utilities
|
||||
4. Replace responsive variants
|
||||
5. Remove old CSS rules
|
||||
6. Keep file under 210 lines
|
||||
|
||||
### CSS Variable Usage
|
||||
Dynamic values should still use CSS variables or inline styles:
|
||||
```tsx
|
||||
// User-settable colors
|
||||
style={{ backgroundColor: settings.highlightColorMine }}
|
||||
|
||||
// Or reference CSS variable
|
||||
className="bg-[var(--highlight-color-mine)]"
|
||||
```
|
||||
|
||||
## 📝 Technical Notes
|
||||
|
||||
### Z-Index Layering
|
||||
- Mobile sidepanes: `z-[1001]`
|
||||
- Mobile backdrop: `z-[999]`
|
||||
- Progress indicator: `z-[1102]`
|
||||
- Mobile buttons: `z-[900]`
|
||||
- Relay status: `z-[999]`
|
||||
- Modals: `z-[10000]`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Mobile: `< 768px`
|
||||
- Tablet: `768px - 1024px`
|
||||
- Desktop: `> 1024px`
|
||||
|
||||
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||
|
||||
### Safe Area Insets
|
||||
Mobile notch support:
|
||||
```tsx
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||
}}
|
||||
```
|
||||
|
||||
### Custom Animations
|
||||
Add to `tailwind.config.js`:
|
||||
```js
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
- [x] Tailwind CSS fully integrated and functional
|
||||
- [x] Document scrolling working correctly
|
||||
- [x] Reading position tracking accurate
|
||||
- [x] Progress indicator always visible
|
||||
- [x] No TypeScript errors
|
||||
- [x] No linting errors
|
||||
- [x] Mobile responsiveness maintained
|
||||
- [x] Theme colors (user settings) working
|
||||
- [x] All existing features functional
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Ship It**: Current state is production-ready
|
||||
2. **Incremental Migration**: Convert components as you touch them
|
||||
3. **Monitor**: Watch for any CSS conflicts
|
||||
4. **Cleanup**: Eventually remove unused CSS files
|
||||
5. **Document**: Update component docs with Tailwind patterns
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||
**Date**: 2025-01-14
|
||||
**Commits**: 8 conventional commits
|
||||
**Lines Removed**: ~190 lines of CSS
|
||||
**Breaking Changes**: None
|
||||
|
||||
@@ -147,7 +147,7 @@ function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||
const baseUrl = 'https://read.withboris.com'
|
||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||
|
||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||
const author = meta?.author || 'Boris'
|
||||
|
||||
@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
description: data.description || '',
|
||||
thumbnail_url: data.thumbnail_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
try {
|
||||
if (videoInfo.source === 'youtube') {
|
||||
// YouTube handling
|
||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||
const title = ''
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
thumbnail_url,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
|
||||
@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||
// In a real implementation, you might want to use YouTube's API or other methods
|
||||
const title = '' // Will be populated from captions or other sources
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<title>Boris - Read, Highlight, Explore</title>
|
||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.23",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -23,6 +23,7 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"fetch-opengraph": "^1.0.36",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -4502,6 +4503,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||
@@ -6171,6 +6181,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-opengraph": {
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
|
||||
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -6264,6 +6284,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6896,6 +6936,22 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.30",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -26,6 +26,7 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"fetch-opengraph": "^1.0.36",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"name": "Boris - Read, Highlight, Explore",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
@@ -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",
|
||||
|
||||
47
public/sw-dev.js
Normal file
47
public/sw-dev.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Development Service Worker - simplified version for testing image caching
|
||||
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim())
|
||||
})
|
||||
|
||||
// Image caching - simple version for dev testing
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
const isImage = event.request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
|
||||
if (isImage) {
|
||||
event.respondWith(
|
||||
caches.open('boris-images-dev').then((cache) => {
|
||||
return cache.match(event.request).then((cachedResponse) => {
|
||||
// Try to fetch from network
|
||||
return fetch(event.request).then((response) => {
|
||||
// If fetch succeeds, cache it and return
|
||||
if (response.ok) {
|
||||
cache.put(event.request, response.clone()).catch(() => {
|
||||
// Ignore cache put errors
|
||||
})
|
||||
}
|
||||
return response
|
||||
}).catch((error) => {
|
||||
// If fetch fails (network error, CORS, etc.), return cached response if available
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
// No cache available, reject the promise so browser handles it
|
||||
return Promise.reject(error)
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
// If cache operations fail, try to fetch directly without caching
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
45
src/App.tsx
45
src/App.tsx
@@ -15,12 +15,13 @@ 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 { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
|
||||
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'
|
||||
@@ -94,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
|
||||
@@ -159,6 +160,10 @@ function AppRoutes({
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/share-target"
|
||||
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||
/>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
@@ -232,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}
|
||||
@@ -248,7 +253,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
path="/my/bookmarks"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -260,7 +265,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads"
|
||||
path="/my/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -272,7 +277,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads/:filter"
|
||||
path="/my/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -284,7 +289,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
path="/my/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -296,7 +301,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links/:filter"
|
||||
path="/my/links/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -308,7 +313,7 @@ function AppRoutes({
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
path="/my/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
@@ -343,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={
|
||||
@@ -561,8 +578,6 @@ function App() {
|
||||
|
||||
// Handle user relay list and blocked relays when account changes
|
||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
|
||||
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
|
||||
if (account) {
|
||||
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||
const pubkey = account.pubkey
|
||||
@@ -610,7 +625,7 @@ function App() {
|
||||
loadUserRelayList(pool, pubkey, {
|
||||
onUpdate: (userRelays) => {
|
||||
const interimRelays = computeRelaySet({
|
||||
hardcoded: [],
|
||||
hardcoded: HARDCODED_RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelays,
|
||||
blocked: [],
|
||||
@@ -624,7 +639,7 @@ function App() {
|
||||
const blockedRelays = await blockedPromise.catch(() => [])
|
||||
|
||||
const finalRelays = computeRelaySet({
|
||||
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
|
||||
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||
bunker: bunkerRelays,
|
||||
userList: userRelayList,
|
||||
blocked: blockedRelays,
|
||||
|
||||
@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { fetchReadableContent } from '../services/readerService'
|
||||
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||
|
||||
interface AddBookmarkModalProps {
|
||||
onClose: () => void
|
||||
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
// Helper to extract metadata from HTML
|
||||
function extractMetaTag(html: string, patterns: string[]): string | null {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(new RegExp(pattern, 'i'))
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTags(html: string): string[] {
|
||||
// Helper to extract tags from OpenGraph data
|
||||
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
// Extract keywords meta tag
|
||||
const keywords = extractMetaTag(html, [
|
||||
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
])
|
||||
if (keywords) {
|
||||
keywords.split(/[,;]/)
|
||||
.map(k => k.trim().toLowerCase())
|
||||
.filter(k => k.length > 0 && k.length < 30)
|
||||
.forEach(k => tags.push(k))
|
||||
// Extract keywords from OpenGraph data
|
||||
if (ogData.keywords && typeof ogData.keywords === 'string') {
|
||||
ogData.keywords.split(/[,;]/)
|
||||
.map((k: string) => k.trim().toLowerCase())
|
||||
.filter((k: string) => k.length > 0 && k.length < 30)
|
||||
.forEach((k: string) => tags.push(k))
|
||||
}
|
||||
|
||||
// Extract article:tag (multiple possible)
|
||||
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
||||
let match
|
||||
while ((match = articleTagRegex.exec(html)) !== null) {
|
||||
const tag = match[1].trim().toLowerCase()
|
||||
if (tag && tag.length < 30) tags.push(tag)
|
||||
// Extract article:tag from OpenGraph data
|
||||
if (ogData['article:tag']) {
|
||||
const articleTagValue = ogData['article:tag']
|
||||
const articleTags = Array.isArray(articleTagValue)
|
||||
? articleTagValue
|
||||
: [articleTagValue]
|
||||
|
||||
articleTags.forEach((tag: unknown) => {
|
||||
if (typeof tag === 'string') {
|
||||
const cleanTag = tag.trim().toLowerCase()
|
||||
if (cleanTag && cleanTag.length < 30) {
|
||||
tags.push(cleanTag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(new Set(tags)).slice(0, 5)
|
||||
@@ -83,17 +82,27 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||
setIsFetchingMetadata(true)
|
||||
try {
|
||||
const content = await fetchReadableContent(normalizedUrl)
|
||||
lastFetchedUrlRef.current = normalizedUrl
|
||||
// Fetch both readable content and OpenGraph data in parallel
|
||||
const [content, ogData] = await Promise.all([
|
||||
fetchReadableContent(normalizedUrl),
|
||||
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
||||
])
|
||||
|
||||
lastFetchedUrlRef.current = normalizedUrl
|
||||
let extractedAnything = false
|
||||
|
||||
// Extract title: prioritize og:title > twitter:title > <title>
|
||||
if (!title && content.html) {
|
||||
const extractedTitle = extractMetaTag(content.html, [
|
||||
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
]) || content.title
|
||||
// Extract title: prioritize og:title > twitter:title > content.title
|
||||
if (!title) {
|
||||
let extractedTitle = null
|
||||
|
||||
if (ogData) {
|
||||
extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
|
||||
}
|
||||
|
||||
// Fallback to content.title if no OpenGraph title found
|
||||
if (!extractedTitle) {
|
||||
extractedTitle = content.title
|
||||
}
|
||||
|
||||
if (extractedTitle) {
|
||||
setTitle(extractedTitle)
|
||||
@@ -102,12 +111,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
|
||||
// Extract description: prioritize og:description > twitter:description > meta description
|
||||
if (!description && content.html) {
|
||||
const extractedDesc = extractMetaTag(content.html, [
|
||||
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
])
|
||||
if (!description && ogData) {
|
||||
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||
|
||||
if (extractedDesc) {
|
||||
setDescription(extractedDesc)
|
||||
@@ -116,8 +121,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
|
||||
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||
if (!tagsInput && content.html) {
|
||||
const extractedTags = extractTags(content.html)
|
||||
if (!tagsInput && ogData) {
|
||||
const extractedTags = extractTagsFromOgData(ogData)
|
||||
|
||||
// Only add boris tag if we extracted something
|
||||
if (extractedAnything || extractedTags.length > 0) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
const npub = nip19.npubEncode(authorPubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
navigate(`/p/${npub}/writings`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -17,12 +18,18 @@ interface BlogPostCardProps {
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
|
||||
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
|
||||
// when they come into view. The Service Worker will cache them automatically.
|
||||
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
|
||||
// when there are many blog posts.
|
||||
|
||||
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')) {
|
||||
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -40,7 +47,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
|
||||
// Debug log - reading progress shown as visual indicator
|
||||
if (readingProgress !== undefined) {
|
||||
// Reading progress display
|
||||
@@ -49,6 +56,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' }}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
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, naddrEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
@@ -23,6 +24,7 @@ interface BookmarkItemProps {
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||
const navigate = useNavigate()
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -40,10 +42,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image and summary tags (per NIP-23)
|
||||
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
|
||||
// Note: We extract directly from tags here since we don't have the full event.
|
||||
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
|
||||
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
||||
|
||||
@@ -58,8 +61,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 = () => {
|
||||
@@ -110,10 +111,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// For kind:30023 articles, pass the bookmark data instead of URL
|
||||
// For kind:30023 articles, navigate to /a/:naddr route
|
||||
if (bookmark.kind === 30023) {
|
||||
if (onSelectUrl) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -135,7 +142,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
@@ -151,11 +157,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
articleTitle,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
@@ -167,5 +169,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
|
||||
}
|
||||
|
||||
@@ -1,8 +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 { formatDistanceToNow } from 'date-fns'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
@@ -13,7 +13,7 @@ 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'
|
||||
@@ -58,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false,
|
||||
@@ -71,7 +70,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())
|
||||
@@ -106,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
// For web bookmarks and other types, try to use URL if available
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (bookmark.kind === 39701) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
// Ensure URL has protocol
|
||||
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||
return readingProgressMap.get(url)
|
||||
}
|
||||
}
|
||||
|
||||
// For other bookmark types, try to extract URL from content
|
||||
const urls = extractUrlsFromContent(bookmark.content)
|
||||
if (urls.length > 0) {
|
||||
return readingProgressMap.get(urls[0])
|
||||
@@ -120,6 +130,18 @@ 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')
|
||||
@@ -140,39 +162,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: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
|
||||
{ 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 }
|
||||
]
|
||||
|
||||
// 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
|
||||
@@ -282,27 +323,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
variant="ghost"
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
{activeAccount && (
|
||||
<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'}
|
||||
variant="ghost"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
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'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,12 +20,11 @@ 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
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
articleTitle?: string
|
||||
readingProgress?: number
|
||||
}
|
||||
|
||||
@@ -32,14 +33,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
contentTypeIcon,
|
||||
articleTitle,
|
||||
readingProgress
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
@@ -47,21 +46,41 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isNote = bookmark.kind === 1
|
||||
|
||||
// Calculate progress color (matching BlogPostCard logic)
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
// Extract title from tags for regular bookmarks (not just articles)
|
||||
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = () => {
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassificationType) {
|
||||
switch (firstUrlClassificationType) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faFileLines
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
}
|
||||
|
||||
// For notes, use sticky note icon
|
||||
if (isNote) return faStickyNote
|
||||
|
||||
// Default fallback
|
||||
return faLink
|
||||
}
|
||||
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
@@ -73,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
}
|
||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
@@ -82,127 +102,113 @@ 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}`}
|
||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
return (
|
||||
<button
|
||||
key={urlIndex}
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
<div className="card-layout">
|
||||
<div className="card-content">
|
||||
<div className="card-content-header">
|
||||
{(cachedImage || firstUrl) && (
|
||||
<div
|
||||
className="card-thumbnail"
|
||||
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{extractedUrls.length > 1 && (
|
||||
<button
|
||||
className="expand-toggle-urls"
|
||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
{!cachedImage && firstUrl && (
|
||||
<div className="thumbnail-placeholder">
|
||||
<FontAwesomeIcon icon={getContentTypeIcon()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-text-content">
|
||||
<div className="bookmark-header">
|
||||
</div>
|
||||
|
||||
{/* Display title for articles or bookmarks with titles */}
|
||||
{(articleTitle || bookmarkTitle) && (
|
||||
<h3 className="bookmark-title">
|
||||
<RichContent content={articleTitle || bookmarkTitle || ''} className="" />
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{isArticle && articleSummary ? (
|
||||
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<RichContent content={bookmark.content} className="bookmark-content" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading progress indicator as separator - always shown for all bookmark types */}
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={1}
|
||||
marginTop="0.125rem"
|
||||
marginBottom="0.125rem"
|
||||
/>
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
|
||||
</button>
|
||||
)}
|
||||
{getAuthorDisplayName()}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bookmark-footer-right">
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isArticle && articleSummary ? (
|
||||
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{shouldTruncate && bookmark.content
|
||||
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}…`} className="" />
|
||||
: renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||
)}
|
||||
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reading progress indicator for articles */}
|
||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${Math.round(readingProgress * 100)}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</Link>
|
||||
</div>
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import RichContent from '../RichContent'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -11,7 +14,7 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleSummary?: string
|
||||
articleTitle?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number
|
||||
}
|
||||
@@ -22,37 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleSummary,
|
||||
articleTitle,
|
||||
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)
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} 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,36 +67,26 @@ 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>
|
||||
|
||||
{/* Reading progress indicator for all bookmark types with reading data */}
|
||||
{/* Reading progress indicator - only show when there's actual progress */}
|
||||
{readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
margin: '0',
|
||||
marginLeft: '1.5rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${Math.round(readingProgress * 100)}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={1}
|
||||
marginLeft="1.5rem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ 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'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,7 +19,6 @@ interface LargeViewProps {
|
||||
getIconForUrlType: IconGetter
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
@@ -35,7 +35,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getIconForUrlType,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
@@ -45,15 +44,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
@@ -63,6 +53,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}`}
|
||||
@@ -100,27 +114,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
<RichContent content={bookmark.content} className="large-text" />
|
||||
)}
|
||||
|
||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Reading progress indicator for all bookmark types - always shown */}
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={3}
|
||||
marginTop="0.75rem"
|
||||
/>
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
@@ -136,16 +135,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,8 @@ 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 { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
@@ -38,7 +40,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 +54,27 @@ 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
|
||||
|
||||
// Manage document title based on current route
|
||||
const isViewingContent = !!(naddr || externalUrl || eventId)
|
||||
useDocumentTitle({
|
||||
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
|
||||
})
|
||||
|
||||
// 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,12 +94,23 @@ 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
|
||||
}
|
||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||
|
||||
// Reset scroll to top when navigating to profile routes
|
||||
useEffect(() => {
|
||||
if (showProfile) {
|
||||
// Reset scroll position when navigating to profile pages
|
||||
// Use requestAnimationFrame to ensure it happens after DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
}
|
||||
}, [location.pathname, showProfile])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -220,14 +240,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => {
|
||||
// Deduplicate by checking if highlight with this ID already exists
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) {
|
||||
return prev // Don't add duplicate
|
||||
}
|
||||
return [highlight, ...prev]
|
||||
}),
|
||||
settings
|
||||
})
|
||||
|
||||
// Determine which loader should be active based on route
|
||||
// Only one loader should run at a time to prevent state conflicts
|
||||
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
|
||||
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
|
||||
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
naddr: shouldLoadArticle ? naddr : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -242,7 +276,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
url: shouldLoadExternal ? externalUrl : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
@@ -255,6 +289,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load event if /e/:eventId route is used
|
||||
useEventLoader({
|
||||
eventId: shouldLoadEvent ? eventId : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
})
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
@@ -34,18 +33,16 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||
import { archiveController } from '../services/archiveController'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { shouldTrackReadingProgress } from '../utils/helpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
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 {
|
||||
@@ -76,6 +73,7 @@ interface ContentPanelProps {
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -103,21 +101,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
isHighlightsCollapsed = false,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
@@ -132,6 +127,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,
|
||||
@@ -143,8 +143,18 @@ 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
|
||||
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
|
||||
if (selectedUrl?.startsWith('nostr-event:')) 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(() => {
|
||||
@@ -152,6 +162,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) {
|
||||
@@ -162,7 +180,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
|
||||
}
|
||||
|
||||
@@ -182,12 +200,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: () => {
|
||||
@@ -207,59 +252,99 @@ 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)
|
||||
|
||||
// Reset scroll position and restore ref when article changes
|
||||
useEffect(() => {
|
||||
if (!articleIdentifier) return
|
||||
|
||||
// Suppress saves during navigation to prevent saving 0% position
|
||||
// The 500ms suppression covers the scroll reset and initial render
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(500)
|
||||
}
|
||||
|
||||
// Reset scroll to top when article identifier changes
|
||||
// This prevents showing wrong scroll position from previous article
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
// Reset restore attempt tracking for new article
|
||||
hasAttemptedRestoreRef.current = null
|
||||
}, [articleIdentifier])
|
||||
|
||||
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(() => {
|
||||
@@ -268,21 +353,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
if (showArticleMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
// Check available space and position menu upward if needed
|
||||
useEffect(() => {
|
||||
@@ -305,13 +387,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (showArticleMenu) {
|
||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||
}
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
if (showExternalMenu) {
|
||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
@@ -343,34 +422,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
// 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)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
// Load YouTube metadata/captions when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(selectedUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [selectedUrl])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
|
||||
// Get article links for menu
|
||||
@@ -408,7 +461,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
@@ -485,52 +537,17 @@ 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)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
if (!selectedUrl) return
|
||||
const native = buildNativeVideoUrl(selectedUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = selectedUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
@@ -572,7 +589,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)
|
||||
}
|
||||
@@ -722,13 +745,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
@@ -749,7 +765,7 @@ 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]}
|
||||
@@ -768,126 +784,51 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={ytMeta?.title || title}
|
||||
title={title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
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">
|
||||
<ReactPlayer
|
||||
url={selectedUrl as string}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
{ytMeta?.description && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{ytMeta.description}
|
||||
</div>
|
||||
)}
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{loading || !markdown && !html ? (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
@@ -895,7 +836,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
{!isNostrArticle && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
@@ -922,13 +863,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}
|
||||
@@ -1043,11 +987,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -651,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
100,
|
||||
eventStore || undefined
|
||||
)
|
||||
|
||||
setWritingPosts(posts)
|
||||
@@ -779,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
|
||||
|
||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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'
|
||||
@@ -8,7 +8,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
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'
|
||||
@@ -31,6 +31,7 @@ import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedu
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
|
||||
// Accessors from Helpers (currently unused here)
|
||||
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
@@ -56,6 +57,7 @@ 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[]>([])
|
||||
@@ -80,11 +82,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
|
||||
|
||||
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
// Visibility filters - load from localStorage first, fallback to settings
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||
// Try to load from localStorage first
|
||||
try {
|
||||
const saved = localStorage.getItem('exploreScopeVisibility')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
// Validate that at least one scope is enabled
|
||||
if (parsed.nostrverse || parsed.friends || parsed.mine) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load explore scope from localStorage:', err)
|
||||
}
|
||||
|
||||
// Fallback to settings or defaults
|
||||
return {
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure at least one scope remains active
|
||||
@@ -94,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||
return prev // ignore toggle that would disable all scopes
|
||||
}
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
@@ -106,6 +131,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[]) => {
|
||||
@@ -207,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
// Update visibility when settings/login state changes
|
||||
useEffect(() => {
|
||||
// Check if user has a saved preference
|
||||
const hasSavedPreference = (() => {
|
||||
try {
|
||||
return localStorage.getItem('exploreScopeVisibility') !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
// Only reset to defaults if no saved preference exists
|
||||
if (hasSavedPreference) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeAccount) {
|
||||
// When logged out, show nostrverse by default
|
||||
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
} else {
|
||||
// When logged in, use settings defaults immediately
|
||||
setVisibility({
|
||||
const defaultVisibility = {
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
}
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(false)
|
||||
setHasLoadedNostrverseHighlights(false)
|
||||
}
|
||||
@@ -246,67 +312,81 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Followed pubkeys
|
||||
if (activeAccount?.pubkey) {
|
||||
fetchContacts(relayPool, activeAccount.pubkey)
|
||||
.then((contacts) => {
|
||||
setFollowedPubkeys(new Set(contacts))
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Prepare parallel fetches
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(followedPubkeys)
|
||||
|
||||
const nostrversePostsPromise: Promise<BlogPostPreview[]> = (!activeAccount || (activeAccount && visibility.nostrverse))
|
||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined).catch(() => [])
|
||||
: Promise.resolve([])
|
||||
|
||||
// Fire non-blocking fetches and merge as they resolve
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
||||
.then((friendsPosts) => {
|
||||
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||
fetchNostrverseBlogPosts(
|
||||
relayPool,
|
||||
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, settings, visibility.nostrverse, followedPubkeys])
|
||||
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [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(() => {
|
||||
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||
@@ -492,8 +572,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">
|
||||
@@ -553,7 +635,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<FontAwesomeIcon icon={faPersonHiking} />
|
||||
Explore
|
||||
</h1>
|
||||
|
||||
@@ -625,7 +707,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={activeTab}>
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
bottom: '80px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
@@ -114,7 +114,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -133,12 +132,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||
}
|
||||
|
||||
// Update offline indicator when highlight prop changes
|
||||
useEffect(() => {
|
||||
if (highlight.isOfflineCreated && !isSyncing) {
|
||||
setShowOfflineIndicator(true)
|
||||
}
|
||||
}, [highlight.isOfflineCreated, isSyncing])
|
||||
|
||||
// Listen to sync state changes
|
||||
useEffect(() => {
|
||||
@@ -147,8 +140,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setIsSyncing(syncingState)
|
||||
// When sync completes successfully, update highlight to show all relays
|
||||
if (!syncingState) {
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
// Update the highlight with all relays after successful sync
|
||||
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||
const updatedHighlight = {
|
||||
@@ -212,12 +203,23 @@ 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,9 +283,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onHighlightUpdate(updatedHighlight)
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to rebroadcast:', error)
|
||||
} finally {
|
||||
@@ -302,8 +301,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Always show relay list, use plane icon for local-only
|
||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||
// Check if this highlight was only published to local relays
|
||||
let isLocalOnly = highlight.isLocalOnly
|
||||
const publishedRelays = highlight.publishedRelays || []
|
||||
|
||||
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
|
||||
if (isLocalOnly === undefined) {
|
||||
if (isEventOfflineCreated(highlight.id)) {
|
||||
isLocalOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2: If publishedRelays only contains local relays, it's local-only
|
||||
if (isLocalOnly === undefined && publishedRelays.length > 0) {
|
||||
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
|
||||
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
|
||||
if (hasOnlyLocalRelays && !hasRemoteRelays) {
|
||||
isLocalOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If isLocalOnly is true (from any fallback), show airplane icon
|
||||
if (isLocalOnly === true) {
|
||||
return {
|
||||
icon: faPlane,
|
||||
tooltip: publishedRelays.length > 0
|
||||
? 'Local relays only - will sync when remote relays available'
|
||||
: 'Created in flight mode - will sync when remote relays available',
|
||||
spin: false
|
||||
}
|
||||
}
|
||||
|
||||
// Show highlighter icon with relay info if available
|
||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||
@@ -311,7 +339,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -422,7 +450,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)
|
||||
@@ -116,15 +118,14 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<HighlightsPanelHeader
|
||||
loading={loading}
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
showHighlights={showHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
currentUserPubkey={currentUserPubkey}
|
||||
onToggleHighlights={handleToggleHighlights}
|
||||
onRefresh={onRefresh}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import React from 'react'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
hasHighlights: boolean
|
||||
showHighlights: boolean
|
||||
highlightVisibility: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
onToggleHighlights: () => void
|
||||
onRefresh?: () => void
|
||||
onToggleCollapse: () => void
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
loading,
|
||||
hasHighlights,
|
||||
showHighlights,
|
||||
highlightVisibility,
|
||||
currentUserPubkey,
|
||||
onToggleHighlights,
|
||||
onRefresh,
|
||||
onToggleCollapse,
|
||||
onHighlightVisibilityChange
|
||||
onHighlightVisibilityChange,
|
||||
isMobile = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-actions">
|
||||
<div className="highlights-actions-left">
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
)}
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<IconButton
|
||||
@@ -80,17 +89,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="highlights-actions-right">
|
||||
{hasHighlights && (
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
@@ -101,14 +101,6 @@ 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)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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, faHeart } 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,8 @@ 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 { dedupeBookmarksById } from '../services/bookmarkHelpers'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
@@ -42,7 +44,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 +59,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 +131,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 +145,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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,23 +230,24 @@ const Me: React.FC<MeProps> = ({
|
||||
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)
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, loadedTabs])
|
||||
return new Set(prev).add('bookmarks')
|
||||
})
|
||||
|
||||
// Always turn off loading after a tick
|
||||
setTimeout(() => setLoading(false), 0)
|
||||
}, [viewingPubkey, activeAccount])
|
||||
|
||||
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,18 +266,22 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load reads:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, loadedTabs, relayPool, eventStore])
|
||||
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||
|
||||
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)
|
||||
|
||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
||||
// Derive links from bookmarks with OpenGraph enhancement
|
||||
const initialLinks = await deriveLinksFromBookmarks(bookmarks)
|
||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||
setLinksMap(initialMap)
|
||||
setLinks(initialLinks)
|
||||
@@ -310,7 +310,7 @@ const Me: React.FC<MeProps> = ({
|
||||
console.error('Failed to load links:', err)
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}, [viewingPubkey, activeAccount, loadedTabs, bookmarks, relayPool, readingProgressMap])
|
||||
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||
|
||||
// Load active tab data
|
||||
const loadActiveTab = useCallback(() => {
|
||||
@@ -336,7 +336,7 @@ const Me: React.FC<MeProps> = ({
|
||||
case 'writings':
|
||||
loadWritingsTab()
|
||||
break
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
loadReadingListTab()
|
||||
break
|
||||
case 'reads':
|
||||
@@ -395,8 +395,7 @@ const Me: React.FC<MeProps> = ({
|
||||
}
|
||||
|
||||
const getReadItemUrl = (item: ReadItem) => {
|
||||
if (item.type === 'article') {
|
||||
// ID is already in naddr format
|
||||
if (item.type === 'article' && item.id.startsWith('naddr1')) {
|
||||
return `/a/${item.id}`
|
||||
} else if (item.url) {
|
||||
return `/r/${encodeURIComponent(item.url)}`
|
||||
@@ -420,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',
|
||||
@@ -439,19 +438,16 @@ const Me: React.FC<MeProps> = ({
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For regular URLs, navigate to the reader route
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
@@ -492,8 +488,10 @@ const Me: React.FC<MeProps> = ({
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
// Merge and flatten all individual bookmarks with deduplication
|
||||
const allIndividualBookmarks = dedupeBookmarksById(
|
||||
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
)
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
|
||||
@@ -567,9 +565,21 @@ const Me: React.FC<MeProps> = ({
|
||||
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||
: []
|
||||
|
||||
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||
const titles: Record<BookmarkFilterType, string> = {
|
||||
'all': 'All Bookmarks',
|
||||
'article': 'Bookmarked Reads',
|
||||
'external': 'Bookmarked Links',
|
||||
'video': 'Bookmarked Videos',
|
||||
'note': 'Bookmarked Notes',
|
||||
'web': 'Web Bookmarks'
|
||||
}
|
||||
return titles[filter]
|
||||
}
|
||||
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
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 },
|
||||
@@ -611,7 +621,7 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
case 'bookmarks':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
@@ -657,21 +667,26 @@ const Me: React.FC<MeProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)))}
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<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"
|
||||
/>
|
||||
<div className="view-mode-controls">
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
icon={faHeart}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support Boris"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
style={{ color: 'rgb(251 146 60)' }}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -856,15 +871,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>
|
||||
@@ -872,7 +887,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>
|
||||
@@ -880,7 +895,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>
|
||||
@@ -888,7 +903,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>
|
||||
|
||||
@@ -6,10 +6,8 @@ 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 { BlogPostPreview } from '../services/exploreService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
@@ -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
|
||||
@@ -103,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], getActiveRelayUrls(relayPool), 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
|
||||
|
||||
@@ -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)
|
||||
@@ -78,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
<img
|
||||
src={cachedImage}
|
||||
alt={title || 'Article image'}
|
||||
onError={(e) => {
|
||||
console.error('[reader-header] Image failed to load:', cachedImage, e)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
@@ -107,8 +115,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 +162,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>
|
||||
|
||||
58
src/components/ReadingProgressBar.tsx
Normal file
58
src/components/ReadingProgressBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ReadingProgressBarProps {
|
||||
readingProgress?: number
|
||||
height?: number
|
||||
marginTop?: string
|
||||
marginBottom?: string
|
||||
marginLeft?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ReadingProgressBar: React.FC<ReadingProgressBarProps> = ({
|
||||
readingProgress,
|
||||
height = 1,
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
className
|
||||
}) => {
|
||||
// Calculate progress color
|
||||
let progressColor = '#6366f1' // Default blue (reading)
|
||||
if (readingProgress && readingProgress >= 0.95) {
|
||||
progressColor = '#10b981' // Green (completed)
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
const progressWidth = readingProgress ? `${Math.round(readingProgress * 100)}%` : '0%'
|
||||
const progressBackground = readingProgress ? progressColor : 'var(--color-border)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
borderRadius: '0.5px',
|
||||
overflow: 'hidden',
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
position: 'relative',
|
||||
minHeight: `${height}px`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: progressWidth,
|
||||
background: progressBackground,
|
||||
transition: 'width 0.3s ease, background 0.3s ease',
|
||||
minHeight: `${height}px`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
fullWidthImages: true,
|
||||
renderVideoLinksAsEmbeds: true,
|
||||
syncReadingPosition: true,
|
||||
autoScrollToReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
ttsUseSystemLanguage: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
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,11 +1,13 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useRef, useEffect } 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, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import IconButton from './IconButton'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { preloadImage } from '../hooks/useImageCache'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -18,6 +20,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
@@ -33,73 +37,154 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
// Preload profile image for offline access
|
||||
useEffect(() => {
|
||||
if (profileImage) {
|
||||
preloadImage(profileImage)
|
||||
}
|
||||
}, [profileImage])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowProfileMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showProfileMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showProfileMenu])
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
// Close mobile sidebar when navigating on mobile
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
{isMobile ? (
|
||||
<div className="sidebar-header-left">
|
||||
{activeAccount && (
|
||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</button>
|
||||
{showProfileMenu && (
|
||||
<div className="profile-dropdown-menu">
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>My Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span>My Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span>My Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span>My Links</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span>My Writings</span>
|
||||
</button>
|
||||
<div className="profile-menu-separator"></div>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(onLogout)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
icon={faHome}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/')
|
||||
}}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
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>
|
||||
<div className="sidebar-header-right">
|
||||
{activeAccount && (
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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 && (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
icon={faPersonHiking}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/explore')
|
||||
}}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
onOpenSettings()
|
||||
}}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
||||
const lang = detect(text)
|
||||
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||
} catch (err) {
|
||||
console.debug('[tts][detect] failed', err)
|
||||
// ignore detection errors
|
||||
}
|
||||
}
|
||||
if (!langOverride && resolvedSystemLang) {
|
||||
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
||||
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||
const next = SPEED_OPTIONS[nextIndex]
|
||||
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
|
||||
setRate(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import VideoView from './VideoView'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -134,15 +136,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
const savedScrollPosition = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
// Save current scroll position
|
||||
savedScrollPosition.current = window.scrollY
|
||||
document.body.style.top = `-${savedScrollPosition.current}px`
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
// Restore scroll position
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
if (savedScrollPosition.current > 0) {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, savedScrollPosition.current)
|
||||
savedScrollPosition.current = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
@@ -306,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
@@ -358,42 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.support}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
) : (() => {
|
||||
// Determine if this is a video URL
|
||||
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
|
||||
|
||||
if (isExternalVideo) {
|
||||
return (
|
||||
<VideoView
|
||||
videoUrl={props.selectedUrl!}
|
||||
title={props.readerContent?.title}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
@@ -413,6 +461,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,10 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
}, ref) => {
|
||||
const processedHtml = useMemo(() => {
|
||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||
const { processedHtml, videoUrls } = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return html
|
||||
return { processedHtml: html, videoUrls: [] }
|
||||
}
|
||||
|
||||
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||
@@ -86,71 +87,19 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
|
||||
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||
|
||||
let processedHtml = result
|
||||
let finalHtml = result
|
||||
remainingUrls.forEach((url) => {
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
collectedUrls.push(url)
|
||||
placeholderIndex++
|
||||
})
|
||||
|
||||
// If nothing collected, return original html
|
||||
if (collectedUrls.length === 0) {
|
||||
return html
|
||||
// Return both processed HTML and collected URLs (in the same order as placeholders)
|
||||
return {
|
||||
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
|
||||
videoUrls: collectedUrls
|
||||
}
|
||||
|
||||
return processedHtml
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
const videoUrls = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return []
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
// 1) Extract from <video> blocks first (video src or nested source src)
|
||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||
const videoBlocks = html.match(videoBlockPattern) || []
|
||||
videoBlocks.forEach((block) => {
|
||||
let url: string | null = null
|
||||
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||
url = videoSrcMatch[1]
|
||||
} else {
|
||||
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||
url = sourceSrcMatch[1]
|
||||
}
|
||||
}
|
||||
if (url && !urls.includes(url)) urls.push(url)
|
||||
})
|
||||
|
||||
// 2) Extract from <img> tags with video src
|
||||
const imgTagPattern = /<img[^>]*>/gi
|
||||
const allImgTags = html.match(imgTagPattern) || []
|
||||
allImgTags.forEach((imgTag) => {
|
||||
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
|
||||
urls.push(srcMatch[1])
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Extract remaining direct file URLs and platform-classified video URLs
|
||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
|
||||
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||
const allUrls: string[] = html.match(allUrlPattern) || []
|
||||
allUrls.forEach(u => {
|
||||
const classification = classifyUrl(u)
|
||||
if (classification.type === 'video' && !urls.includes(u)) {
|
||||
urls.push(u)
|
||||
}
|
||||
})
|
||||
|
||||
return urls
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
// If no video embedding is enabled, just render the HTML normally
|
||||
@@ -195,13 +144,16 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
||||
}
|
||||
}
|
||||
|
||||
// Regular HTML content
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
// Regular HTML content - only render if not empty
|
||||
if (part.trim()) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
320
src/components/VideoView.tsx
Normal file
320
src/components/VideoView.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { getYouTubeThumbnail } from '../utils/imagePreview'
|
||||
|
||||
// Helper function to get Vimeo thumbnail
|
||||
const getVimeoThumbnail = (url: string): string | null => {
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (!vimeoMatch) return null
|
||||
|
||||
const videoId = vimeoMatch[1]
|
||||
return `https://vumbnail.com/${videoId}.jpg`
|
||||
}
|
||||
import {
|
||||
createWebsiteReaction,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import { unarchiveWebsite } from '../services/unarchiveService'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
|
||||
interface VideoViewProps {
|
||||
videoUrl: string
|
||||
title?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const VideoView: React.FC<VideoViewProps> = ({
|
||||
videoUrl,
|
||||
title,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
|
||||
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load YouTube metadata when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!videoUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(videoUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [videoUrl])
|
||||
|
||||
// Check if video is marked as watched
|
||||
useEffect(() => {
|
||||
const checkWatchedStatus = async () => {
|
||||
if (!activeAccount || !videoUrl) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
try {
|
||||
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
|
||||
setIsMarkedAsWatched(isWatched)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkWatchedStatus()
|
||||
}, [activeAccount, videoUrl, relayPool])
|
||||
|
||||
// Handle click outside to close menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
// Check menu position for upward opening
|
||||
useEffect(() => {
|
||||
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
|
||||
if (!menuRef.current) return
|
||||
|
||||
const rect = menuRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// Open upward if there's more space above and less space below
|
||||
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
const handleMarkAsWatched = async () => {
|
||||
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
try {
|
||||
if (isMarkedAsWatched) {
|
||||
// Unmark as watched
|
||||
if (relayPool) {
|
||||
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(false)
|
||||
} else {
|
||||
// Mark as watched
|
||||
if (relayPool) {
|
||||
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
setTimeout(() => setShowCheckAnimation(false), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenVideoExternal = () => {
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
const native = buildNativeVideoUrl(videoUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = videoUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: ytMeta?.title || title || 'Video',
|
||||
url: videoUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const displayTitle = ytMeta?.title || title
|
||||
const displaySummary = ytMeta?.description || summary
|
||||
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
|
||||
|
||||
// Get video thumbnail for cover image
|
||||
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
|
||||
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
|
||||
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
|
||||
const displayImage = videoThumbnail || image
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReaderHeader
|
||||
title={displayTitle}
|
||||
image={displayImage}
|
||||
summary={displaySummary}
|
||||
published={published}
|
||||
readingTimeText={durationText}
|
||||
hasHighlights={false}
|
||||
highlightCount={0}
|
||||
settings={settings}
|
||||
highlights={[]}
|
||||
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displaySummary && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{displaySummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsWatched}
|
||||
disabled={isCheckingWatchedStatus}
|
||||
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
|
||||
/>
|
||||
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoView
|
||||
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)
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||
import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
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, getFromCache, saveToCache } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { preloadImage } from './useImageCache'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
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 +41,7 @@ interface UseArticleLoaderProps {
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -36,75 +53,453 @@ 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
|
||||
|
||||
// Track the current article title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (!relayPool || !naddr) return
|
||||
// First check: naddr is required
|
||||
if (!naddr) {
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const loadArticle = async () => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// Clear readerContent immediately to prevent showing stale content from previous article
|
||||
// This ensures images from previous articles don't flash briefly
|
||||
setReaderContent(undefined)
|
||||
|
||||
// Synchronously check cache sources BEFORE checking relayPool
|
||||
// This prevents showing loading skeletons when content is immediately available
|
||||
// and fixes the race condition where relayPool isn't ready yet
|
||||
let foundInCache = false
|
||||
try {
|
||||
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||
const cachedArticle = getFromCache(naddr)
|
||||
if (cachedArticle) {
|
||||
foundInCache = true
|
||||
const title = cachedArticle.title || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: cachedArticle.markdown,
|
||||
image: cachedArticle.image,
|
||||
summary: cachedArticle.summary,
|
||||
published: cachedArticle.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(cachedArticle.event.id)
|
||||
setCurrentArticle?.(cachedArticle.event)
|
||||
setReaderLoading(false)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Preload image if available to ensure it's cached by Service Worker
|
||||
// This ensures images are available when offline
|
||||
if (cachedArticle.image) {
|
||||
preloadImage(cachedArticle.image)
|
||||
}
|
||||
|
||||
// Store in EventStore for future lookups
|
||||
if (eventStore) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventStore.add?.(cachedArticle.event as unknown as any)
|
||||
} catch {
|
||||
// Silently ignore store errors
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights in background (don't block UI)
|
||||
// Only fetch highlights if relayPool is available
|
||||
if (mountedRef.current && relayPool) {
|
||||
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||
const eventId = cachedArticle.event.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(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,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have cached content, no need to query relays
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If cache check fails, fall through to async loading
|
||||
console.warn('[article-loader] Cache check failed:', err)
|
||||
}
|
||||
|
||||
// Check EventStore synchronously (also doesn't need relayPool)
|
||||
let foundInEventStore = false
|
||||
if (eventStore && !foundInCache) {
|
||||
try {
|
||||
// Decode naddr to get the coordinate
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
foundInEventStore = true
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
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)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Fetch highlights in background if relayPool is available
|
||||
if (relayPool) {
|
||||
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||
const eventId = storedEvent.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(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,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have EventStore content, no need to query relays yet
|
||||
// But we might want to fetch from relays in background if relayPool becomes available
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
console.warn('[article-loader] EventStore check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only return early if we have no content AND no relayPool to fetch from
|
||||
if (!relayPool && !foundInCache && !foundInEventStore) {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have relayPool, proceed with async loading
|
||||
if (!relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// Don't clear highlights yet - let the smart filtering logic handle it
|
||||
// when we know the article coordinate
|
||||
setHighlightsLoading(false) // Don't show loading yet
|
||||
|
||||
// Note: Cache and EventStore were already checked synchronously above
|
||||
// This async function only runs if we need to fetch from relays
|
||||
|
||||
// At this point, we've checked EventStore and cache - neither had content
|
||||
// Only show loading skeleton if we also don't have preview data
|
||||
if (previewData) {
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
setCurrentTitle(previewData.title)
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
|
||||
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
|
||||
// Don't preload image here - it should already be cached from BlogPostCard
|
||||
// Preloading again would be redundant and could cause unnecessary network requests
|
||||
} else {
|
||||
// No cache, no EventStore, no preview data - need to load from relays
|
||||
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
|
||||
|
||||
// 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)
|
||||
|
||||
setCurrentTitle(title)
|
||||
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)
|
||||
|
||||
// Save to cache immediately when we get the first event
|
||||
// Don't wait for queryEvents to complete in case it hangs
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: evt.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: evt.pubkey,
|
||||
event: evt
|
||||
}
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
|
||||
// Preload image to ensure it's cached by Service Worker
|
||||
if (image) {
|
||||
preloadImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
setCurrentTitle(title)
|
||||
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)
|
||||
|
||||
// Save to cache for future loads (if we haven't already saved from first event)
|
||||
// Only save if this is a different/newer event than what we first rendered
|
||||
// Note: We already saved from first event, so only save if this is different
|
||||
if (!firstEmitted) {
|
||||
// First event wasn't emitted, so save now
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: finalEvent.pubkey,
|
||||
event: finalEvent
|
||||
}
|
||||
saveToCache(naddr, articleContent)
|
||||
}
|
||||
} 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
|
||||
setCurrentTitle(article.title)
|
||||
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)
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
// Clear highlights that don't belong to this article coordinate
|
||||
setHighlights((prev) => {
|
||||
return prev.filter(h => {
|
||||
// Keep highlights that match this article coordinate or event ID
|
||||
return h.eventReference === coord || h.eventReference === eventId
|
||||
})
|
||||
},
|
||||
settings
|
||||
)
|
||||
})
|
||||
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,
|
||||
false, // force
|
||||
eventStore || undefined
|
||||
)
|
||||
} 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,18 +515,13 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||
// This fixes the race condition where articles don't load on direct navigation
|
||||
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
settings,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
previewData,
|
||||
relayPool
|
||||
])
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@ export const useBookmarksData = ({
|
||||
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!relayPool || !activeAccount) {
|
||||
setHighlightsLoading(false)
|
||||
return
|
||||
}
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (effectiveArticleCoordinate && !externalUrl) {
|
||||
@@ -167,6 +170,9 @@ export const useBookmarksData = ({
|
||||
// Clear article highlights when not viewing an article
|
||||
setArticleHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
} else {
|
||||
// For external URLs or other cases, loading is not needed
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
|
||||
|
||||
35
src/hooks/useDocumentTitle.ts
Normal file
35
src/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
|
||||
|
||||
interface UseDocumentTitleProps {
|
||||
title?: string
|
||||
fallback?: string
|
||||
}
|
||||
|
||||
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
|
||||
const originalTitleRef = useRef<string>(document.title)
|
||||
|
||||
useEffect(() => {
|
||||
// Store the original title on first mount
|
||||
if (originalTitleRef.current === DEFAULT_TITLE) {
|
||||
originalTitleRef.current = document.title
|
||||
}
|
||||
|
||||
// Set the new title if provided, otherwise use fallback or default
|
||||
const newTitle = title || fallback || DEFAULT_TITLE
|
||||
document.title = newTitle
|
||||
|
||||
// Cleanup: restore original title when component unmounts
|
||||
return () => {
|
||||
document.title = originalTitleRef.current
|
||||
}
|
||||
}, [title, fallback])
|
||||
|
||||
// Return a function to manually reset to default
|
||||
const resetTitle = () => {
|
||||
document.title = DEFAULT_TITLE
|
||||
}
|
||||
|
||||
return { resetTitle }
|
||||
}
|
||||
140
src/hooks/useEventLoader.ts
Normal file
140
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useCallback, useState } 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'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
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) {
|
||||
// Track the current event title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
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
|
||||
}
|
||||
setCurrentTitle(title)
|
||||
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) {
|
||||
const updatedTitle = `Note by @${resolved}`
|
||||
setCurrentTitle(updatedTitle)
|
||||
setReaderContent({ ...baseContent, title: updatedTitle })
|
||||
}
|
||||
} 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'
|
||||
}
|
||||
setCurrentTitle('Error')
|
||||
setReaderContent(errorContent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useMemo } from 'react'
|
||||
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { useStoreTimeline } from './useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
// Helper to extract filename from URL
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
@@ -49,6 +50,12 @@ export function useExternalUrlLoader({
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
const mountedRef = useRef(true)
|
||||
// Track in-flight request to prevent stale updates when switching quickly
|
||||
const currentRequestIdRef = useRef(0)
|
||||
|
||||
// Track the current content title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
|
||||
// Load cached URL-specific highlights from event store
|
||||
const urlFilter = useMemo(() => {
|
||||
@@ -70,6 +77,7 @@ export function useExternalUrlLoader({
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
@@ -83,7 +91,9 @@ export function useExternalUrlLoader({
|
||||
const content = await fetchReadableContent(url)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
setCurrentTitle(content.title)
|
||||
setReaderContent(content)
|
||||
setReaderLoading(false)
|
||||
|
||||
@@ -114,6 +124,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 +142,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,19 +165,12 @@ export function useExternalUrlLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
cachedUrlHighlights,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setSelectedUrl,
|
||||
setHighlights,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setHighlightsLoading
|
||||
cachedUrlHighlights
|
||||
])
|
||||
|
||||
// Keep UI highlights synced with cached store updates without reloading content
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { normalizeUrl } from '../utils/urlHelpers'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface UseFilteredHighlightsParams {
|
||||
highlights: Highlight[]
|
||||
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
|
||||
|
||||
let urlFiltered = highlights
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
if (!selectedUrl.startsWith('nostr:')) {
|
||||
// Filter highlights based on URL type
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
// For Nostr articles, extract the article coordinate and filter by eventReference
|
||||
try {
|
||||
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
// Keep highlights that match this article coordinate
|
||||
return h.eventReference === articleCoordinate
|
||||
})
|
||||
} else {
|
||||
// Not a valid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} catch {
|
||||
// Invalid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} else {
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
|
||||
@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
|
||||
}, [])
|
||||
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
|
||||
if (!activeAccount || !relayPool || !eventStore) {
|
||||
console.error('Missing requirements for highlight creation')
|
||||
return
|
||||
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
|
||||
? currentArticle.content
|
||||
: readerContent?.markdown || readerContent?.html
|
||||
|
||||
|
||||
const newHighlight = await createHighlight(
|
||||
text,
|
||||
source,
|
||||
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
|
||||
)
|
||||
|
||||
// Highlight created successfully
|
||||
|
||||
// Clear the browser's text selection immediately to allow DOM update
|
||||
const selection = window.getSelection()
|
||||
if (selection) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useImageCache(
|
||||
imageUrl: string | undefined
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
// The Service Worker will intercept fetch requests and cache them
|
||||
// Make sure images use standard <img src> tags for SW interception
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
|
||||
void imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an image URL to ensure it's cached by the Service Worker
|
||||
* This is useful when loading content from cache - we want to ensure
|
||||
* images are cached before going offline
|
||||
*/
|
||||
export function preloadImage(imageUrl: string | undefined): void {
|
||||
if (!imageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a link element with rel=prefetch or use Image object to trigger fetch
|
||||
// Service Worker will intercept and cache the request
|
||||
const img = new Image()
|
||||
img.src = imageUrl
|
||||
|
||||
// Also try using fetch to explicitly trigger Service Worker
|
||||
// This ensures the image is cached even if <img> tag hasn't rendered yet
|
||||
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
|
||||
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
|
||||
// The Image() approach above will work for most cases
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,69 @@ 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
|
||||
|
||||
// Store callbacks in refs to avoid them being dependencies
|
||||
const onPositionChangeRef = useRef(onPositionChange)
|
||||
const onReadingCompleteRef = useRef(onReadingComplete)
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
||||
useEffect(() => {
|
||||
onPositionChangeRef.current = onPositionChange
|
||||
onReadingCompleteRef.current = onReadingComplete
|
||||
onSaveRef.current = onSave
|
||||
}, [onPositionChange, onReadingComplete, onSave])
|
||||
|
||||
// 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) {
|
||||
if (!syncEnabled || !onSaveRef.current) {
|
||||
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
|
||||
onSaveRef.current(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
|
||||
onSaveRef.current?.(positionToSave)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Always allow immediate save (including 0%)
|
||||
lastSavedPosition.current = position
|
||||
hasSavedOnce.current = true
|
||||
onSave(position)
|
||||
}, [syncEnabled, onSave, position])
|
||||
}, THROTTLE_MS)
|
||||
}, [syncEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -88,21 +93,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))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChange?.(clampedProgress)
|
||||
onPositionChangeRef.current?.(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) {
|
||||
@@ -113,7 +124,7 @@ export const useReadingPosition = ({
|
||||
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
completionTimerRef.current = null
|
||||
}, completionHoldMs)
|
||||
@@ -127,7 +138,7 @@ export const useReadingPosition = ({
|
||||
if (clampedProgress >= readingCompleteThreshold) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,23 +156,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)
|
||||
}
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
}, [enabled, 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
|
||||
@@ -173,6 +181,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'
|
||||
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
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({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAndWatch()
|
||||
// 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 })
|
||||
})
|
||||
|
||||
// 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()
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
stopNetwork()
|
||||
}
|
||||
}, [relayPool, pubkey, eventStore])
|
||||
|
||||
// Apply settings to document
|
||||
@@ -73,8 +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%')
|
||||
// Set image width and max-height based on full-width setting
|
||||
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
|
||||
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,15 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
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) {
|
||||
console.debug('[tts] defaultRate changed ->', options.defaultRate)
|
||||
setRate(options.defaultRate)
|
||||
}
|
||||
}, [options.defaultRate])
|
||||
@@ -68,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
if (!voice && v.length) {
|
||||
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||
setVoice(byLang || v[0] || null)
|
||||
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
|
||||
}
|
||||
}
|
||||
load()
|
||||
@@ -79,11 +82,21 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
}
|
||||
}, [supported, defaultLang, voice, synth])
|
||||
|
||||
const createUtterance = useCallback((text: string): SpeechSynthesisUtterance => {
|
||||
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
|
||||
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
||||
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||
u.lang = voice?.lang || defaultLang
|
||||
if (voice) u.voice = voice
|
||||
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
|
||||
@@ -92,38 +105,42 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
|
||||
u.onstart = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onstart')
|
||||
setSpeaking(true)
|
||||
setPaused(false)
|
||||
}
|
||||
u.onpause = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onpause')
|
||||
setPaused(true)
|
||||
}
|
||||
u.onresume = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onresume')
|
||||
setPaused(false)
|
||||
}
|
||||
u.onend = () => {
|
||||
if (utteranceRef.current !== self) return
|
||||
console.debug('[tts] onend')
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
// 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
|
||||
console.debug('[tts] onerror')
|
||||
setSpeaking(false)
|
||||
setPaused(false)
|
||||
utteranceRef.current = null
|
||||
}
|
||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||
if (utteranceRef.current !== self) return
|
||||
if (typeof ev.charIndex === 'number') {
|
||||
const newIndex = ev.charIndex
|
||||
const newIndex = globalOffsetRef.current + ev.charIndex
|
||||
if (newIndex > charIndexRef.current) {
|
||||
charIndexRef.current = newIndex
|
||||
}
|
||||
@@ -131,43 +148,69 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
}
|
||||
|
||||
return u
|
||||
}, [voice, defaultLang, rate, pitch, volume])
|
||||
}, [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
|
||||
console.debug('[tts] stop')
|
||||
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
|
||||
console.debug('[tts] speak', { len: text.length, rate })
|
||||
synth!.cancel()
|
||||
spokenTextRef.current = text
|
||||
charIndexRef.current = 0
|
||||
|
||||
const u = createUtterance(text)
|
||||
if (langOverride) {
|
||||
u.lang = langOverride
|
||||
// try to pick a voice that matches the override
|
||||
const available = voices
|
||||
const match = available.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
||||
if (match) u.voice = match
|
||||
}
|
||||
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
}, [supported, synth, createUtterance, rate, voices])
|
||||
langRef.current = langOverride
|
||||
startSpeakingChunks(text)
|
||||
}, [supported, synth, startSpeakingChunks])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
console.debug('[tts] pause')
|
||||
synth!.pause()
|
||||
setPaused(true)
|
||||
}
|
||||
@@ -176,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const resume = useCallback(() => {
|
||||
if (!supported) return
|
||||
if (synth!.speaking && synth!.paused) {
|
||||
console.debug('[tts] resume')
|
||||
synth!.resume()
|
||||
setPaused(false)
|
||||
}
|
||||
@@ -187,25 +229,24 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
if (!supported) return
|
||||
if (!utteranceRef.current) return
|
||||
|
||||
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
|
||||
|
||||
if (synth!.speaking && !synth!.paused) {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
|
||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
||||
synth!.cancel()
|
||||
const u = createUtterance(remainingText)
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
// 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, createUtterance])
|
||||
}, [rate, supported, synth, startSpeakingChunks])
|
||||
|
||||
const updateRate = useCallback((newRate: number) => {
|
||||
setRate(newRate)
|
||||
@@ -216,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
const fullText = spokenTextRef.current
|
||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||
const remainingText = fullText.slice(startIndex)
|
||||
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
|
||||
synth!.cancel()
|
||||
const u = createUtterance(remainingText)
|
||||
// ensure the new rate is applied immediately on the new utterance
|
||||
@@ -224,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||
utteranceRef.current = u
|
||||
synth!.speak(u)
|
||||
} else if (utteranceRef.current) {
|
||||
console.debug('[tts] updateRate -> set on utterance', { newRate })
|
||||
utteranceRef.current.rate = newRate
|
||||
}
|
||||
}, [supported, synth, createUtterance])
|
||||
|
||||
72
src/main.tsx
72
src/main.tsx
@@ -6,16 +6,59 @@ import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
// With injectRegister: null, we need to register manually
|
||||
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
const swPath = '/sw.js'
|
||||
|
||||
// Check if already registered/active first
|
||||
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
|
||||
if (registrations.length > 0) {
|
||||
return registrations[0]
|
||||
}
|
||||
|
||||
// Not registered yet, try to register
|
||||
// In dev mode, use the dev Service Worker for testing
|
||||
if (import.meta.env.DEV) {
|
||||
const devSwPath = '/sw-dev.js'
|
||||
try {
|
||||
// Check if dev SW exists
|
||||
const response = await fetch(devSwPath)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
|
||||
|
||||
if (response.ok && isJavaScript) {
|
||||
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
|
||||
} else {
|
||||
console.warn('[sw-registration] Development Service Worker not available')
|
||||
return null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sw-registration] Could not load development Service Worker:', err)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// In production, just register directly
|
||||
return await navigator.serviceWorker.register(swPath)
|
||||
}
|
||||
})
|
||||
.then(registration => {
|
||||
if (!registration) return
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
// Wait for Service Worker to activate
|
||||
if (registration.installing) {
|
||||
registration.installing.addEventListener('statechange', () => {
|
||||
// Service Worker state changed
|
||||
})
|
||||
}
|
||||
|
||||
// Check for updates periodically (production only)
|
||||
if (import.meta.env.PROD) {
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
}
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
@@ -24,8 +67,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)
|
||||
}
|
||||
@@ -34,9 +75,22 @@ if ('serviceWorker' in navigator) {
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] Error details:', {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
|
||||
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
|
||||
return `${CACHE_PREFIX}${naddr}`
|
||||
}
|
||||
|
||||
function getFromCache(naddr: string): ArticleContent | null {
|
||||
export function getFromCache(naddr: string): ArticleContent | null {
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
if (!cached) return null
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||
const age = Date.now() - timestamp
|
||||
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
|
||||
}
|
||||
|
||||
return content
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Silently handle cache read errors
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
/**
|
||||
* Caches an article event to localStorage for offline access
|
||||
* @param event - The Nostr event to cache
|
||||
* @param settings - Optional user settings
|
||||
*/
|
||||
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||
try {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (!dTag || event.kind !== 30023) return
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
const articleContent: ArticleContent = {
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
markdown: event.content,
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
summary: getArticleSummary(event),
|
||||
author: event.pubkey,
|
||||
event
|
||||
}
|
||||
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
} catch (err) {
|
||||
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||
// Future: could add explicit enableArticleCache setting
|
||||
// For now, we cache aggressively but handle errors gracefully
|
||||
// Note: settings parameter reserved for future use
|
||||
void settings // Mark as intentionally unused for now
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached: CachedArticle = {
|
||||
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
// Silently fail - don't block the UI if caching fails
|
||||
// Handles quota exceeded, invalid data, and other errors gracefully
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,10 +138,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 +155,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')
|
||||
@@ -143,7 +205,7 @@ export async function fetchArticleByNaddr(
|
||||
}
|
||||
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
saveToCache(naddr, content, settings)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -115,14 +119,14 @@ 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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -133,14 +137,14 @@ export const processApplesauceBookmarks = (
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -149,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
|
||||
@@ -184,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,
|
||||
@@ -191,7 +202,7 @@ 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 => {
|
||||
|
||||
@@ -133,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,8 +1,9 @@
|
||||
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'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -22,6 +23,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 +31,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 +48,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)
|
||||
@@ -68,11 +76,18 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 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())
|
||||
@@ -94,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
|
||||
@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { publishEvent } from './writeService'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { setHighlightMetadata } from './highlightEventProcessor'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
|
||||
// Extended event type with highlight metadata
|
||||
interface HighlightEvent extends NostrEvent {
|
||||
__highlightProps?: {
|
||||
publishedRelays?: string[]
|
||||
isLocalOnly?: boolean
|
||||
isSyncing?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
getHighlightContext,
|
||||
@@ -118,25 +127,111 @@ export async function createHighlight(
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Use unified write service to store and publish
|
||||
await publishEvent(relayPool, eventStore, signedEvent)
|
||||
// Initialize custom properties on the event (will be updated after publishing)
|
||||
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||
publishedRelays: [],
|
||||
isLocalOnly: false,
|
||||
isSyncing: false
|
||||
}
|
||||
|
||||
// Check current connection status for UI feedback
|
||||
// Get only connected relays to avoid long timeouts
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
|
||||
let isLocalOnly = false
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
||||
try {
|
||||
// Publish only to connected relays to avoid long timeouts
|
||||
if (connectedRelays.length === 0) {
|
||||
isLocalOnly = true
|
||||
} else {
|
||||
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
|
||||
}
|
||||
|
||||
// Determine which relays successfully accepted the event
|
||||
const successfulRelays = publishResponses
|
||||
.filter(response => response.ok)
|
||||
.map(response => response.from)
|
||||
|
||||
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
|
||||
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
|
||||
|
||||
// isLocalOnly is true if only local relays accepted the event
|
||||
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
|
||||
|
||||
|
||||
// Handle case when no relays were connected
|
||||
const successfulRelaysList = publishResponses.length > 0
|
||||
? publishResponses
|
||||
.filter(response => response.ok)
|
||||
.map(response => response.from)
|
||||
: []
|
||||
|
||||
// Store metadata in cache (persists across EventStore serialization)
|
||||
setHighlightMetadata(signedEvent.id, {
|
||||
publishedRelays: successfulRelaysList,
|
||||
isLocalOnly,
|
||||
isSyncing: false
|
||||
})
|
||||
|
||||
// Also update the event with the actual properties (for backwards compatibility)
|
||||
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||
publishedRelays: successfulRelaysList,
|
||||
isLocalOnly,
|
||||
isSyncing: false
|
||||
}
|
||||
|
||||
// Store the event in EventStore AFTER updating with final properties
|
||||
eventStore.add(signedEvent)
|
||||
|
||||
// Mark for offline sync if we're in local-only mode
|
||||
if (isLocalOnly) {
|
||||
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||
markEventAsOfflineCreated(signedEvent.id)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
|
||||
// If publishing fails completely, assume local-only mode
|
||||
isLocalOnly = true
|
||||
|
||||
// Store metadata in cache (persists across EventStore serialization)
|
||||
setHighlightMetadata(signedEvent.id, {
|
||||
publishedRelays: [],
|
||||
isLocalOnly: true,
|
||||
isSyncing: false
|
||||
})
|
||||
|
||||
// Also update the event with the error state (for backwards compatibility)
|
||||
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||
publishedRelays: [],
|
||||
isLocalOnly: true,
|
||||
isSyncing: false
|
||||
}
|
||||
|
||||
// Store the event in EventStore AFTER updating with final properties
|
||||
eventStore.add(signedEvent)
|
||||
|
||||
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||
markEventAsOfflineCreated(signedEvent.id)
|
||||
}
|
||||
|
||||
// Convert to Highlight with relay tracking info
|
||||
const highlight = eventToHighlight(signedEvent)
|
||||
highlight.publishedRelays = expectedSuccessRelays
|
||||
|
||||
// Manually set the properties since __highlightProps might not be working
|
||||
const finalPublishedRelays = publishResponses.length > 0
|
||||
? publishResponses
|
||||
.filter(response => response.ok)
|
||||
.map(response => response.from)
|
||||
: []
|
||||
|
||||
highlight.publishedRelays = finalPublishedRelays
|
||||
highlight.isLocalOnly = isLocalOnly
|
||||
highlight.isOfflineCreated = isLocalOnly
|
||||
highlight.isSyncing = false
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
// Extended event type with highlight metadata
|
||||
interface HighlightEvent extends NostrEvent {
|
||||
__highlightProps?: {
|
||||
publishedRelays?: string[]
|
||||
isLocalOnly?: boolean
|
||||
isSyncing?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
getHighlightContext,
|
||||
@@ -12,6 +21,66 @@ const {
|
||||
getHighlightAttributions
|
||||
} = Helpers
|
||||
|
||||
const METADATA_CACHE_KEY = 'highlightMetadataCache'
|
||||
|
||||
type HighlightMetadata = {
|
||||
publishedRelays?: string[]
|
||||
isLocalOnly?: boolean
|
||||
isSyncing?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Load highlight metadata from localStorage
|
||||
*/
|
||||
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
|
||||
try {
|
||||
const raw = localStorage.getItem(METADATA_CACHE_KEY)
|
||||
if (!raw) return new Map()
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
|
||||
return new Map(Object.entries(parsed))
|
||||
} catch {
|
||||
// Silently fail on parse errors or if storage is unavailable
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save highlight metadata to localStorage
|
||||
*/
|
||||
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
|
||||
try {
|
||||
const record = Object.fromEntries(cache.entries())
|
||||
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
|
||||
} catch {
|
||||
// Silently fail if storage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for highlight metadata that persists across EventStore serialization
|
||||
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
|
||||
*/
|
||||
const highlightMetadataCache = loadHighlightMetadataFromStorage()
|
||||
|
||||
/**
|
||||
* Store highlight metadata for an event ID
|
||||
*/
|
||||
export function setHighlightMetadata(
|
||||
eventId: string,
|
||||
metadata: HighlightMetadata
|
||||
): void {
|
||||
highlightMetadataCache.set(eventId, metadata)
|
||||
saveHighlightMetadataToStorage(highlightMetadataCache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highlight metadata for an event ID
|
||||
*/
|
||||
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
|
||||
return highlightMetadataCache.get(eventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NostrEvent to a Highlight object
|
||||
*/
|
||||
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
// Check cache first (persists across EventStore serialization)
|
||||
const cachedMetadata = getHighlightMetadata(event.id)
|
||||
|
||||
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
|
||||
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
comment,
|
||||
// Preserve custom properties if they exist
|
||||
publishedRelays: customProps.publishedRelays,
|
||||
isLocalOnly: customProps.isLocalOnly,
|
||||
isSyncing: customProps.isSyncing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
||||
{ kinds: [9802], authors: pubkeys, limit: 1000 },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Service Worker automatically caches images on fetch
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
// Must match the cache name in src/sw.ts
|
||||
const CACHE_NAME = 'boris-images'
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
|
||||
@@ -81,13 +81,21 @@ class NostrverseHighlightsController {
|
||||
const currentGeneration = this.generation
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
// Start with existing highlights when doing incremental sync
|
||||
const highlightsMap = new Map<string, Highlight>(
|
||||
this.currentHighlights.map(h => [h.id, h])
|
||||
)
|
||||
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number; limit?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
} else {
|
||||
// On initial load, fetch more highlights
|
||||
filter.limit = 1000
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -111,22 +119,24 @@ class NostrverseHighlightsController {
|
||||
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
const highlights = events.map(eventToHighlight)
|
||||
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
|
||||
const sorted = sortHighlights(unique)
|
||||
const highlights = events.map(eventToHighlight)
|
||||
// Merge new highlights with existing ones
|
||||
highlights.forEach(h => highlightsMap.set(h.id, h))
|
||||
const sorted = sortHighlights(Array.from(highlightsMap.values()))
|
||||
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const newest = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(newest)
|
||||
}
|
||||
} catch (err) {
|
||||
this.currentHighlights = []
|
||||
// On error, keep existing highlights instead of clearing them
|
||||
console.error('[nostrverse-highlights] Failed to sync:', err)
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
} finally {
|
||||
if (currentGeneration === this.generation) this.setLoading(false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
|
||||
@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
|
||||
|
||||
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
|
||||
|
||||
let isSyncing = false
|
||||
|
||||
/**
|
||||
* Load offline events from localStorage
|
||||
*/
|
||||
function loadOfflineEventsFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(OFFLINE_EVENTS_KEY)
|
||||
if (!raw) return new Set()
|
||||
|
||||
const parsed = JSON.parse(raw) as string[]
|
||||
return new Set(parsed)
|
||||
} catch {
|
||||
// Silently fail on parse errors or if storage is unavailable
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save offline events to localStorage
|
||||
*/
|
||||
function saveOfflineEventsToStorage(events: Set<string>): void {
|
||||
try {
|
||||
const array = Array.from(events)
|
||||
localStorage.setItem(OFFLINE_EVENTS_KEY, JSON.stringify(array))
|
||||
} catch {
|
||||
// Silently fail if storage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// Track events created during offline period
|
||||
const offlineCreatedEvents = new Set<string>()
|
||||
const offlineCreatedEvents = loadOfflineEventsFromStorage()
|
||||
|
||||
// Track events currently being synced
|
||||
const syncingEvents = new Set<string>()
|
||||
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
|
||||
*/
|
||||
export function markEventAsOfflineCreated(eventId: string): void {
|
||||
offlineCreatedEvents.add(eventId)
|
||||
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event was created during offline period (flight mode)
|
||||
*/
|
||||
export function isEventOfflineCreated(eventId: string): boolean {
|
||||
return offlineCreatedEvents.has(eventId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +126,7 @@ export async function syncLocalEventsToRemote(
|
||||
if (eventsToSync.length === 0) {
|
||||
isSyncing = false
|
||||
offlineCreatedEvents.clear()
|
||||
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
|
||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||
)
|
||||
|
||||
// Mark all events as syncing
|
||||
// Mark all events as syncing and update metadata
|
||||
uniqueEvents.forEach(event => {
|
||||
syncingEvents.add(event.id)
|
||||
notifySyncStateChange(event.id, true)
|
||||
|
||||
// Update metadata cache to reflect syncing state
|
||||
const existingMetadata = getHighlightMetadata(event.id)
|
||||
setHighlightMetadata(event.id, {
|
||||
...existingMetadata,
|
||||
isSyncing: true
|
||||
})
|
||||
})
|
||||
|
||||
// Publish to remote relays
|
||||
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
|
||||
syncingEvents.delete(eventId)
|
||||
offlineCreatedEvents.delete(eventId)
|
||||
notifySyncStateChange(eventId, false)
|
||||
|
||||
// Update metadata cache: sync complete, no longer local-only
|
||||
const existingMetadata = getHighlightMetadata(eventId)
|
||||
setHighlightMetadata(eventId, {
|
||||
...existingMetadata,
|
||||
isSyncing: false,
|
||||
isLocalOnly: false
|
||||
})
|
||||
})
|
||||
|
||||
// Save updated offline events set to localStorage
|
||||
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||
|
||||
// Clear syncing state for failed events
|
||||
uniqueEvents.forEach(event => {
|
||||
if (!successfulIds.includes(event.id)) {
|
||||
syncingEvents.delete(event.id)
|
||||
notifySyncStateChange(event.id, false)
|
||||
|
||||
// Update metadata cache: sync failed, still local-only
|
||||
const existingMetadata = getHighlightMetadata(event.id)
|
||||
setHighlightMetadata(event.id, {
|
||||
...existingMetadata,
|
||||
isSyncing: false
|
||||
// Keep isLocalOnly as true (sync failed)
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
114
src/services/opengraphEnhancer.ts
Normal file
114
src/services/opengraphEnhancer.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||
import { ReadItem } from './readsService'
|
||||
|
||||
// Cache for OpenGraph data to avoid repeated requests
|
||||
const ogCache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
function getCachedOgData(url: string): Record<string, unknown> | null {
|
||||
const cached = ogCache.get(url)
|
||||
if (!cached) return null
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
function setCachedOgData(url: string, data: Record<string, unknown>): void {
|
||||
ogCache.set(url, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhances a ReadItem with OpenGraph data
|
||||
* Only fetches if the item doesn't already have good metadata
|
||||
*/
|
||||
export async function enhanceReadItemWithOpenGraph(item: ReadItem): Promise<ReadItem> {
|
||||
// Skip if we already have good metadata
|
||||
if (item.title && item.title !== fallbackTitleFromUrl(item.url || '') && item.image) {
|
||||
return item
|
||||
}
|
||||
|
||||
if (!item.url) return item
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
let ogData = getCachedOgData(item.url)
|
||||
|
||||
if (!ogData) {
|
||||
// Fetch OpenGraph data
|
||||
const fetchedOgData = await fetchOpenGraph(item.url)
|
||||
if (fetchedOgData) {
|
||||
ogData = fetchedOgData
|
||||
setCachedOgData(item.url, fetchedOgData)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ogData) return item
|
||||
|
||||
// Enhance the item with OpenGraph data
|
||||
const enhanced: ReadItem = { ...item }
|
||||
|
||||
// Use OpenGraph title if we don't have a good title
|
||||
if (!enhanced.title || enhanced.title === fallbackTitleFromUrl(item.url)) {
|
||||
const ogTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
|
||||
if (typeof ogTitle === 'string') {
|
||||
enhanced.title = ogTitle
|
||||
}
|
||||
}
|
||||
|
||||
// Use OpenGraph description if we don't have a summary
|
||||
if (!enhanced.summary) {
|
||||
const ogDescription = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||
if (typeof ogDescription === 'string') {
|
||||
enhanced.summary = ogDescription
|
||||
}
|
||||
}
|
||||
|
||||
// Use OpenGraph image if we don't have an image
|
||||
if (!enhanced.image) {
|
||||
const ogImage = ogData['og:image'] || ogData['twitter:image'] || ogData.image
|
||||
if (typeof ogImage === 'string') {
|
||||
enhanced.image = ogImage
|
||||
}
|
||||
}
|
||||
|
||||
return enhanced
|
||||
} catch (error) {
|
||||
console.warn('Failed to enhance ReadItem with OpenGraph data:', error)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhances multiple ReadItems with OpenGraph data in parallel
|
||||
* Uses batching to avoid overwhelming the service
|
||||
*/
|
||||
export async function enhanceReadItemsWithOpenGraph(items: ReadItem[]): Promise<ReadItem[]> {
|
||||
const BATCH_SIZE = 5
|
||||
const BATCH_DELAY = 1000 // 1 second between batches
|
||||
|
||||
const enhancedItems: ReadItem[] = []
|
||||
|
||||
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||
const batch = items.slice(i, i + BATCH_SIZE)
|
||||
|
||||
// Process batch in parallel
|
||||
const batchPromises = batch.map(item => enhanceReadItemWithOpenGraph(item))
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
enhancedItems.push(...batchResults)
|
||||
|
||||
// Add delay between batches to be respectful to the service
|
||||
if (i + BATCH_SIZE < items.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY))
|
||||
}
|
||||
}
|
||||
|
||||
return enhancedItems
|
||||
}
|
||||
|
||||
// Helper function to generate fallback title from URL
|
||||
function fallbackTitleFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.hostname.replace('www.', '')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,10 @@ export const fetchProfiles = async (
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
|
||||
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
|
||||
// Profile images will be cached by Service Worker when they're actually displayed.
|
||||
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
await rebroadcastEvents(profiles, relayPool, settings)
|
||||
|
||||
@@ -110,3 +110,4 @@ export async function fetchReadableContent(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface ReadingProgressContent {
|
||||
progress: number // 0-1 scroll progress
|
||||
ts?: number // Unix timestamp (optional, for display)
|
||||
loc?: number // Optional: pixel position
|
||||
ver?: string // Schema version
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading progress from event (kind 39802)
|
||||
@@ -98,11 +97,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,8 +116,7 @@ export async function saveReadingPosition(
|
||||
const progressContent: ReadingProgressContent = {
|
||||
progress: position.position,
|
||||
ts: position.timestamp,
|
||||
loc: position.scrollTop,
|
||||
ver: '1'
|
||||
loc: position.scrollTop
|
||||
}
|
||||
|
||||
const tags = generateProgressTags(articleIdentifier)
|
||||
@@ -138,8 +133,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 +272,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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache'
|
||||
|
||||
/**
|
||||
* Shared reading progress controller
|
||||
@@ -276,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,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)
|
||||
|
||||
@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
|
||||
'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
|
||||
*/
|
||||
@@ -74,7 +81,15 @@ export function applyRelaySetToPool(
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
if (relay) {
|
||||
relay.close()
|
||||
try {
|
||||
// Only close if relay is actually connected or attempting to connect
|
||||
// This helps avoid WebSocket warnings for connections that never started
|
||||
relay.close()
|
||||
} catch (error) {
|
||||
// Suppress errors when closing relays that haven't fully connected yet
|
||||
// This can happen when switching relay sets before connections establish
|
||||
// Silently ignore
|
||||
}
|
||||
relayPool.relays.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface UserSettings {
|
||||
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
|
||||
@@ -75,90 +76,82 @@ export interface UserSettings {
|
||||
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(
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
.compact-read-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-type { font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
|
||||
@@ -76,6 +76,179 @@
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
|
||||
|
||||
/* Medium-sized card view */
|
||||
.individual-bookmark.card-view {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-bg-elevated);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.individual-bookmark.card-view:hover {
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card-content-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-text-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-thumbnail::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.1) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-thumbnail .thumbnail-placeholder {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-border-subtle);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-content .bookmark-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-content .bookmark-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-content .bookmark-content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-content .bookmark-content.article-summary {
|
||||
-webkit-line-clamp: 2;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 0.125rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer .bookmark-type {
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-text-secondary) !important;
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer .bookmark-type .content-type-icon {
|
||||
color: var(--color-text-secondary) !important;
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer .bookmark-date,
|
||||
.card-content .bookmark-footer .bookmark-date-link {
|
||||
display: inline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Reading progress as separator - always shown, full width */
|
||||
.reading-progress-separator {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--color-border);
|
||||
border-radius: 0.5px;
|
||||
overflow: hidden;
|
||||
margin: 0.125rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.reading-progress-separator .progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.5px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
@@ -115,6 +288,74 @@
|
||||
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
|
||||
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
||||
/* Responsive design for medium-sized cards */
|
||||
@media (max-width: 768px) {
|
||||
.individual-bookmark.card-view {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-layout {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-content-header {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.card-text-content {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-content .bookmark-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-content .bookmark-content {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-content .bookmark-footer {
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-layout {
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-content-header {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.card-thumbnail .thumbnail-placeholder {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card-content .bookmark-title {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-content .bookmark-content {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.explore-container { padding: 1rem; }
|
||||
.explore-header h1 { font-size: 2rem; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Me page tabs */
|
||||
/* My page tabs */
|
||||
.me-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -71,10 +71,22 @@
|
||||
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%; }
|
||||
|
||||
/* Hide tab labels on mobile to save space */
|
||||
@media (max-width: 768px) {
|
||||
.me-tab .tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
padding: 0.75rem;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks list */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
@@ -83,7 +95,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;
|
||||
|
||||
@@ -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 */
|
||||
@@ -55,13 +57,14 @@
|
||||
.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: var(--image-max-width, 100%);
|
||||
max-height: 70vh;
|
||||
width: var(--image-width, auto);
|
||||
max-width: 100%;
|
||||
max-height: var(--image-max-height, 70vh);
|
||||
height: auto;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
/* Headlines with Tailwind typography */
|
||||
.reader-markdown h1, .reader-html h1 {
|
||||
@@ -143,7 +146,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; }
|
||||
@@ -190,8 +193,11 @@
|
||||
}
|
||||
|
||||
.reader-markdown img, .reader-html img {
|
||||
max-width: 100%;
|
||||
width: var(--image-width, auto) !important;
|
||||
max-width: 100% !important;
|
||||
max-height: var(--image-max-height, 70vh) !important;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +236,7 @@
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -259,7 +265,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; }
|
||||
|
||||
@@ -62,6 +62,28 @@
|
||||
|
||||
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-actions-right { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* Collapse button in highlights header */
|
||||
.highlights-header .toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.highlights-header .toggle-highlights-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
|
||||
.highlights-header .toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
|
||||
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
|
||||
@@ -54,11 +54,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
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 +101,7 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
.profile-avatar-button {
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
width: 33px;
|
||||
@@ -108,10 +117,91 @@
|
||||
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; }
|
||||
|
||||
/* Profile menu wrapper */
|
||||
.profile-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.profile-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 180px;
|
||||
padding: 0.25rem;
|
||||
z-index: 1000;
|
||||
animation: profileMenuSlideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes profileMenuSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-menu-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.profile-menu-item svg {
|
||||
width: 1em;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.profile-menu-separator {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
|
||||
47
src/sw.ts
47
src/sw.ts
@@ -23,13 +23,15 @@ sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
// Runtime cache: All images (cross-origin and same-origin)
|
||||
// Cache both external images and any internal image assets
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isImage = request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
return isImage && url.origin !== sw.location.origin
|
||||
// Cache all images, not just cross-origin ones
|
||||
// This ensures article images from any source get cached
|
||||
return isImage
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-images',
|
||||
@@ -41,6 +43,11 @@ registerRoute(
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
{
|
||||
cacheWillUpdate: async ({ response }) => {
|
||||
return response.ok ? response : null
|
||||
}
|
||||
}
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -98,10 +105,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 {
|
||||
|
||||
@@ -15,11 +15,10 @@ export interface Highlight {
|
||||
comment?: string // optional comment about the highlight
|
||||
// Level classification (computed based on user's context)
|
||||
level?: HighlightLevel
|
||||
// Relay tracking for offline/local-only highlights
|
||||
// Relay tracking for local-only highlights
|
||||
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
||||
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
||||
isLocalOnly?: boolean // true if only published to local relays
|
||||
isOfflineCreated?: boolean // true if created while in flight mode (offline)
|
||||
isSyncing?: boolean // true if currently being synced to remote relays
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import { ReadItem } from '../services/readsService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||
import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer'
|
||||
|
||||
/**
|
||||
* Derives ReadItems from bookmarks for external URLs:
|
||||
* - Web bookmarks (kind:39701)
|
||||
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||
*/
|
||||
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
export async function deriveLinksFromBookmarks(bookmarks: Bookmark[]): Promise<ReadItem[]> {
|
||||
const linksMap = new Map<string, ReadItem>()
|
||||
|
||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
@@ -51,7 +52,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)
|
||||
@@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent bookmark activity
|
||||
return Array.from(linksMap.values()).sort((a, b) => {
|
||||
// Get initial items sorted by most recent bookmark activity
|
||||
const initialItems = Array.from(linksMap.values()).sort((a, b) => {
|
||||
const timeA = a.readingTimestamp || 0
|
||||
const timeB = b.readingTimestamp || 0
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// Enhance with OpenGraph data
|
||||
return await enhanceReadItemsWithOpenGraph(initialItems)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,17 +107,123 @@ export function getNostrUriLabel(encoded: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process markdown to replace nostr URIs while skipping those inside markdown links
|
||||
* This prevents nested markdown link issues when nostr identifiers appear in URLs
|
||||
*/
|
||||
function replaceNostrUrisSafely(
|
||||
markdown: string,
|
||||
getReplacement: (encoded: string) => string
|
||||
): string {
|
||||
// Track positions where we're inside a markdown link URL
|
||||
// Use a parser approach to correctly handle URLs with brackets/parentheses
|
||||
const linkRanges: Array<{ start: number, end: number }> = []
|
||||
|
||||
// Find all markdown link URLs by looking for ]( pattern and tracking to matching )
|
||||
let i = 0
|
||||
while (i < markdown.length) {
|
||||
// Look for ]( pattern that starts a markdown link URL
|
||||
const urlStartMatch = markdown.indexOf('](', i)
|
||||
if (urlStartMatch === -1) break
|
||||
|
||||
const urlStart = urlStartMatch + 2 // Position after "]("
|
||||
|
||||
// Now find the matching closing parenthesis
|
||||
// We need to account for nested parentheses and escaped characters
|
||||
let pos = urlStart
|
||||
let depth = 1 // We're inside one set of parentheses
|
||||
let urlEnd = -1
|
||||
|
||||
while (pos < markdown.length && depth > 0) {
|
||||
const char = markdown[pos]
|
||||
const nextChar = pos + 1 < markdown.length ? markdown[pos + 1] : ''
|
||||
|
||||
// Check for escaped characters
|
||||
if (char === '\\' && nextChar) {
|
||||
pos += 2 // Skip escaped character
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
depth++
|
||||
} else if (char === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
urlEnd = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pos++
|
||||
}
|
||||
|
||||
if (urlEnd !== -1) {
|
||||
linkRanges.push({
|
||||
start: urlStart,
|
||||
end: urlEnd
|
||||
})
|
||||
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
// No matching closing paren found, skip this one
|
||||
i = urlStart + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a position is inside any markdown link URL
|
||||
const isInsideLinkUrl = (pos: number): boolean => {
|
||||
return linkRanges.some(range => pos >= range.start && pos < range.end)
|
||||
}
|
||||
|
||||
// Replace nostr URIs, but skip those inside link URLs
|
||||
// Also check if nostr URI is part of any URL pattern (http/https URLs)
|
||||
// Callback params: (match, encoded, type, offset, string)
|
||||
const result = markdown.replace(NOSTR_URI_REGEX, (match, encoded, _type, offset, fullString) => {
|
||||
const matchEnd = offset + match.length
|
||||
|
||||
// Check if this match is inside a markdown link URL
|
||||
// Check both start and end positions to ensure we catch the whole match
|
||||
const startInside = isInsideLinkUrl(offset)
|
||||
const endInside = isInsideLinkUrl(matchEnd - 1) // Check end position
|
||||
|
||||
if (startInside || endInside) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// Also check if the nostr URI is part of an HTTP/HTTPS URL pattern
|
||||
// This catches cases where the source markdown has URLs like https://example.com/naddr1...
|
||||
// before they're formatted as markdown links
|
||||
const contextBefore = fullString.slice(Math.max(0, offset - 200), offset)
|
||||
const contextAfter = fullString.slice(matchEnd, Math.min(fullString.length, matchEnd + 10))
|
||||
|
||||
// Check if we're inside an http/https URL (looking for https?:// pattern before the match)
|
||||
// and the match is followed by valid URL characters (not whitespace or closing paren)
|
||||
const urlPatternBefore = /https?:\/\/[^\s)]*$/i
|
||||
const isInHttpUrl = urlPatternBefore.test(contextBefore)
|
||||
const isValidUrlContinuation = !contextAfter.match(/^[\s)]/) // Not followed by space or closing paren
|
||||
|
||||
if (isInHttpUrl && isValidUrlContinuation) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||
const replacement = getReplacement(encoded)
|
||||
return replacement
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nostr: URIs in markdown with proper markdown links
|
||||
* This converts: nostr:npub1... to [label](link)
|
||||
*/
|
||||
export function replaceNostrUrisInMarkdown(markdown: string): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
const label = getNostrUriLabel(encoded)
|
||||
|
||||
return `[${label}](${link})`
|
||||
})
|
||||
}
|
||||
@@ -132,9 +238,7 @@ export function replaceNostrUrisInMarkdownWithTitles(
|
||||
markdown: string,
|
||||
articleTitles: Map<string, string>
|
||||
): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
|
||||
// For articles, use the resolved title if available
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
try {
|
||||
@@ -15,10 +16,26 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
|
||||
}
|
||||
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
// So we don't need to filter them - they're all relevant
|
||||
// For Nostr articles, filter by article coordinate
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
return highlights
|
||||
try {
|
||||
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
|
||||
return highlights.filter(h => {
|
||||
// Keep highlights that match this article coordinate
|
||||
return h.eventReference === articleCoordinate
|
||||
})
|
||||
} else {
|
||||
// Not a valid naddr, return empty array
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
// Invalid naddr, return empty array
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
|
||||
@@ -104,7 +104,7 @@ export default defineConfig({
|
||||
filename: 'sw.ts',
|
||||
injectRegister: null,
|
||||
manifest: {
|
||||
name: 'Boris - Nostr Bookmarks',
|
||||
name: 'Boris - Read, Highlight, Explore',
|
||||
short_name: 'Boris',
|
||||
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
|
||||
start_url: '/',
|
||||
@@ -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' },
|
||||
@@ -128,7 +139,10 @@ export default defineConfig({
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
type: 'module',
|
||||
// Use generateSW strategy for dev mode to enable SW testing
|
||||
// This creates a working SW in dev mode, while injectManifest is used in production
|
||||
navigateFallback: 'index.html'
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user