mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366e10b23a | ||
|
|
bb66823915 | ||
|
|
f09973c858 | ||
|
|
d03726801d | ||
|
|
164e941a1f | ||
|
|
6def58f128 | ||
|
|
347e23ff6f | ||
|
|
934768ebf2 | ||
|
|
60e9ede9cf | ||
|
|
c70e6bc2aa | ||
|
|
ab8665815b | ||
|
|
1929b50892 | ||
|
|
160dca628d | ||
|
|
c04ba0c787 | ||
|
|
479d9314bd | ||
|
|
b9d5e501f4 | ||
|
|
43e0dd76c4 | ||
|
|
dc9a49e895 | ||
|
|
3200bdf378 | ||
|
|
2254586960 | ||
|
|
18c78c19be | ||
|
|
167d5f2041 | ||
|
|
cce7507e50 | ||
|
|
e83d4dbcdb | ||
|
|
a5bdde68fc | ||
|
|
5551cc3a55 | ||
|
|
145ff138b0 | ||
|
|
5bd5686805 | ||
|
|
d2c1a16ca6 | ||
|
|
b8242312b5 | ||
|
|
96ef227f79 | ||
|
|
30ed5fb436 | ||
|
|
42d7143845 | ||
|
|
f02bc21faf | ||
|
|
0f5d42465d | ||
|
|
004367bab6 | ||
|
|
312adea9f9 | ||
|
|
a081b26333 | ||
|
|
51e48804fe | ||
|
|
e08ce0e477 | ||
|
|
2791c69ebe | ||
|
|
96451e6173 | ||
|
|
d20cc684c3 | ||
|
|
4316c46a4d | ||
|
|
e382310c88 | ||
|
|
e6b99490dd | ||
|
|
09ee05861d | ||
|
|
205988a6b0 | ||
|
|
8012752a39 | ||
|
|
c3302da11d | ||
|
|
60e1e3c821 | ||
|
|
6c2247249a | ||
|
|
33a31df2b4 | ||
|
|
f9dda1c5d4 | ||
|
|
6522a2871c | ||
|
|
f39b926e7b | ||
|
|
144cf5cbd1 | ||
|
|
4b9de7cd07 | ||
|
|
2be58332bb | ||
|
|
6fc93cbd0f | ||
|
|
5df426a863 | ||
|
|
8ca4671bea | ||
|
|
ad1a808c6d | ||
|
|
ae118a0581 | ||
|
|
3cddcd850e | ||
|
|
cadf4dcb48 | ||
|
|
47d257faaf | ||
|
|
f542cee4cc | ||
|
|
8274eb26c2 | ||
|
|
35018fef91 | ||
|
|
1fd08bb64a | ||
|
|
d953542c93 | ||
|
|
8c0b73ad0c | ||
|
|
a5d2ed8b07 | ||
|
|
67fec91ab3 | ||
|
|
868fe68ce2 | ||
|
|
66c4bfc449 | ||
|
|
29918f78f9 | ||
|
|
18fcf6064e | ||
|
|
35766d5691 | ||
|
|
7450ba4251 | ||
|
|
95c770c083 | ||
|
|
14a7e1138e | ||
|
|
9c45c71c8a | ||
|
|
23b9224272 | ||
|
|
bcd4a12542 | ||
|
|
d82e22ce1c | ||
|
|
ea5c173745 | ||
|
|
a214c487cc | ||
|
|
43f56fc29a | ||
|
|
cfbc3efeeb | ||
|
|
bb9e98ff16 | ||
|
|
073bb3867f | ||
|
|
1ac7fb26b2 | ||
|
|
a551234a29 | ||
|
|
227f062456 | ||
|
|
6c42ee88ea | ||
|
|
fc138f3ceb | ||
|
|
831f701c04 | ||
|
|
94b9d89225 | ||
|
|
2793a6dd44 | ||
|
|
9086692e29 | ||
|
|
f8c4bbb99c | ||
|
|
b14842c6fe | ||
|
|
7cdf0673bd | ||
|
|
bbed20d679 | ||
|
|
7594d30fd2 | ||
|
|
67506d9040 | ||
|
|
e2d0bc2acf | ||
|
|
2283f4ec08 | ||
|
|
463ac8f44c | ||
|
|
e2de6f2d91 | ||
|
|
fdb52fe3b2 | ||
|
|
ae14064822 | ||
|
|
5526bfc425 | ||
|
|
b3f4b03229 | ||
|
|
b92f5716dc | ||
|
|
177f8c1e70 | ||
|
|
0407769206 | ||
|
|
eb75e7722d | ||
|
|
81aa414d2e | ||
|
|
c82fb65745 | ||
|
|
cc1b9f042f | ||
|
|
c2bf4b4a9a | ||
|
|
13a47e4fdc | ||
|
|
24b652847c | ||
|
|
c623dc8d84 | ||
|
|
31987010b8 | ||
|
|
b3206d5e79 | ||
|
|
34f44c59b5 | ||
|
|
a51fbd25d7 | ||
|
|
95f6949ab7 | ||
|
|
1e613bd2a2 | ||
|
|
95b882b0d1 | ||
|
|
be00f1434d | ||
|
|
568890e131 | ||
|
|
f000ac3be1 | ||
|
|
2fed1cc6e7 | ||
|
|
4bdcfcaeb4 | ||
|
|
a5494ba15c | ||
|
|
64aad42be3 | ||
|
|
3673849a9a | ||
|
|
c6795f7c18 | ||
|
|
b27f26b639 | ||
|
|
975399e293 | ||
|
|
53b8356373 | ||
|
|
8c5225b271 | ||
|
|
dfac7a5089 | ||
|
|
9fe09b813b | ||
|
|
ea30c136f2 | ||
|
|
623856ffe9 | ||
|
|
d08071def2 | ||
|
|
556e8f2f7d | ||
|
|
9ab6847501 | ||
|
|
31afe3792e | ||
|
|
ebe8ecf63b | ||
|
|
c418000a0c | ||
|
|
15fd19f6a4 | ||
|
|
2a44b4e3c0 | ||
|
|
aa7807e3d2 | ||
|
|
359d3d0dd6 | ||
|
|
d40b3c0048 | ||
|
|
7b4ca50b16 | ||
|
|
76e001aba4 | ||
|
|
0b42aeb383 | ||
|
|
a4554e5176 | ||
|
|
2e844fc26b | ||
|
|
8c0a4cac16 | ||
|
|
c6eccc9589 | ||
|
|
2e5536c331 | ||
|
|
fc025b9579 | ||
|
|
88db14c352 | ||
|
|
49c5f0c3ad | ||
|
|
dbed4ad253 | ||
|
|
b117b1e6cf | ||
|
|
627ffd6c5d | ||
|
|
0d53027818 | ||
|
|
811d96dee0 | ||
|
|
21335d56dc | ||
|
|
f7e50023a3 | ||
|
|
6b09212fe9 | ||
|
|
cecff6b8d5 | ||
|
|
2b061afa47 | ||
|
|
7516013e67 | ||
|
|
567641de77 | ||
|
|
4e86907663 | ||
|
|
ec34e00573 | ||
|
|
5e6c8b7516 | ||
|
|
e50af42c96 | ||
|
|
73470987be | ||
|
|
31e203825d | ||
|
|
6f9c0a35e2 | ||
|
|
96f59a54f3 | ||
|
|
87c0a0454b | ||
|
|
77c2ef1794 | ||
|
|
8d08911bd3 | ||
|
|
31b005a989 | ||
|
|
337bfe5432 | ||
|
|
2f275375f7 | ||
|
|
27cbcb56ec | ||
|
|
7f150003b5 | ||
|
|
1f50d8e1b6 | ||
|
|
f53decef16 | ||
|
|
f272943b64 | ||
|
|
49745e1b8a | ||
|
|
470f4fb34e | ||
|
|
8cde36c08c | ||
|
|
c21f96f5bb | ||
|
|
c9fef5804b | ||
|
|
8337622a22 | ||
|
|
572f0fed6f | ||
|
|
27a55ec329 | ||
|
|
7ba362a3bb | ||
|
|
dc1844907e | ||
|
|
28123b5e13 | ||
|
|
d9eb87aa5c | ||
|
|
a0ff0daf9d | ||
|
|
8c3baf1416 | ||
|
|
e0c169edbc | ||
|
|
d2181ad772 | ||
|
|
8ff3f08d8c | ||
|
|
e17e1bc824 | ||
|
|
948674ae8c | ||
|
|
431f14f56d | ||
|
|
4cc9d557a0 | ||
|
|
cc60f9584a | ||
|
|
94f1f9035b | ||
|
|
e5b1594933 | ||
|
|
2bf9b9789b | ||
|
|
d3405a4029 | ||
|
|
763f7bef4d | ||
|
|
e8e629f4e1 | ||
|
|
a0829e834f | ||
|
|
ff938aa384 | ||
|
|
3991bfeeb2 | ||
|
|
e8c35c8914 | ||
|
|
46345c154b | ||
|
|
f43dae92aa | ||
|
|
99c164a5e9 | ||
|
|
569b4357f2 | ||
|
|
de287c625b | ||
|
|
1424f6ebc5 | ||
|
|
b0a368fc64 | ||
|
|
6f8cf641b7 | ||
|
|
23b4c3475f | ||
|
|
5633dc640c | ||
|
|
0f1dfa445a | ||
|
|
ab5225de50 | ||
|
|
b89705cf43 | ||
|
|
740dd53299 | ||
|
|
eb61553c20 | ||
|
|
8b708535ca | ||
|
|
f77761c002 | ||
|
|
b900666eb8 | ||
|
|
2639c78957 | ||
|
|
8320911bc9 | ||
|
|
00d6bd4c46 | ||
|
|
cd377b6f26 |
354
CHANGELOG.md
354
CHANGELOG.md
@@ -7,6 +7,350 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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 `/me` page
|
||||||
|
- Resolved infinite update loop caused by circular dependency in `useCallback` hooks
|
||||||
|
- Tab navigation now properly updates UI when URL changes
|
||||||
|
- Removed `loadedTabs` from dependency arrays to prevent re-render cycles
|
||||||
|
- Explore page data loading patterns
|
||||||
|
- Implemented subscribe-first, non-blocking loading model
|
||||||
|
- Removed all timeouts in favor of immediate subscription and progressive hydration
|
||||||
|
- Contacts, writings, and highlights now stream results as they arrive
|
||||||
|
- Nostrverse content loads in background without blocking UI
|
||||||
|
- Text-to-speech handler cleanup
|
||||||
|
- Removed no-op self-assignment in rate change handler
|
||||||
|
|
||||||
|
## [0.10.4] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Web Share Target support for PWA (system-level share integration)
|
||||||
|
- Boris can now receive shared URLs from other apps on mobile and desktop
|
||||||
|
- Implements POST-based Web Share Target API per Chrome standards
|
||||||
|
- Service worker intercepts share requests and redirects to handler route
|
||||||
|
- ShareTargetHandler component auto-saves shared URLs as web bookmarks
|
||||||
|
- Android compatibility with URL extraction from text field when url param is missing
|
||||||
|
- Automatic navigation to bookmarks list after successful save
|
||||||
|
- Login prompt when sharing while logged out
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Manifest now includes `share_target` configuration for system share menu integration
|
||||||
|
- Service worker handles POST requests to `/share-target` endpoint
|
||||||
|
- Added `/share-target` route for processing incoming shared content
|
||||||
|
|
||||||
|
## [0.10.3] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Content filtering setting to hide articles posted by bots
|
||||||
|
- New "Hide content posted by bots" checkbox in Explore settings (enabled by default)
|
||||||
|
- Filters articles where author's profile name or display_name contains "bot" (case-insensitive)
|
||||||
|
- Applies to both Explore page and Me section writings
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Resolved all linting and type checking issues
|
||||||
|
- Added missing React Hook dependencies to `useMemo` and `useEffect`
|
||||||
|
- Wrapped loader functions in `useCallback` to prevent unnecessary re-renders
|
||||||
|
- Removed unused variables (`queryTime`, `startTime`, `allEvents`)
|
||||||
|
- All ESLint warnings and TypeScript errors now resolved
|
||||||
|
|
||||||
|
## [0.10.2] - 2025-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Text-to-speech (TTS) speaker language selection mode
|
||||||
|
- New "Speaker language" dropdown in TTS settings (system or content)
|
||||||
|
- Detects content language using tinyld for accurate voice matching
|
||||||
|
- Falls back to system language when content detection unavailable
|
||||||
|
- Top 10 languages featured in dropdown for quick access
|
||||||
|
- TTS example text section in settings
|
||||||
|
- Test TTS voices directly in the settings panel
|
||||||
|
- Uses Boris mission statement as example text
|
||||||
|
- Real-time speaker selection testing
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- TTS language selection now uses "Speaker language" terminology
|
||||||
|
- Distinguishes between American English (en-US) and British English (en-GB)
|
||||||
|
- Improved language detection with content-aware voice selection
|
||||||
|
- Streamlined dropdown for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- TTS voice detection and selection logic
|
||||||
|
- Proper empty catch block handling instead of silently failing
|
||||||
|
- Consistent use of `setting-select` class for dropdown styling
|
||||||
|
- Improved dropdown spacing with adequate padding-right
|
||||||
|
|
||||||
|
## [0.10.0] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized bookmark loading with streaming and auto-decrypt
|
||||||
|
- Bookmarks now load progressively with streaming updates
|
||||||
|
- Auto-decrypt bookmarks as they arrive from relays
|
||||||
|
- Individual decrypt buttons for encrypted bookmark events
|
||||||
|
- Centralized bookmark controller for consistent loading across the app
|
||||||
|
- Enhanced debug page with comprehensive diagnostics
|
||||||
|
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||||
|
- Live performance timing with stopwatch display
|
||||||
|
- Bookmark loading and decryption diagnostics
|
||||||
|
- Real-time bunker logs with filtering and clearing
|
||||||
|
- Version and git commit footer
|
||||||
|
- Bunker (NIP-46) authentication support
|
||||||
|
- Support for remote signing via Nostr Connect protocol
|
||||||
|
- Bunker URI input with validation and error handling
|
||||||
|
- Automatic reconnection on app restore with proper permissions
|
||||||
|
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved bookmark loading performance
|
||||||
|
- Non-blocking, progressive bookmark updates via callback pattern
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Enhanced bunker error messages
|
||||||
|
- Formatted error messages with signer suggestions
|
||||||
|
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||||
|
- Better error handling for missing signer extensions
|
||||||
|
- Centralized bookmark loading architecture
|
||||||
|
- Single shared bookmark controller for consistent loading
|
||||||
|
- Unified bookmark loading with streaming and auto-decrypt
|
||||||
|
- Consolidated bookmark loading into single centralized function
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- NIP-46 bunker signing and decryption
|
||||||
|
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||||
|
- Bunker relays added to relay pool for signing requests
|
||||||
|
- Proper setup of pool and relays before bunker reconnection
|
||||||
|
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||||
|
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||||
|
- Wait for bunker relay connections before marking signer ready
|
||||||
|
- Validate bunker URI (remote must differ from user pubkey)
|
||||||
|
- Accept remote===pubkey for Amber compatibility
|
||||||
|
- Bookmark loading and decryption
|
||||||
|
- Bookmarks load and complete properly with streaming
|
||||||
|
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||||
|
- Include decrypted private bookmarks in sidebar
|
||||||
|
- Skip background event fetching when there are too many IDs
|
||||||
|
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||||
|
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Proper timeout handling for bookmark decryption (no hanging)
|
||||||
|
- Smart encryption detection with consistent padlock display
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Add extraRelays to EventLoader and AddressLoader
|
||||||
|
- TypeScript and linting errors throughout
|
||||||
|
- Replace empty catch blocks with warnings
|
||||||
|
- Fix explicit any types
|
||||||
|
- Add missing useEffect dependencies
|
||||||
|
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking NIP-46 operations
|
||||||
|
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||||
|
- Non-blocking bookmark decryption with sequential processing
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Optimized bookmark loading
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Progressive, non-blocking bookmark loading with streaming
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Remove artificial delays from bookmark decryption
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Centralized bookmark controller architecture
|
||||||
|
- Extract bookmark streaming helpers and centralize loading
|
||||||
|
- Consolidated bookmark loading into single function
|
||||||
|
- Remove deprecated bookmark service files
|
||||||
|
- Share bookmark controller between components
|
||||||
|
- Debug page organization
|
||||||
|
- Extract VersionFooter component to eliminate duplication
|
||||||
|
- Structured sections with proper layout and styling
|
||||||
|
- Apply settings page styling structure
|
||||||
|
- Simplified bunker implementation following applesauce patterns
|
||||||
|
- Clean up bunker implementation for better maintainability
|
||||||
|
- Import RELAYS from central config (DRY principle)
|
||||||
|
- Update RELAYS list with relay.nsec.app
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Comprehensive Amber.md documentation
|
||||||
|
- Amethyst-style bookmarks section
|
||||||
|
- Bunker decrypt investigation summary
|
||||||
|
- Critical queue disabling requirement
|
||||||
|
- NIP-46 setup and troubleshooting
|
||||||
|
|
||||||
|
## [0.9.1] - 2025-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Video embedding for nostr-native content
|
||||||
|
- Detect and embed `<video>...</video>` blocks (including nested `<source>`)
|
||||||
|
- Detect and embed `<img src="…(mp4|webm|ogg|mov|avi|mkv|m4v)">` tags
|
||||||
|
- Detect and embed bare video file URLs and platform-classified video links
|
||||||
|
- Media display settings
|
||||||
|
- New "Render video links as embeds" setting (defaults to enabled)
|
||||||
|
- New "Full-width images" display option
|
||||||
|
- Dedicated "Media Display" settings section
|
||||||
|
- Article view improvements
|
||||||
|
- Center images by default in reader
|
||||||
|
- Writings list sorted by publication date (newest first)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enable media display options by default for a better out‑of‑the‑box experience
|
||||||
|
- Constrain video player to reader width to prevent horizontal overflow
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevent double video player rendering when both processor and panel attempted to embed
|
||||||
|
- Remove text artifacts and broken tags when converting markdown image/video URLs
|
||||||
|
- Improved URL regex and robust tag replacement
|
||||||
|
- Avoid injecting unknown img props from markdown renderer
|
||||||
|
- Resolved remaining ESLint and TypeScript issues
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Optimized Support page loading with instant display and skeletons
|
||||||
|
|
||||||
|
## [0.9.0] - 2025-01-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- User relay list integration (NIP-65) and blocked relays (NIP-51)
|
||||||
|
- Automatically loads user's relay list from kind 10002 events
|
||||||
|
- Supports blocked relay filtering from kind 10006 mute lists
|
||||||
|
- Integrates with existing relay pool for seamless user experience
|
||||||
|
- Relay list debug section in Debug component
|
||||||
|
- Enhanced debugging capabilities for relay list loading
|
||||||
|
- Detailed logging for relay query diagnostics
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved relay list loading performance
|
||||||
|
- Added streaming callback to relay list service for faster results
|
||||||
|
- User relay list now streams into pool immediately and finalizes after blocked relays
|
||||||
|
- Made relay list loading non-blocking in App.tsx
|
||||||
|
- Enhanced relay URL handling
|
||||||
|
- Normalized relay URLs to match applesauce-relay internal format
|
||||||
|
- Removed relay.dergigi.com from default relays
|
||||||
|
- Use user's relay list exclusively when logged in
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Resolved all linting issues across the codebase
|
||||||
|
- Fixed TypeScript type issues in relayListService
|
||||||
|
- Replaced any types with proper NostrEvent types
|
||||||
|
- Improved type safety and code quality
|
||||||
|
- Cleaned up temporary test relays from hardcoded list
|
||||||
|
- Removed non-relay console.log statements and debug output
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Enhanced relay initialization logging for better diagnostics
|
||||||
|
- Improved error handling and timeout management for relay queries
|
||||||
|
- Better separation of concerns between relay loading and application startup
|
||||||
|
|
||||||
|
## [0.8.6] - 2025-10-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- React Hooks violations in NostrMentionLink component
|
||||||
|
- Fixed useEffect dependency warnings by removing isMounted from dependencies
|
||||||
|
- Reverted to inline mount tracking with useRef for safer lifecycle handling
|
||||||
|
|
||||||
|
## [0.8.4] - 2024-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Progressive article hydration for reads tab
|
||||||
|
- Articles now load titles, summaries, images, and author information progressively
|
||||||
|
- Implemented readsController following the same pattern as bookmarkController
|
||||||
|
- Uses AddressLoader for efficient batched article event retrieval
|
||||||
|
- Articles rehydrate as data arrives from relays without blocking initial display
|
||||||
|
- Event store integration for caching article events
|
||||||
|
- Centralized reads data fetching following DRY principles
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed React type imports in useArticleLoader
|
||||||
|
- Import `Dispatch` and `SetStateAction` directly from 'react' instead of using `React.` prefix
|
||||||
|
- Resolves ESLint no-undef errors
|
||||||
|
|
||||||
|
## [0.8.3] - 2025-01-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Highlight creation now shows immediate UI feedback without page refresh
|
||||||
|
- Fixed streaming highlight merge logic to preserve newly created highlights
|
||||||
|
- Decoupled cached highlight sync from content loading to prevent unintended reloads
|
||||||
|
- Newly created highlights appear instantly in both reader and highlights panel
|
||||||
|
- Highlights remain visible while remote results stream in and merge properly
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved highlight creation user experience
|
||||||
|
- Selection clearing and synchronous rendering for immediate highlight display
|
||||||
|
- Better error handling for bunker permission issues with user-friendly messages
|
||||||
|
|
||||||
|
## [0.8.2] - 2025-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reading progress indicator in compact bookmark cards
|
||||||
|
- Shows progress bar for articles and web bookmarks with reading data
|
||||||
|
- Progress bar aligned with bookmark text for better visual association
|
||||||
|
- Color-coded progress (blue for reading, green for completed, gray for started)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Compact cards layout optimizations for more space-efficient display
|
||||||
|
- Reduced vertical padding from 0.5rem to 0.25rem
|
||||||
|
- Reduced compact row height from 28px to 24px
|
||||||
|
- Reduced gap between compact cards from 0.5rem to 0.25rem
|
||||||
|
- Reading progress bar styling for compact view
|
||||||
|
- Bar height reduced from 2px to 1px for more subtle appearance
|
||||||
|
- Left margin of 1.5rem aligns bar with bookmark text instead of appearing as separator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed borders from compact bookmarks in bookmarks sidebar
|
||||||
|
- Border styling from `.bookmarks-list` no longer applies to compact cards
|
||||||
|
- Compact cards now display as truly borderless and transparent
|
||||||
|
|
||||||
## [0.8.0] - 2025-10-19
|
## [0.8.0] - 2025-10-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -2044,7 +2388,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
|
||||||
|
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
|
||||||
|
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
|
||||||
|
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
||||||
|
[0.10.1]: https://github.com/dergigi/boris/compare/v0.10.0...v0.10.1
|
||||||
|
[0.10.0]: https://github.com/dergigi/boris/compare/v0.9.1...v0.10.0
|
||||||
|
[0.9.1]: https://github.com/dergigi/boris/compare/v0.9.0...v0.9.1
|
||||||
|
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
|
||||||
|
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
|
||||||
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
||||||
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
|
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
|
||||||
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||||
- **Progress**: Reading progress indicator with completion state.
|
- **Progress**: Reading progress indicator with completion state.
|
||||||
|
- **Text‑to‑Speech**: Listen to articles with browser‑native TTS; play/pause/stop controls with adjustable speed (0.8–1.6x).
|
||||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const RELAYS = [
|
|||||||
'wss://relay.dergigi.com',
|
'wss://relay.dergigi.com',
|
||||||
'wss://wot.dergigi.com',
|
'wss://wot.dergigi.com',
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
'wss://relay.current.fyi',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
'wss://nostr-pub.wellorder.net',
|
||||||
'wss://purplepag.es',
|
'wss://purplepag.es',
|
||||||
'wss://relay.primal.net'
|
'wss://relay.primal.net'
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -11215,6 +11216,22 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyld": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tinyld": "bin/tinyld.js",
|
||||||
|
"tinyld-heavy": "bin/tinyld-heavy.js",
|
||||||
|
"tinyld-light": "bin/tinyld-light.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.10.0",
|
||||||
|
"npm": ">= 6.12.0",
|
||||||
|
"yarn": ">= 1.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.8.2",
|
"version": "0.10.7",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
"background_color": "#0b1220",
|
"background_color": "#0b1220",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["productivity", "social", "utilities"],
|
"categories": ["productivity", "social", "utilities"],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "link"
|
||||||
|
}
|
||||||
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
|
|||||||
223
src/App.tsx
223
src/App.tsx
@@ -8,17 +8,20 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
|
|||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrConnectSigner } from 'applesauce-signers'
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Debug from './components/Debug'
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
import RouteDebug from './components/RouteDebug'
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
|
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
import { DebugBus } from './utils/debugBus'
|
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||||
|
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||||
import { Bookmark } from './types/bookmarks'
|
import { Bookmark } from './types/bookmarks'
|
||||||
import { bookmarkController } from './services/bookmarkController'
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
@@ -28,6 +31,7 @@ import { readingProgressController } from './services/readingProgressController'
|
|||||||
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
|
import { archiveController } from './services/archiveController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -91,7 +95,7 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Load bookmarks
|
// Load bookmarks
|
||||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load contacts
|
// Load contacts
|
||||||
@@ -114,6 +118,11 @@ function AppRoutes({
|
|||||||
readingProgressController.start({ relayPool, eventStore, pubkey })
|
readingProgressController.start({ relayPool, eventStore, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load archive (marked-as-read) controller
|
||||||
|
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
|
||||||
|
archiveController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
// Start centralized nostrverse highlights controller (non-blocking)
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
nostrverseHighlightsController.start({ relayPool, eventStore })
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
@@ -145,11 +154,16 @@ function AppRoutes({
|
|||||||
contactsController.reset() // Clear contacts via controller
|
contactsController.reset() // Clear contacts via controller
|
||||||
highlightsController.reset() // Clear highlights via controller
|
highlightsController.reset() // Clear highlights via controller
|
||||||
readingProgressController.reset() // Clear reading progress via controller
|
readingProgressController.reset() // Clear reading progress via controller
|
||||||
|
archiveController.reset() // Clear archive state
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/share-target"
|
||||||
|
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/a/:naddr"
|
path="/a/:naddr"
|
||||||
element={
|
element={
|
||||||
@@ -286,6 +300,18 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/writings"
|
path="/me/writings"
|
||||||
element={
|
element={
|
||||||
@@ -322,6 +348,18 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:eventId"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/debug"
|
path="/debug"
|
||||||
element={
|
element={
|
||||||
@@ -366,16 +404,9 @@ function App() {
|
|||||||
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// Fire-and-forget publish; do not block callers
|
||||||
const result: any = pool.publish(relays, event as any)
|
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
if (result && typeof (result as any).subscribe === 'function') {
|
|
||||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
// Return an already-resolved promise so upstream await finishes immediately
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,14 +429,10 @@ function App() {
|
|||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
accounts.setActive(activeId)
|
accounts.setActive(activeId)
|
||||||
} else {
|
|
||||||
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No active account ID in localStorage
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
console.error('Failed to load accounts from storage:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
// Subscribe to accounts changes and persist to localStorage
|
||||||
@@ -474,61 +501,27 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
recreatedSigner.relays = mergedRelays
|
recreatedSigner.relays = mergedRelays
|
||||||
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
} catch (err) { /* ignore */ }
|
||||||
|
|
||||||
// Replace the signer on the account
|
// Replace the signer on the account
|
||||||
nostrConnectAccount.signer = recreatedSigner
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
|
||||||
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
// Fire-and-forget publish for bunker: trigger but don't wait for completion
|
||||||
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
try {
|
|
||||||
let method: string | undefined
|
|
||||||
const content = (event as { content?: unknown })?.content
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
|
||||||
method = parsed?.method
|
|
||||||
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
|
||||||
}
|
|
||||||
const summary = {
|
|
||||||
relays,
|
|
||||||
kind: (event as { kind?: number })?.kind,
|
|
||||||
method,
|
|
||||||
// include tags array for debugging (NIP-46 expects method tag)
|
|
||||||
tags: (event as { tags?: unknown })?.tags,
|
|
||||||
contentLength: typeof content === 'string' ? content.length : undefined
|
|
||||||
}
|
|
||||||
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
|
||||||
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
|
||||||
// Fire-and-forget publish: trigger the publish but do not return the
|
|
||||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
|
||||||
const result = originalPublish(relays, event)
|
const result = originalPublish(relays, event)
|
||||||
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
|
||||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
|
||||||
// Return a benign object so callers that probe for a "subscribe" property
|
|
||||||
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
|
||||||
return {} as unknown as never
|
return {} as unknown as never
|
||||||
}
|
}
|
||||||
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
|
||||||
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
|
||||||
try {
|
|
||||||
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
|
||||||
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
|
||||||
return originalSubscribe(relays, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Just ensure the signer is listening for responses - don't call connect() again
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
// The fromBunkerURI already connected with permissions during login
|
// The fromBunkerURI already connected with permissions during login
|
||||||
if (!nostrConnectAccount.signer.listening) {
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
await nostrConnectAccount.signer.open()
|
await nostrConnectAccount.signer.open()
|
||||||
} else {
|
|
||||||
// Signer already listening
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
@@ -538,7 +531,7 @@ function App() {
|
|||||||
await nostrConnectAccount.signer.connect(undefined, permissions)
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
// Ignore reconnect errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the subscription a moment to fully establish before allowing decrypt operations
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
@@ -578,17 +571,132 @@ function App() {
|
|||||||
// Mark this account as reconnected
|
// Mark this account as reconnected
|
||||||
reconnectedAccounts.add(account.id)
|
reconnectedAccounts.add(account.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[bunker] ❌ Failed to open signer:', error)
|
console.error('Failed to open signer:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle user relay list and blocked relays when account changes
|
||||||
|
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||||
|
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
|
||||||
|
|
||||||
|
// Bunker relays (if any)
|
||||||
|
let bunkerRelays: string[] = []
|
||||||
|
if (account.type === 'nostr-connect') {
|
||||||
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
bunkerRelays = signerData.relays || []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Start with hardcoded + bunker relays immediately (non-blocking)
|
||||||
|
const initialRelays = computeRelaySet({
|
||||||
|
hardcoded: RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: [],
|
||||||
|
blocked: [],
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Apply initial set immediately
|
||||||
|
applyRelaySetToPool(pool, initialRelays)
|
||||||
|
|
||||||
|
// Prepare keep-alive helper
|
||||||
|
const updateKeepAlive = () => {
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const activeRelays = getActiveRelayUrls(pool)
|
||||||
|
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin loading blocked relays in background
|
||||||
|
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||||
|
|
||||||
|
// Stream user relay list; apply immediately on first/updated event
|
||||||
|
loadUserRelayList(pool, pubkey, {
|
||||||
|
onUpdate: (userRelays) => {
|
||||||
|
const interimRelays = computeRelaySet({
|
||||||
|
hardcoded: HARDCODED_RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: userRelays,
|
||||||
|
blocked: [],
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, interimRelays)
|
||||||
|
updateKeepAlive()
|
||||||
|
}
|
||||||
|
}).then(async (userRelayList) => {
|
||||||
|
const blockedRelays = await blockedPromise.catch(() => [])
|
||||||
|
|
||||||
|
const finalRelays = computeRelaySet({
|
||||||
|
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: userRelayList,
|
||||||
|
blocked: blockedRelays,
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, finalRelays)
|
||||||
|
|
||||||
|
updateKeepAlive()
|
||||||
|
|
||||||
|
// Update address loader with new relays
|
||||||
|
const activeRelays = getActiveRelayUrls(pool)
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: activeRelays
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
||||||
|
// Continue with initial relay set on error - no need to change anything
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// User logged out - reset to hardcoded relays
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, RELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
// Update keep-alive subscription
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
|
||||||
|
// Reset address loader
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: RELAYS
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
// Create a minimal subscription that never completes to keep connections alive
|
// Create a minimal subscription that never completes to keep connections alive
|
||||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||||
next: () => {}, // No-op, we don't care about events
|
next: () => {},
|
||||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
error: () => {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
@@ -611,6 +719,7 @@ function App() {
|
|||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
bunkerReconnectSub.unsubscribe()
|
bunkerReconnectSub.unsubscribe()
|
||||||
|
userRelaysSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,18 +6,26 @@ import { formatDistance } from 'date-fns'
|
|||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
|
import { isKnownBot } from '../config/bots'
|
||||||
|
|
||||||
interface BlogPostCardProps {
|
interface BlogPostCardProps {
|
||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
readingProgress?: number // 0-1 reading progress (optional)
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
|
hideBotByName?: boolean // default true
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
|
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||||
|
|
||||||
|
// Hide bot authors by name/display_name
|
||||||
|
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const publishedDate = post.published || post.event.created_at
|
const publishedDate = post.published || post.event.created_at
|
||||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||||
|
|||||||
@@ -145,8 +145,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
const compactProps = {
|
||||||
const { articleImage, ...compactProps } = sharedProps
|
bookmark,
|
||||||
|
index,
|
||||||
|
hasUrls,
|
||||||
|
extractedUrls,
|
||||||
|
onSelectUrl,
|
||||||
|
authorNpub,
|
||||||
|
eventNevent,
|
||||||
|
getAuthorDisplayName,
|
||||||
|
handleReadNow,
|
||||||
|
articleSummary,
|
||||||
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
|
}
|
||||||
return <CompactView {...compactProps} />
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWith
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import LoginOptions from './LoginOptions'
|
import LoginOptions from './LoginOptions'
|
||||||
@@ -125,7 +125,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
}
|
}
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull-to-refresh for bookmarks
|
// Pull-to-refresh for bookmarks
|
||||||
@@ -285,6 +285,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div className="view-mode-right">
|
<div className="view-mode-right">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRotate}
|
icon={faRotate}
|
||||||
@@ -296,13 +303,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
spin={isRefreshing}
|
spin={isRefreshing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
|
||||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
|
||||||
onClick={toggleGroupingMode}
|
|
||||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
|
||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faList}
|
icon={faList}
|
||||||
onClick={() => onViewModeChange('compact')}
|
onClick={() => onViewModeChange('compact')}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
@@ -147,19 +147,15 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isArticle && articleSummary ? (
|
{isArticle && articleSummary ? (
|
||||||
<div className="bookmark-content article-summary">
|
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||||
<ContentWithResolvedProfiles content={articleSummary} />
|
|
||||||
</div>
|
|
||||||
) : bookmark.parsedContent ? (
|
) : bookmark.parsedContent ? (
|
||||||
<div className="bookmark-content">
|
<div className="bookmark-content">
|
||||||
{shouldTruncate && bookmark.content
|
{shouldTruncate && bookmark.content
|
||||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}…`} className="" />
|
||||||
: renderParsedContent(bookmark.parsedContent)}
|
: renderParsedContent(bookmark.parsedContent)}
|
||||||
</div>
|
</div>
|
||||||
) : bookmark.content && (
|
) : bookmark.content && (
|
||||||
<div className="bookmark-content">
|
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||||
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentLength > 210 && (
|
{contentLength > 210 && (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
contentTypeIcon,
|
contentTypeIcon,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
const isNote = bookmark.kind === 1
|
||||||
|
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||||
|
|
||||||
// Calculate progress color (matching BlogPostCard logic)
|
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||||
|
|
||||||
|
// Calculate progress color
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
progressColor = '#10b981' // Green (completed)
|
progressColor = '#10b981' // Green (completed)
|
||||||
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
|
||||||
|
|
||||||
if (isArticle) {
|
if (isArticle) {
|
||||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||||
} else if (hasUrls) {
|
} 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 (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -64,9 +64,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText ? (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
@@ -95,13 +95,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
|
|
||||||
<div className="large-content">
|
<div className="large-content">
|
||||||
{isArticle && articleSummary ? (
|
{isArticle && articleSummary ? (
|
||||||
<div className="large-text article-summary">
|
<RichContent content={articleSummary} className="large-text article-summary" />
|
||||||
<ContentWithResolvedProfiles content={articleSummary} />
|
|
||||||
</div>
|
|
||||||
) : bookmark.content && (
|
) : bookmark.content && (
|
||||||
<div className="large-text">
|
<RichContent content={bookmark.content} className="large-text" />
|
||||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
onRefreshBookmarks
|
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 location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
const showMe = location.pathname.startsWith('/me')
|
const showMe = location.pathname.startsWith('/me')
|
||||||
const showProfile = location.pathname.startsWith('/p/')
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
|
const eventId = eventIdParam
|
||||||
|
|
||||||
// Extract tab from explore routes
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
@@ -64,7 +66,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
location.pathname === '/me/links' ? 'links' :
|
location.pathname.startsWith('/me/links') ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
@@ -255,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Load event if /e/:eventId route is used
|
||||||
|
useEventLoader({
|
||||||
|
eventId,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed
|
||||||
|
})
|
||||||
|
|
||||||
// Classify highlights with levels based on user context
|
// Classify highlights with levels based on user context
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
@@ -328,7 +341,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import rehypePrism from 'rehype-prism-plus'
|
import rehypePrism from 'rehype-prism-plus'
|
||||||
|
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import 'prismjs/themes/prism-tomorrow.css'
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ContentSkeleton } from './Skeletons'
|
import { ContentSkeleton } from './Skeletons'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
@@ -29,6 +30,8 @@ import {
|
|||||||
hasMarkedEventAsRead,
|
hasMarkedEventAsRead,
|
||||||
hasMarkedWebsiteAsRead
|
hasMarkedWebsiteAsRead
|
||||||
} from '../services/reactionService'
|
} from '../services/reactionService'
|
||||||
|
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||||
|
import { archiveController } from '../services/archiveController'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
@@ -43,6 +46,7 @@ import {
|
|||||||
loadReadingPosition,
|
loadReadingPosition,
|
||||||
saveReadingPosition
|
saveReadingPosition
|
||||||
} from '../services/readingPositionService'
|
} from '../services/readingPositionService'
|
||||||
|
import TTSControls from './TTSControls'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -182,14 +186,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||||
|
|
||||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
const { progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
syncEnabled: settings?.syncReadingPosition !== false,
|
syncEnabled: settings?.syncReadingPosition !== false,
|
||||||
onSave: handleSavePosition,
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Auto-mark as read when reading is complete (if enabled in settings)
|
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||||
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
|
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
|
||||||
|
if (!isMarkedAsRead) {
|
||||||
handleMarkAsRead()
|
handleMarkAsRead()
|
||||||
|
} else {
|
||||||
|
// Already archived: still show the success animation for feedback
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
setTimeout(() => setShowCheckAnimation(false), 600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -313,6 +322,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
const hasHighlights = relevantHighlights.length > 0
|
||||||
|
|
||||||
|
// Extract plain text for TTS
|
||||||
|
const baseHtml = useMemo(() => {
|
||||||
|
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
|
||||||
|
return finalHtml || html || ''
|
||||||
|
}, [markdown, renderedMarkdownHtml, finalHtml, html])
|
||||||
|
|
||||||
|
const articleText = useMemo(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (title) parts.push(title)
|
||||||
|
if (summary) parts.push(summary)
|
||||||
|
if (baseHtml) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = baseHtml
|
||||||
|
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (txt) parts.push(txt)
|
||||||
|
}
|
||||||
|
return parts.join('. ')
|
||||||
|
}, [title, summary, baseHtml])
|
||||||
|
|
||||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||||
@@ -350,7 +378,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (!currentArticle) return null
|
if (!currentArticle) return null
|
||||||
|
|
||||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const relayHints = RELAYS.filter(r =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayHints = activeRelays.filter(r =>
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
).slice(0, 3)
|
).slice(0, 3)
|
||||||
|
|
||||||
@@ -456,7 +485,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenSearch = () => {
|
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')
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
@@ -566,12 +600,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
hasRead = hasRead || archiveController.isMarked(naddr)
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore encoding errors
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hasRead = await hasMarkedWebsiteAsRead(
|
hasRead = await hasMarkedWebsiteAsRead(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const ctrl = archiveController.isMarked(selectedUrl)
|
||||||
|
hasRead = hasRead || ctrl
|
||||||
}
|
}
|
||||||
setIsMarkedAsRead(hasRead)
|
setIsMarkedAsRead(hasRead)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -585,7 +632,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
if (!activeAccount || !relayPool) return
|
||||||
|
|
||||||
|
// Toggle archive state: if already archived, request deletion; else archive
|
||||||
|
if (isMarkedAsRead) {
|
||||||
|
// Optimistically unarchive in UI; background deletion request (NIP-09)
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
// Send deletion for all matching reactions
|
||||||
|
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
|
||||||
|
// Also clear controller mark so lists update
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.unmark(naddr)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[archive][content] encode naddr failed', e)
|
||||||
|
}
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
|
||||||
|
archiveController.unmark(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] unarchive failed', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,14 +682,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
currentArticle.pubkey,
|
currentArticle.pubkey,
|
||||||
currentArticle.kind,
|
currentArticle.kind,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool,
|
||||||
|
{
|
||||||
|
aCoord: (() => {
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
return `${30023}:${currentArticle.pubkey}:${dTag}`
|
||||||
|
} catch { return undefined }
|
||||||
|
})()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
// Update archiveController immediately
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.mark(naddr)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] optimistic article mark failed', err)
|
||||||
|
}
|
||||||
} else if (selectedUrl) {
|
} else if (selectedUrl) {
|
||||||
await createWebsiteReaction(
|
await createWebsiteReaction(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
archiveController.mark(selectedUrl)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as read:', error)
|
console.error('Failed to mark as read:', error)
|
||||||
@@ -648,7 +743,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{isTextContent && (
|
{isTextContent && (
|
||||||
<ReadingProgressIndicator
|
<ReadingProgressIndicator
|
||||||
progress={progressPercentage}
|
progress={progressPercentage}
|
||||||
isComplete={isReadingComplete}
|
// Consider complete only at 95%+
|
||||||
|
isComplete={progressPercentage >= 95}
|
||||||
showPercentage={true}
|
showPercentage={true}
|
||||||
isSidebarCollapsed={isSidebarCollapsed}
|
isSidebarCollapsed={isSidebarCollapsed}
|
||||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
@@ -663,11 +759,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
components={{
|
components={{
|
||||||
img: ({ src, alt, ...props }) => (
|
img: ({ src, alt }) => (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -689,6 +784,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
highlights={relevantHighlights}
|
highlights={relevantHighlights}
|
||||||
highlightVisibility={highlightVisibility}
|
highlightVisibility={highlightVisibility}
|
||||||
/>
|
/>
|
||||||
|
{isTextContent && articleText && (
|
||||||
|
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||||
|
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isExternalVideo ? (
|
{isExternalVideo ? (
|
||||||
<>
|
<>
|
||||||
<div className="reader-video">
|
<div className="reader-video">
|
||||||
@@ -754,8 +854,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
disabled={isCheckingReadStatus}
|
||||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||||
|
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
@@ -772,10 +873,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{markdown ? (
|
{markdown ? (
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-markdown"
|
html={finalHtml}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
|
className="reader-markdown"
|
||||||
onMouseUp={handleSelectionEnd}
|
onMouseUp={handleSelectionEnd}
|
||||||
onTouchEnd={handleSelectionEnd}
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
@@ -787,10 +889,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-html"
|
html={finalHtml || html || ''}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
|
className="reader-html"
|
||||||
onMouseUp={handleSelectionEnd}
|
onMouseUp={handleSelectionEnd}
|
||||||
onTouchEnd={handleSelectionEnd}
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
@@ -917,21 +1020,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mark as Read button */}
|
{/* Archive button */}
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div className="mark-as-read-container">
|
<div className="mark-as-read-container">
|
||||||
<button
|
<button
|
||||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
disabled={isCheckingReadStatus}
|
||||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
|
||||||
|
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
spin={isCheckingReadStatus}
|
spin={isCheckingReadStatus}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useSettings } from '../hooks/useSettings'
|
|||||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { contactsController } from '../services/contactsController'
|
import { contactsController } from '../services/contactsController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
|
||||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
@@ -102,6 +103,27 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||||
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Reading Progress loading state
|
||||||
|
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
|
||||||
|
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
|
||||||
|
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Mark-as-read reactions loading state
|
||||||
|
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
|
||||||
|
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
|
||||||
|
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Relay list loading state
|
||||||
|
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
|
||||||
|
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
|
||||||
|
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Deduplicated reading progress from controller
|
||||||
|
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Live timing state
|
// Live timing state
|
||||||
const [liveTiming, setLiveTiming] = useState<{
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
@@ -109,6 +131,9 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
loadBookmarks?: { startTime: number }
|
loadBookmarks?: { startTime: number }
|
||||||
decryptBookmarks?: { startTime: number }
|
decryptBookmarks?: { startTime: number }
|
||||||
loadHighlights?: { startTime: number }
|
loadHighlights?: { startTime: number }
|
||||||
|
loadReadingProgress?: { startTime: number }
|
||||||
|
loadMarkAsRead?: { startTime: number }
|
||||||
|
loadRelayList?: { startTime: number }
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
// Web of Trust state
|
// Web of Trust state
|
||||||
@@ -409,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
|
|
||||||
const elapsed = Math.round(performance.now() - start)
|
const elapsed = Math.round(performance.now() - start)
|
||||||
setTLoadHighlights(elapsed)
|
setTLoadHighlights(elapsed)
|
||||||
setLiveTiming(prev => {
|
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
|
||||||
const { loadHighlights, ...rest } = prev
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
|
|
||||||
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -630,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
100,
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
setWritingPosts(posts)
|
setWritingPosts(posts)
|
||||||
@@ -724,6 +747,202 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
setTFirstWriting(null)
|
setTFirstWriting(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadReadingProgress = async () => {
|
||||||
|
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load reading progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingReadingProgress(true)
|
||||||
|
setReadingProgressEvents([])
|
||||||
|
setTLoadReadingProgress(null)
|
||||||
|
setTFirstReadingProgress(null)
|
||||||
|
setDeduplicatedProgressMap(new Map())
|
||||||
|
DebugBus.info('debug', 'Loading reading progress events...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { KINDS } = await import('../config/kinds')
|
||||||
|
|
||||||
|
// Load raw events for display
|
||||||
|
const rawEvents: NostrEvent[] = []
|
||||||
|
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstReadingProgress(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
rawEvents.push(evt)
|
||||||
|
setReadingProgressEvents([...rawEvents])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load deduplicated results via controller
|
||||||
|
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||||
|
setDeduplicatedProgressMap(new Map(progressMap))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run both in parallel
|
||||||
|
await Promise.all([
|
||||||
|
rawQueryPromise,
|
||||||
|
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
|
||||||
|
])
|
||||||
|
|
||||||
|
unsubProgress()
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadReadingProgress(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
|
||||||
|
|
||||||
|
const finalMap = readingProgressController.getProgressMap()
|
||||||
|
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reading progress:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingReadingProgress(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearReadingProgress = () => {
|
||||||
|
setReadingProgressEvents([])
|
||||||
|
setTLoadReadingProgress(null)
|
||||||
|
setTFirstReadingProgress(null)
|
||||||
|
setDeduplicatedProgressMap(new Map())
|
||||||
|
DebugBus.info('debug', 'Cleared reading progress data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadMarkAsReadReactions = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingMarkAsRead(true)
|
||||||
|
setMarkAsReadReactions([])
|
||||||
|
setTLoadMarkAsRead(null)
|
||||||
|
setTFirstMarkAsRead(null)
|
||||||
|
DebugBus.info('debug', 'Loading mark-as-read reactions...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
|
||||||
|
|
||||||
|
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
|
||||||
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.content === ARCHIVE_EMOJI) {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setMarkAsReadReactions(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.content === ARCHIVE_EMOJI) {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstMarkAsRead(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setMarkAsReadReactions(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalEvents = kind7Events.length + kind17Events.length
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadMarkAsRead(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load mark-as-read reactions:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMarkAsRead(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearMarkAsRead = () => {
|
||||||
|
setMarkAsReadReactions([])
|
||||||
|
setTLoadMarkAsRead(null)
|
||||||
|
setTFirstMarkAsRead(null)
|
||||||
|
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadRelayList = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load relay list')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingRelayList(true)
|
||||||
|
setRelayListEvents([])
|
||||||
|
setTLoadRelayList(null)
|
||||||
|
setTFirstRelayList(null)
|
||||||
|
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
|
||||||
|
// Query for kind:10002 (relay list)
|
||||||
|
const events = await queryEvents(relayPool, {
|
||||||
|
kinds: [10002],
|
||||||
|
authors: [activeAccount.pubkey],
|
||||||
|
limit: 10
|
||||||
|
}, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstRelayList(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setRelayListEvents(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadRelayList(elapsed)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
|
||||||
|
|
||||||
|
// Log details about the events
|
||||||
|
events.forEach((event, index) => {
|
||||||
|
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
|
||||||
|
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load relay list:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRelayList(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearRelayList = () => {
|
||||||
|
setRelayListEvents([])
|
||||||
|
setTLoadRelayList(null)
|
||||||
|
setTFirstRelayList(null)
|
||||||
|
DebugBus.info('debug', 'Cleared relay list data')
|
||||||
|
}
|
||||||
|
|
||||||
const handleLoadFriendsList = async () => {
|
const handleLoadFriendsList = async () => {
|
||||||
if (!relayPool || !activeAccount?.pubkey) {
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||||
@@ -1348,6 +1567,260 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reading Progress Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Reading Progress Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadReadingProgress}
|
||||||
|
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingReadingProgress ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Reading Progress'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearReadingProgress}
|
||||||
|
disabled={readingProgressEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadReadingProgress} />
|
||||||
|
<Stat label="first event" value={tFirstReadingProgress} />
|
||||||
|
</div>
|
||||||
|
{readingProgressEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{readingProgressEvents.map((evt, idx) => {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||||
|
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||||
|
const content = evt.content || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{dTag && <div>d-tag: {dTag}</div>}
|
||||||
|
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
|
||||||
|
{content && <div>Progress: {content}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deduplicatedProgressMap.size > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
|
||||||
|
|
||||||
|
{/* Category breakdown */}
|
||||||
|
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
|
||||||
|
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(() => {
|
||||||
|
let unopened = 0, started = 0, reading = 0, completed = 0
|
||||||
|
for (const progress of deduplicatedProgressMap.values()) {
|
||||||
|
if (progress === 0) unopened++
|
||||||
|
else if (progress > 0 && progress <= 0.10) started++
|
||||||
|
else if (progress > 0.10 && progress <= 0.94) reading++
|
||||||
|
else if (progress >= 0.95) completed++
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Unopened (0%):</span>
|
||||||
|
<span className="font-semibold">{unopened}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Started (0% < progress ≤ 10%):</span>
|
||||||
|
<span className="font-semibold">{started}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
|
||||||
|
<span>Reading (10% < progress ≤ 94%) ✓:</span>
|
||||||
|
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>Completed (≥ 95%):</span>
|
||||||
|
<span className="font-semibold">{completed}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
|
||||||
|
<div className="font-semibold mb-1">Article #{idx + 1}</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="break-all">ID: {articleId}</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
|
||||||
|
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-full"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mark-as-read Reactions Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadMarkAsReadReactions}
|
||||||
|
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingMarkAsRead ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Mark-as-read Reactions'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearMarkAsRead}
|
||||||
|
disabled={markAsReadReactions.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadMarkAsRead} />
|
||||||
|
<Stat label="first event" value={tFirstMarkAsRead} />
|
||||||
|
</div>
|
||||||
|
{markAsReadReactions.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{markAsReadReactions.map((evt, idx) => {
|
||||||
|
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||||
|
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||||
|
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Kind: {evt.kind}</div>
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div>Emoji: {evt.content}</div>
|
||||||
|
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||||
|
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
|
||||||
|
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relay List Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadRelayList}
|
||||||
|
disabled={isLoadingRelayList || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingRelayList ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Relay List'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearRelayList}
|
||||||
|
disabled={relayListEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-3 text-sm">
|
||||||
|
<Stat label="total" value={tLoadRelayList} />
|
||||||
|
<Stat label="first event" value={tFirstRelayList} />
|
||||||
|
</div>
|
||||||
|
{relayListEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{relayListEvents.map((evt, idx) => {
|
||||||
|
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Kind: {evt.kind}</div>
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
<div>Relays: {relayTags.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
|
||||||
|
{relayTags.map((tag, tagIdx) => (
|
||||||
|
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
|
||||||
|
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Web of Trust Section */}
|
{/* Web of Trust Section */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Web of Trust</h3>
|
<h3 className="section-title">Web of Trust</h3>
|
||||||
|
|||||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore, Helpers } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19, NostrEvent } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
// Contacts are managed via controller subscription
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
import { KINDS } from '../config/kinds'
|
// import { KINDS } from '../config/kinds'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
// Accessors from Helpers (currently unused here)
|
||||||
|
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -55,27 +57,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
const hasHydratedRef = useRef(false)
|
||||||
|
|
||||||
// Get myHighlights directly from controller
|
// Get myHighlights directly from controller
|
||||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||||
// Remove unused loading state to avoid warnings
|
// Remove unused loading state to avoid warnings
|
||||||
|
|
||||||
// Reading progress state (naddr -> progress 0-1)
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Load cached content from event store (instant display)
|
// Load cached content from event store (instant display)
|
||||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||||
event,
|
// event,
|
||||||
title: getArticleTitle(event) || 'Untitled',
|
// title: getArticleTitle(event) || 'Untitled',
|
||||||
summary: getArticleSummary(event),
|
// summary: getArticleSummary(event),
|
||||||
image: getArticleImage(event),
|
// image: getArticleImage(event),
|
||||||
published: getArticlePublished(event),
|
// published: getArticlePublished(event),
|
||||||
author: event.pubkey
|
// author: event.pubkey
|
||||||
}), [])
|
// }), [])
|
||||||
|
|
||||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +108,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts stream and mirror into local state
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ensure contacts controller is started for the active account (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && activeAccount?.pubkey) {
|
||||||
|
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [relayPool, activeAccount?.pubkey])
|
||||||
|
|
||||||
// Subscribe to nostrverse highlights controller for global stream
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const apply = (incoming: Highlight[]) => {
|
const apply = (incoming: Highlight[]) => {
|
||||||
@@ -230,242 +248,95 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
useEffect(() => {
|
// Load initial data and refresh on triggers
|
||||||
const loadData = async () => {
|
const loadData = useCallback(() => {
|
||||||
try {
|
if (!relayPool) return
|
||||||
// begin load, but do not block rendering
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
// If not logged in, only fetch nostrverse content with streaming posts
|
// Seed from cache for instant UI
|
||||||
if (!activeAccount) {
|
if (activeAccount) {
|
||||||
// Logged out: rely entirely on centralized controllers; do not fetch here
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
setLoading(false)
|
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||||
}
|
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cached && cached.length > 0) setHighlights(cached)
|
||||||
|
}
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
setLoading(true)
|
||||||
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
|
||||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
|
||||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
|
||||||
}
|
|
||||||
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
|
||||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
|
||||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed with cached content from event store (instant display)
|
|
||||||
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
|
||||||
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
|
||||||
setHighlights(prev => {
|
|
||||||
const all = dedupeHighlightsById([...prev, ...merged])
|
|
||||||
return all.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed with cached writings from event store
|
|
||||||
if (cachedWritings.length > 0) {
|
|
||||||
setBlogPosts(prev => {
|
|
||||||
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
|
||||||
return all.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, we have seeded any available data; lift the loading state
|
try {
|
||||||
setLoading(false)
|
// Prepare parallel fetches
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||||
const contacts = await fetchContacts(
|
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount?.pubkey || '',
|
relayUrls,
|
||||||
(partial) => {
|
50,
|
||||||
// Store followed pubkeys for highlight classification
|
eventStore || undefined,
|
||||||
setFollowedPubkeys(partial)
|
(post) => {
|
||||||
// When local contacts are available, kick off early fetch
|
|
||||||
if (partial.size > 0) {
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const partialArray = Array.from(partial)
|
|
||||||
|
|
||||||
// Fetch blog posts
|
|
||||||
fetchBlogPostsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
relayUrls,
|
|
||||||
(post) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
// Deduplicate by author:d-tag (replaceable event key)
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const key = `${post.author}:${dTag}`
|
|
||||||
const existingIndex = prev.findIndex(p => {
|
|
||||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
return `${p.author}:${pDTag}` === key
|
|
||||||
})
|
|
||||||
|
|
||||||
// If exists, only replace if this one is newer
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const existing = prev[existingIndex]
|
|
||||||
if (post.event.created_at <= existing.event.created_at) {
|
|
||||||
return prev // Keep existing (newer or same)
|
|
||||||
}
|
|
||||||
// Replace with newer version
|
|
||||||
const next = [...prev]
|
|
||||||
next[existingIndex] = post
|
|
||||||
return next.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// New post, add it
|
|
||||||
const next = [...prev, post]
|
|
||||||
return next.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
// Deduplicate by author:d-tag (replaceable event key)
|
|
||||||
const byKey = new Map<string, BlogPostPreview>()
|
|
||||||
|
|
||||||
// Add existing posts
|
|
||||||
for (const p of prev) {
|
|
||||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const key = `${p.author}:${dTag}`
|
|
||||||
byKey.set(key, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge in new posts (keeping newer versions)
|
|
||||||
for (const post of all) {
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const key = `${post.author}:${dTag}`
|
|
||||||
const existing = byKey.get(key)
|
|
||||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
|
||||||
byKey.set(key, post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = Array.from(byKey.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch highlights
|
|
||||||
fetchHighlightsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
(highlight) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const exists = prev.some(h => h.id === highlight.id)
|
|
||||||
if (exists) return prev
|
|
||||||
const next = [...prev, highlight]
|
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
})
|
|
||||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const byId = new Map(prev.map(h => [h.id, h]))
|
|
||||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Always proceed to load nostrverse content even if no contacts
|
|
||||||
// (removed blocking error for empty contacts)
|
|
||||||
|
|
||||||
// Store final followed pubkeys
|
|
||||||
setFollowedPubkeys(contacts)
|
|
||||||
|
|
||||||
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const contactsArray = Array.from(contacts)
|
|
||||||
// Use centralized writingsController for my posts (non-blocking)
|
|
||||||
// pull from writingsController; no need to store promise
|
|
||||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
|
||||||
setHasLoadedMine(true)
|
|
||||||
const nostrversePostsPromise = visibility.nostrverse
|
|
||||||
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
|
||||||
// Stream nostrverse posts too when logged in
|
|
||||||
setBlogPosts(prev => {
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const key = `${post.author}:${dTag}`
|
|
||||||
const existingIndex = prev.findIndex(p => {
|
|
||||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
return `${p.author}:${pDTag}` === key
|
|
||||||
})
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const existing = prev[existingIndex]
|
|
||||||
if (post.event.created_at <= existing.event.created_at) return prev
|
|
||||||
const next = [...prev]
|
|
||||||
next[existingIndex] = post
|
|
||||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
|
||||||
}
|
|
||||||
const next = [...prev, post]
|
|
||||||
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
: Promise.resolve([] as BlogPostPreview[])
|
|
||||||
|
|
||||||
// Fire non-blocking fetches and merge as they resolve
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
|
||||||
.then((friendsPosts) => {
|
|
||||||
setBlogPosts(prev => {
|
setBlogPosts(prev => {
|
||||||
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
// Pre-cache profiles in background
|
|
||||||
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
|
||||||
return sorted
|
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
).then((nostrversePosts) => {
|
||||||
.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) => {
|
|
||||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
}).catch(() => {})
|
}).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) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
// loading is already turned off after seeding
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}
|
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadData, refreshTrigger])
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
|
||||||
|
// 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)
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -509,7 +380,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((nostriverseHighlights) => {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
|
||||||
|
|
||||||
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -586,6 +462,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const publishedTime = post.published || post.event.created_at
|
const publishedTime = post.published || post.event.created_at
|
||||||
if (publishedTime > maxFutureTime) return false
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Hide bot authors by profile display name if setting enabled
|
||||||
|
if (settings?.hideBotArticlesByName !== false) {
|
||||||
|
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||||
|
// Keep list intact here; individual cards will render null if author is a bot
|
||||||
|
}
|
||||||
|
|
||||||
// Apply visibility filters
|
// Apply visibility filters
|
||||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
const isFriend = followedPubkeys.has(post.author)
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
@@ -604,7 +486,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||||
|
|
||||||
// Helper to get reading progress for a post
|
// Helper to get reading progress for a post
|
||||||
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
@@ -653,6 +535,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
readingProgress={getReadingProgress(post)}
|
readingProgress={getReadingProgress(post)}
|
||||||
|
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (!highlight.eventReference) return
|
if (!highlight.eventReference) return
|
||||||
|
|
||||||
|
// Skip if it's a raw event ID (hex string without colons)
|
||||||
|
// Raw event IDs cannot be decoded to nadrs without additional context
|
||||||
|
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert eventReference to naddr if needed
|
// Convert eventReference to naddr if needed
|
||||||
let naddr: string
|
let naddr: string
|
||||||
if (highlight.eventReference.includes(':')) {
|
if (highlight.eventReference.includes(':')) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal } from '../utils/helpers'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
import { createDeletionRequest } from '../services/deletionService'
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
@@ -17,6 +17,7 @@ import { getNostrUrl } from '../config/nostrGateways'
|
|||||||
import CompactButton from './CompactButton'
|
import CompactButton from './CompactButton'
|
||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -29,99 +30,6 @@ const isImageUrl = (url: string): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render a nostr identifier
|
|
||||||
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
|
||||||
try {
|
|
||||||
// Remove nostr: prefix
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
const decoded = nip19.decode(identifier)
|
|
||||||
|
|
||||||
switch (decoded.type) {
|
|
||||||
case 'npub': {
|
|
||||||
const pubkey = decoded.data
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${nip19.npubEncode(pubkey)}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nprofile': {
|
|
||||||
const { pubkey } = decoded.data
|
|
||||||
const npub = nip19.npubEncode(pubkey)
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${npub}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'naddr': {
|
|
||||||
const { kind, pubkey, identifier } = decoded.data
|
|
||||||
// Check if it's a blog post (kind:30023)
|
|
||||||
if (kind === 30023) {
|
|
||||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/a/${naddr}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{identifier || 'Article'}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// For other kinds, show shortened identifier
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
nostr:{identifier.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'note': {
|
|
||||||
const eventId = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
note:{eventId.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nevent': {
|
|
||||||
const { id } = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
event:{id.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Fallback for unrecognized types
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If decoding fails, show shortened identifier
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component to render comment with links, inline images, and nostr identifiers
|
// Component to render comment with links, inline images, and nostr identifiers
|
||||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||||
// Pattern to match both http(s) URLs and nostr: URIs
|
// Pattern to match both http(s) URLs and nostr: URIs
|
||||||
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
// Handle nostr: URIs
|
// Handle nostr: URIs - now with profile resolution
|
||||||
if (part.startsWith('nostr:')) {
|
if (part.startsWith('nostr:')) {
|
||||||
return renderNostrId(part, index)
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle http(s) URLs
|
// Handle http(s) URLs
|
||||||
@@ -236,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setShowOfflineIndicator(false)
|
setShowOfflineIndicator(false)
|
||||||
|
|
||||||
// Update the highlight with all relays after successful sync
|
// Update the highlight with all relays after successful sync
|
||||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||||
const updatedHighlight = {
|
const updatedHighlight = {
|
||||||
...highlight,
|
...highlight,
|
||||||
publishedRelays: RELAYS,
|
publishedRelays: getActiveRelayUrls(relayPool),
|
||||||
isLocalOnly: false,
|
isLocalOnly: false,
|
||||||
isOfflineCreated: false
|
isOfflineCreated: false
|
||||||
}
|
}
|
||||||
@@ -250,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [highlight, onHighlightUpdate])
|
}, [highlight, onHighlightUpdate, relayPool])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && itemRef.current) {
|
if (isSelected && itemRef.current) {
|
||||||
@@ -310,7 +224,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const getHighlightLinks = () => {
|
const getHighlightLinks = () => {
|
||||||
// Encode the highlight event itself (kind 9802) as a nevent
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
// Get non-local relays for the hint
|
// Get non-local relays for the hint
|
||||||
const relayHints = RELAYS.filter(r =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayHints = activeRelays.filter(r =>
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
).slice(0, 3) // Include up to 3 relay hints
|
||||||
|
|
||||||
@@ -346,7 +261,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Publish to all configured relays - let the relay pool handle connection state
|
||||||
const targetRelays = RELAYS
|
const targetRelays = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
|
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
@@ -414,7 +329,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: show all relays we queried (where this was likely fetched from)
|
// Fallback: show all relays we queried (where this was likely fetched from)
|
||||||
const relayNames = RELAYS.map(url =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayNames = activeRelays.map(url =>
|
||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
|
|||||||
<div className="login-content">
|
<div className="login-content">
|
||||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||||
<p className="login-description">
|
<p className="login-description">
|
||||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="login-buttons">
|
<div className="login-buttons">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
@@ -11,8 +11,8 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
|
||||||
import { fetchLinks } from '../services/linksService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
|
import { ReadItem, readsController } from '../services/readsController'
|
||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
@@ -23,15 +23,15 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
|||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
|
||||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
|
||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { archiveController } from '../services/archiveController'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -39,29 +39,30 @@ interface MeProps {
|
|||||||
activeTab?: TabType
|
activeTab?: TabType
|
||||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||||
|
settings: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
// Valid reading progress filters
|
// Valid reading progress filters
|
||||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({
|
const Me: React.FC<MeProps> = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
activeTab: propActiveTab,
|
activeTab: propActiveTab,
|
||||||
bookmarks
|
bookmarks,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const activeTab = propActiveTab || 'highlights'
|
||||||
|
|
||||||
// Only for own profile
|
// Only for own profile
|
||||||
const viewingPubkey = activeAccount?.pubkey
|
const viewingPubkey = activeAccount?.pubkey
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
|
||||||
const [links, setLinks] = useState<ReadItem[]>([])
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
@@ -90,8 +91,10 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize reading progress filter from URL param
|
// Initialize reading progress filter from URL param
|
||||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
// Backward compat: map legacy 'emoji' route to 'archive'
|
||||||
? (urlFilter as ReadingProgressFilterType)
|
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||||
|
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
|
||||||
|
? (normalizedUrlFilter as ReadingProgressFilterType)
|
||||||
: 'all'
|
: 'all'
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
@@ -126,17 +129,11 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (propActiveTab) {
|
|
||||||
setActiveTab(propActiveTab)
|
|
||||||
}
|
|
||||||
}, [propActiveTab])
|
|
||||||
|
|
||||||
// Sync filter state with URL changes
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
|
||||||
? (urlFilter as ReadingProgressFilterType)
|
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
|
||||||
|
? (normalized as ReadingProgressFilterType)
|
||||||
: 'all'
|
: 'all'
|
||||||
setReadingProgressFilter(filterFromUrl)
|
setReadingProgressFilter(filterFromUrl)
|
||||||
}, [urlFilter])
|
}, [urlFilter])
|
||||||
@@ -150,10 +147,29 @@ const Me: React.FC<MeProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
navigate(`/me/reads/${filter}`, { replace: true })
|
navigate(`/me/reads/${filter}`, { replace: true })
|
||||||
}
|
}
|
||||||
|
} else if (activeTab === 'links') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/me/links', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/me/links/${filter}`, { replace: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reading progress controller
|
// Subscribe to reads controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setReads(readsController.getReads())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubReads = readsController.onReads(setReads)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubReads()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to reading progress map for writings and links enrichment
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get initial state immediately
|
// Get initial state immediately
|
||||||
setReadingProgressMap(readingProgressController.getProgressMap())
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
@@ -165,6 +181,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
unsubProgress()
|
unsubProgress()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
// Load reading progress data for writings tab
|
// Load reading progress data for writings tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,15 +198,15 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Tab-specific loading functions
|
// Tab-specific loading functions
|
||||||
const loadHighlightsTab = async () => {
|
const loadHighlightsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Highlights come from controller subscription (sync effect handles it)
|
// Highlights come from controller subscription (sync effect handles it)
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}, [viewingPubkey])
|
||||||
|
|
||||||
const loadWritingsTab = async () => {
|
const loadWritingsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -206,70 +223,57 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load writings:', err)
|
console.error('Failed to load writings:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
const loadReadingListTab = async () => {
|
const loadReadingListTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
setLoadedTabs(prev => {
|
||||||
|
const hasBeenLoaded = prev.has('reading-list')
|
||||||
try {
|
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
// Bookmarks come from centralized loading in App.tsx
|
return new Set(prev).add('reading-list')
|
||||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
})
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load reading list:', err)
|
// Always turn off loading after a tick
|
||||||
} finally {
|
setTimeout(() => setLoading(false), 0)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
}, [viewingPubkey, activeAccount])
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadReadsTab = async () => {
|
const loadReadsTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('reads')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('reads')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
// Use readsController to get reads with progressive hydration
|
||||||
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
await readsController.start({
|
||||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
relayPool,
|
||||||
setReadsMap(initialMap)
|
eventStore,
|
||||||
setReads(initialReads)
|
pubkey: viewingPubkey
|
||||||
|
})
|
||||||
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
// Background enrichment: merge reading progress and mark-as-read
|
|
||||||
// Only update items that are already in our map
|
|
||||||
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
|
||||||
setReadsMap(prevMap => {
|
|
||||||
// Only update if item exists in our current map
|
|
||||||
if (!prevMap.has(item.id)) {
|
|
||||||
return prevMap
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMap = new Map(prevMap)
|
|
||||||
const merged = mergeReadItem(newMap, item)
|
|
||||||
if (merged) {
|
|
||||||
// Update reads array after map is updated
|
|
||||||
setReads(Array.from(newMap.values()))
|
|
||||||
return newMap
|
|
||||||
}
|
|
||||||
return prevMap
|
|
||||||
})
|
|
||||||
}).catch(err => console.warn('Failed to enrich reads:', err))
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load reads:', err)
|
console.error('Failed to load reads:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
const loadLinksTab = async () => {
|
const loadLinksTab = useCallback(async () => {
|
||||||
if (!viewingPubkey || !activeAccount) return
|
if (!viewingPubkey || !activeAccount) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('links')
|
let hasBeenLoaded = false
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
hasBeenLoaded = prev.has('links')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
@@ -290,12 +294,13 @@ const Me: React.FC<MeProps> = ({
|
|||||||
if (!prevMap.has(item.id)) return prevMap
|
if (!prevMap.has(item.id)) return prevMap
|
||||||
|
|
||||||
const newMap = new Map(prevMap)
|
const newMap = new Map(prevMap)
|
||||||
if (mergeReadItem(newMap, item)) {
|
if (item.type === 'article' && item.author) {
|
||||||
// Update links array after map is updated
|
const progress = readingProgressMap.get(item.id)
|
||||||
setLinks(Array.from(newMap.values()))
|
if (progress !== undefined) {
|
||||||
return newMap
|
newMap.set(item.id, { ...item, readingProgress: progress })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prevMap
|
return newMap
|
||||||
})
|
})
|
||||||
}).catch(err => console.warn('Failed to enrich links:', err))
|
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||||
|
|
||||||
@@ -303,10 +308,10 @@ const Me: React.FC<MeProps> = ({
|
|||||||
console.error('Failed to load links:', err)
|
console.error('Failed to load links:', err)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
|
||||||
|
|
||||||
// Load active tab data
|
// Load active tab data
|
||||||
useEffect(() => {
|
const loadActiveTab = useCallback(() => {
|
||||||
if (!viewingPubkey || !activeTab) {
|
if (!viewingPubkey || !activeTab) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -339,8 +344,11 @@ const Me: React.FC<MeProps> = ({
|
|||||||
loadLinksTab()
|
loadLinksTab()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
|
||||||
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadActiveTab()
|
||||||
|
}, [loadActiveTab])
|
||||||
|
|
||||||
// Sync myHighlights from controller
|
// Sync myHighlights from controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -485,23 +493,14 @@ const Me: React.FC<MeProps> = ({
|
|||||||
// Merge and flatten all individual bookmarks
|
// Merge and flatten all individual bookmarks
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContent)
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
// Apply bookmark filter
|
// Apply bookmark filter
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Enrich reads and links with reading progress from controller
|
// Enrich links with reading progress (reads already have progress from controller)
|
||||||
const readsWithProgress = reads.map(item => {
|
|
||||||
if (item.type === 'article' && item.author) {
|
|
||||||
const progress = readingProgressMap.get(item.id)
|
|
||||||
if (progress !== undefined) {
|
|
||||||
return { ...item, readingProgress: progress }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
|
|
||||||
const linksWithProgress = links.map(item => {
|
const linksWithProgress = links.map(item => {
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
const progress = readingProgressMap.get(item.url)
|
const progress = readingProgressMap.get(item.url)
|
||||||
@@ -512,9 +511,60 @@ const Me: React.FC<MeProps> = ({
|
|||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply reading progress filter
|
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
|
||||||
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
|
const filteredReads = filterByReadingProgress(
|
||||||
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
|
reads.filter(item => item.type === 'article'),
|
||||||
|
readingProgressFilter,
|
||||||
|
highlights
|
||||||
|
)
|
||||||
|
const filteredLinks = filterByReadingProgress(
|
||||||
|
linksWithProgress.filter(item => item.type === 'external'),
|
||||||
|
readingProgressFilter,
|
||||||
|
highlights
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper: build archive-only list from marked IDs and a base list
|
||||||
|
const buildArchiveOnly = (
|
||||||
|
baseItems: ReadItem[],
|
||||||
|
options: { kind: 'article' | 'external' }
|
||||||
|
): ReadItem[] => {
|
||||||
|
const allMarked = archiveController.getMarkedIds()
|
||||||
|
const relevantMarked = options.kind === 'article'
|
||||||
|
? allMarked.filter(id => id.startsWith('naddr1'))
|
||||||
|
: allMarked.filter(id => !id.startsWith('naddr1'))
|
||||||
|
const markedSet = new Set(relevantMarked)
|
||||||
|
|
||||||
|
const items: ReadItem[] = []
|
||||||
|
for (const item of baseItems) {
|
||||||
|
const key = options.kind === 'article' ? item.id : (item.url || item.id)
|
||||||
|
if (key && markedSet.has(key)) {
|
||||||
|
items.push({ ...item, markedAsRead: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of markedSet) {
|
||||||
|
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
|
||||||
|
if (!exists) {
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: options.kind,
|
||||||
|
url: options.kind === 'article' ? undefined : id,
|
||||||
|
markedAsRead: true,
|
||||||
|
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive-only lists: independent of reading progress
|
||||||
|
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
|
||||||
|
? buildArchiveOnly(reads, { kind: 'article' })
|
||||||
|
: []
|
||||||
|
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
|
||||||
|
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
|
||||||
|
: []
|
||||||
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
groupingMode === 'flat'
|
groupingMode === 'flat'
|
||||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||||
@@ -652,21 +702,42 @@ const Me: React.FC<MeProps> = ({
|
|||||||
selectedFilter={readingProgressFilter}
|
selectedFilter={readingProgressFilter}
|
||||||
onFilterChange={handleReadingProgressFilterChange}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
/>
|
/>
|
||||||
{filteredReads.length === 0 ? (
|
{readingProgressFilter === 'archive' ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
archiveOnlyReads.length === 0 ? (
|
||||||
No articles match this filter.
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
</div>
|
No articles in archive.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{archiveOnlyReads
|
||||||
|
.filter(item => item.type === 'article')
|
||||||
|
.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
filteredReads.length === 0 ? (
|
||||||
{filteredReads.map((item) => (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<BlogPostCard
|
No articles match this filter.
|
||||||
key={item.id}
|
</div>
|
||||||
post={convertReadItemToBlogPostPreview(item)}
|
) : (
|
||||||
href={getReadItemUrl(item)}
|
<div className="explore-grid">
|
||||||
readingProgress={item.readingProgress}
|
{filteredReads.map((item) => (
|
||||||
/>
|
<BlogPostCard
|
||||||
))}
|
key={item.id}
|
||||||
</div>
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -699,21 +770,40 @@ const Me: React.FC<MeProps> = ({
|
|||||||
selectedFilter={readingProgressFilter}
|
selectedFilter={readingProgressFilter}
|
||||||
onFilterChange={handleReadingProgressFilterChange}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
/>
|
/>
|
||||||
{filteredLinks.length === 0 ? (
|
{readingProgressFilter === 'archive' ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
archiveOnlyLinks.length === 0 ? (
|
||||||
No links match this filter.
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
</div>
|
No links in archive.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{archiveOnlyLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
filteredLinks.length === 0 ? (
|
||||||
{filteredLinks.map((item) => (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<BlogPostCard
|
No links match this filter.
|
||||||
key={item.id}
|
</div>
|
||||||
post={convertReadItemToBlogPostPreview(item)}
|
) : (
|
||||||
href={getReadItemUrl(item)}
|
<div className="explore-grid">
|
||||||
readingProgress={item.readingProgress}
|
{filteredLinks.map((item) => (
|
||||||
/>
|
<BlogPostCard
|
||||||
))}
|
key={item.id}
|
||||||
</div>
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -740,6 +830,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
readingProgress={getWritingReadingProgress(post)}
|
readingProgress={getWritingReadingProgress(post)}
|
||||||
|
hideBotByName={settings.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
134
src/components/NostrMentionLink.tsx
Normal file
134
src/components/NostrMentionLink.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
|
||||||
|
interface NostrMentionLinkProps {
|
||||||
|
nostrUri: string
|
||||||
|
onClick?: (e: React.MouseEvent) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render nostr mentions with resolved profile names
|
||||||
|
* Handles npub, nprofile, note, nevent, and naddr URIs
|
||||||
|
*/
|
||||||
|
const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
|
||||||
|
nostrUri,
|
||||||
|
onClick,
|
||||||
|
className = 'highlight-comment-link'
|
||||||
|
}) => {
|
||||||
|
// Decode the nostr URI first
|
||||||
|
let decoded: ReturnType<typeof nip19.decode> | null = null
|
||||||
|
let pubkey: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
|
// Extract pubkey for profile fetching (works for npub and nprofile)
|
||||||
|
if (decoded.type === 'npub') {
|
||||||
|
pubkey = decoded.data
|
||||||
|
} else if (decoded.type === 'nprofile') {
|
||||||
|
pubkey = decoded.data.pubkey
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Decoding failed, will fallback to shortened identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile at top level (Rules of Hooks)
|
||||||
|
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
|
||||||
|
// If decoding failed, show shortened identifier
|
||||||
|
if (!decoded) {
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render based on decoded type
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub': {
|
||||||
|
const pk = decoded.data
|
||||||
|
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${nip19.npubEncode(pk)}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
const { pubkey: pk } = decoded.data
|
||||||
|
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
||||||
|
const npub = nip19.npubEncode(pk)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
|
||||||
|
// Check if it's a blog post (kind:30023)
|
||||||
|
if (kind === 30023) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey: pk, identifier: addrIdentifier })
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/a/${naddr}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{addrIdentifier || 'Article'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// For other kinds, show shortened identifier
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
nostr:{addrIdentifier.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
const eventId = decoded.data
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
note:{eventId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
const { id } = decoded.data
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
event:{id.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Fallback for unrecognized types
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NostrMentionLink
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
@@ -8,8 +8,8 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
@@ -57,6 +57,15 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
[pubkey]
|
[pubkey]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sort writings by publication date, newest first
|
||||||
|
const sortedWritings = useMemo(() => {
|
||||||
|
return cachedWritings.slice().sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [cachedWritings])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -98,24 +107,14 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey || !relayPool || !eventStore) return
|
if (!pubkey || !relayPool || !eventStore) return
|
||||||
|
|
||||||
|
// Fetch all highlights and writings in background (no limits)
|
||||||
|
const relayUrls = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
// Fetch highlights in background
|
|
||||||
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||||
.then(() => {
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||||
// 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], relayUrls, undefined, null, eventStore)
|
||||||
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||||
.then(writings => {
|
|
||||||
writings.forEach(w => eventStore.add(w.event))
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
|
|
||||||
})
|
|
||||||
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
@@ -168,7 +167,7 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey)
|
const npub = nip19.npubEncode(pubkey)
|
||||||
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -209,13 +208,13 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return cachedWritings.length === 0 ? (
|
return sortedWritings.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles written yet.
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{cachedWritings.map((post) => (
|
{sortedWritings.map((post) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={post.event.id}
|
||||||
post={post}
|
post={post}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
|
||||||
|
|
||||||
interface ReadingProgressFiltersProps {
|
interface ReadingProgressFiltersProps {
|
||||||
selectedFilter: ReadingProgressFilterType
|
selectedFilter: ReadingProgressFilterType
|
||||||
@@ -13,11 +14,13 @@ interface ReadingProgressFiltersProps {
|
|||||||
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
const filters = [
|
const filters = [
|
||||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||||
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
// Archive-marked items (previously emoji-marked)
|
||||||
|
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +34,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
|
|||||||
activeStyle = { color: '#10b981' } // green
|
activeStyle = { color: '#10b981' } // green
|
||||||
} else if (filter.type === 'highlighted') {
|
} else if (filter.type === 'highlighted') {
|
||||||
activeStyle = { color: '#fde047' } // yellow
|
activeStyle = { color: '#fde047' } // yellow
|
||||||
|
} else if (filter.type === 'archive') {
|
||||||
|
activeStyle = { color: '#60a5fa' } // blue accent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
src/components/RichContent.tsx
Normal file
77
src/components/RichContent.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
|
||||||
|
interface RichContentProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render text content with:
|
||||||
|
* - Clickable links
|
||||||
|
* - Resolved nostr mentions (npub, nprofile, note, nevent, naddr)
|
||||||
|
* - Plain text
|
||||||
|
*
|
||||||
|
* Handles both nostr:npub1... and plain npub1... formats
|
||||||
|
*/
|
||||||
|
const RichContent: React.FC<RichContentProps> = ({
|
||||||
|
content,
|
||||||
|
className = 'bookmark-content'
|
||||||
|
}) => {
|
||||||
|
// Pattern to match:
|
||||||
|
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
|
||||||
|
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
|
||||||
|
// 3. http(s) URLs
|
||||||
|
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
|
||||||
|
|
||||||
|
const parts = content.split(pattern)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Handle nostr: URIs
|
||||||
|
if (part.startsWith('nostr:')) {
|
||||||
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain nostr identifiers (add nostr: prefix)
|
||||||
|
if (
|
||||||
|
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={`nostr:${part}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle http(s) URLs
|
||||||
|
if (part.match(/^https?:\/\//)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={part}
|
||||||
|
className="nostr-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text
|
||||||
|
return <React.Fragment key={index}>{part}</React.Fragment>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichContent
|
||||||
|
|
||||||
@@ -6,11 +6,13 @@ import IconButton from './IconButton'
|
|||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
import ThemeSettings from './Settings/ThemeSettings'
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
|
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||||
import ExploreSettings from './Settings/ExploreSettings'
|
import ExploreSettings from './Settings/ExploreSettings'
|
||||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
|
import TTSSettings from './Settings/TTSSettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import VersionFooter from './VersionFooter'
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
@@ -39,9 +41,15 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
|
fullWidthImages: true,
|
||||||
|
renderVideoLinksAsEmbeds: true,
|
||||||
syncReadingPosition: true,
|
syncReadingPosition: true,
|
||||||
autoMarkAsReadOnCompletion: false,
|
autoMarkAsReadOnCompletion: false,
|
||||||
hideBookmarksWithoutCreationDate: false,
|
hideBookmarksWithoutCreationDate: true,
|
||||||
|
ttsUseSystemLanguage: false,
|
||||||
|
ttsDetectContentLanguage: true,
|
||||||
|
ttsLanguageMode: 'content',
|
||||||
|
ttsDefaultSpeed: 2.1,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -169,8 +177,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBotArticlesByName"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBotArticlesByName !== false}
|
||||||
|
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide content posted by bots</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||||
className="setting-checkbox"
|
className="setting-checkbox"
|
||||||
/>
|
/>
|
||||||
<span>Automatically mark as read at 100%</span>
|
<span>Automatically move to archive at 100%</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
|
interface MediaDisplaySettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Media Display</h3>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="fullWidthImages"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.fullWidthImages === true}
|
||||||
|
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Full-width images in articles</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="renderVideoLinksAsEmbeds"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||||
|
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Render video links as embeds</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaDisplaySettings
|
||||||
@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Highlight Visibility</label>
|
<label>Default Highlight Visibility</label>
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
|
|||||||
86
src/components/Settings/TTSSettings.tsx
Normal file
86
src/components/Settings/TTSSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import TTSControls from '../TTSControls'
|
||||||
|
|
||||||
|
interface TTSSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
|
||||||
|
|
||||||
|
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const currentSpeed = settings.ttsDefaultSpeed || 2.1
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Text-to-Speech</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Playback Speed</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{currentSpeed}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Speaker language</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<select
|
||||||
|
value={settings.ttsLanguageMode || 'content'}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value
|
||||||
|
onUpdate({
|
||||||
|
ttsLanguageMode: value,
|
||||||
|
ttsUseSystemLanguage: value === 'system',
|
||||||
|
ttsDetectContentLanguage: value === 'content'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="setting-select"
|
||||||
|
>
|
||||||
|
<option value="system">System Language</option>
|
||||||
|
<option value="content">Content (auto-detect)</option>
|
||||||
|
<option disabled>────────────</option>
|
||||||
|
<option value="en-US">English (American)</option>
|
||||||
|
<option value="en-GB">English (British)</option>
|
||||||
|
<option value="zh">Mandarin Chinese</option>
|
||||||
|
<option value="es">Spanish</option>
|
||||||
|
<option value="hi">Hindi</option>
|
||||||
|
<option value="ar">Arabic</option>
|
||||||
|
<option value="fr">French</option>
|
||||||
|
<option value="pt">Portuguese</option>
|
||||||
|
<option value="de">German</option>
|
||||||
|
<option value="ja">Japanese</option>
|
||||||
|
<option value="ru">Russian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||||
|
{EXAMPLE_TEXT}
|
||||||
|
</div>
|
||||||
|
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSSettings
|
||||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
|
|
||||||
|
interface ShareTargetHandlerProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming shared URLs from the Web Share Target API.
|
||||||
|
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||||
|
*/
|
||||||
|
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSharedContent = async () => {
|
||||||
|
// Parse query parameters
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const link = params.get('link')
|
||||||
|
const title = params.get('title')
|
||||||
|
const text = params.get('text')
|
||||||
|
|
||||||
|
// Validate we have a URL
|
||||||
|
if (!link) {
|
||||||
|
showToast('No URL to save')
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no active account, wait for login
|
||||||
|
if (!activeAccount) {
|
||||||
|
setWaitingForLogin(true)
|
||||||
|
showToast('Please log in to save this bookmark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have account and URL, proceed with saving
|
||||||
|
if (!processing) {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await createWebBookmark(
|
||||||
|
link,
|
||||||
|
title || undefined,
|
||||||
|
text || undefined,
|
||||||
|
undefined,
|
||||||
|
activeAccount,
|
||||||
|
relayPool,
|
||||||
|
getActiveRelayUrls(relayPool)
|
||||||
|
)
|
||||||
|
showToast('Bookmark saved!')
|
||||||
|
navigate('/me/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
|
|||||||
|
|
||||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSupporters = async () => {
|
const loadSupporters = async () => {
|
||||||
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
|
|
||||||
if (zappers.length > 0) {
|
if (zappers.length > 0) {
|
||||||
const pubkeys = zappers.map(z => z.pubkey)
|
const pubkeys = zappers.map(z => z.pubkey)
|
||||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
// Fetch profiles in background without blocking
|
||||||
|
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
setSupporters(zappers)
|
setSupporters(zappers)
|
||||||
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
loadSupporters()
|
loadSupporters()
|
||||||
}, [relayPool, eventStore, settings])
|
}, [relayPool, eventStore, settings])
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||||
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supporters.length === 0 ? (
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
<div className="mb-16 md:mb-20">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Legends
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Supporters
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : supporters.length === 0 ? (
|
||||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,5 +249,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SupporterSkeletonProps {
|
||||||
|
isWhale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Avatar Skeleton */}
|
||||||
|
<div
|
||||||
|
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
|
||||||
|
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whale Badge Skeleton */}
|
||||||
|
{isWhale && (
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-border)',
|
||||||
|
borderColor: 'var(--color-bg)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and Total Skeleton */}
|
||||||
|
<div className="mt-2 text-center space-y-1">
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Support
|
export default Support
|
||||||
|
|
||||||
|
|||||||
114
src/components/TTSControls.tsx
Normal file
114
src/components/TTSControls.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useTextToSpeech } from '../hooks/useTextToSpeech'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { detect } from 'tinyld'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
defaultLang?: string
|
||||||
|
className?: string
|
||||||
|
settings?: UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
|
||||||
|
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
|
||||||
|
const {
|
||||||
|
supported, speaking, paused,
|
||||||
|
speak, pause, resume,
|
||||||
|
rate, setRate
|
||||||
|
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
|
||||||
|
|
||||||
|
const canPlay = supported && text?.trim().length > 0
|
||||||
|
|
||||||
|
const resolvedSystemLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
|
||||||
|
return navigator?.language?.split('-')[0]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
|
||||||
|
|
||||||
|
const detectContentLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if (mode) return mode === 'content'
|
||||||
|
return settings?.ttsDetectContentLanguage !== false
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
|
||||||
|
|
||||||
|
const specificLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
// If mode is not 'system' or 'content', it's a specific language code
|
||||||
|
if (mode && mode !== 'system' && mode !== 'content') {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode])
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!canPlay) return
|
||||||
|
|
||||||
|
if (!speaking) {
|
||||||
|
let langOverride: string | undefined
|
||||||
|
|
||||||
|
// Priority: specific language > content detection > system language
|
||||||
|
if (specificLang) {
|
||||||
|
langOverride = specificLang
|
||||||
|
} else if (detectContentLang && text) {
|
||||||
|
try {
|
||||||
|
const lang = detect(text)
|
||||||
|
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('[tts][detect] failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!langOverride && resolvedSystemLang) {
|
||||||
|
langOverride = resolvedSystemLang
|
||||||
|
}
|
||||||
|
speak(text, langOverride)
|
||||||
|
} else if (paused) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
const next = SPEED_OPTIONS[nextIndex]
|
||||||
|
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
|
||||||
|
setRate(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
|
||||||
|
|
||||||
|
if (!supported) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
title={playLabel}
|
||||||
|
disabled={!canPlay}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{rate}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSControls
|
||||||
|
|
||||||
212
src/components/VideoEmbedProcessor.tsx
Normal file
212
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useMemo, forwardRef } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
|
||||||
|
interface VideoEmbedProcessorProps {
|
||||||
|
html: string
|
||||||
|
renderVideoLinksAsEmbeds: boolean
|
||||||
|
className?: string
|
||||||
|
onMouseUp?: (e: React.MouseEvent) => void
|
||||||
|
onTouchEnd?: (e: React.TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that processes HTML content and optionally embeds video links
|
||||||
|
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
|
||||||
|
*/
|
||||||
|
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||||
|
html,
|
||||||
|
renderVideoLinksAsEmbeds,
|
||||||
|
className,
|
||||||
|
onMouseUp,
|
||||||
|
onTouchEnd
|
||||||
|
}, ref) => {
|
||||||
|
const processedHtml = useMemo(() => {
|
||||||
|
if (!renderVideoLinksAsEmbeds || !html) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||||
|
let result = html
|
||||||
|
|
||||||
|
const collectedUrls: string[] = []
|
||||||
|
let placeholderIndex = 0
|
||||||
|
|
||||||
|
// 1) Replace entire <video>...</video> blocks when they reference a video URL
|
||||||
|
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||||
|
const videoBlocks = result.match(videoBlockPattern) || []
|
||||||
|
videoBlocks.forEach((block) => {
|
||||||
|
// Try src on <video>
|
||||||
|
let url: string | null = null
|
||||||
|
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||||
|
url = videoSrcMatch[1]
|
||||||
|
} else {
|
||||||
|
// Try nested <source>
|
||||||
|
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||||
|
url = sourceSrcMatch[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
collectedUrls.push(url)
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
result = result.replace(new RegExp(escaped, 'g'), placeholder)
|
||||||
|
placeholderIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Replace entire <img ...> tags if their src points to a video
|
||||||
|
const imgTagPattern = /<img[^>]*>/gi
|
||||||
|
const allImgTags = result.match(imgTagPattern) || []
|
||||||
|
allImgTags.forEach((imgTag) => {
|
||||||
|
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||||
|
if (srcMatch && srcMatch[1]) {
|
||||||
|
const videoUrl = srcMatch[1]
|
||||||
|
collectedUrls.push(videoUrl)
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
|
||||||
|
placeholderIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
|
||||||
|
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||||
|
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
|
||||||
|
|
||||||
|
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||||
|
const allUrls: string[] = result.match(allUrlPattern) || []
|
||||||
|
const platformVideoUrls = allUrls.filter(url => {
|
||||||
|
// include URLs classified as video and not already collected
|
||||||
|
const classification = classifyUrl(url)
|
||||||
|
return classification.type === 'video' && !collectedUrls.includes(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||||
|
|
||||||
|
let processedHtml = result
|
||||||
|
remainingUrls.forEach((url) => {
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
processedHtml = processedHtml.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 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
|
||||||
|
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the HTML by video placeholders and render with embedded players
|
||||||
|
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||||
|
if (videoMatch) {
|
||||||
|
const videoIndex = parseInt(videoMatch[1])
|
||||||
|
const videoUrl = videoUrls[videoIndex]
|
||||||
|
if (videoUrl) {
|
||||||
|
return (
|
||||||
|
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
|
||||||
|
<ReactPlayer
|
||||||
|
url={videoUrl}
|
||||||
|
controls
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16/9'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular HTML content
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
dangerouslySetInnerHTML={{ __html: part }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
|
||||||
|
|
||||||
|
export default VideoEmbedProcessor
|
||||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||||
|
* These are accounts known to be bots or automated services
|
||||||
|
*/
|
||||||
|
export const BOT_PUBKEYS = new Set([
|
||||||
|
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||||
|
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pubkey corresponds to a known bot
|
||||||
|
*/
|
||||||
|
export function isKnownBot(pubkey: string): boolean {
|
||||||
|
return BOT_PUBKEYS.has(pubkey)
|
||||||
|
}
|
||||||
@@ -11,13 +11,11 @@ export const RELAYS = [
|
|||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.nostr.band',
|
'wss://relay.nostr.band',
|
||||||
'wss://relay.dergigi.com',
|
|
||||||
'wss://wot.dergigi.com',
|
'wss://wot.dergigi.com',
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
'wss://relay.current.fyi',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
'wss://nostr-pub.wellorder.net',
|
||||||
'wss://purplepag.es',
|
'wss://purplepag.es',
|
||||||
'wss://relay.primal.net',
|
'wss://relay.primal.net',
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { fetchArticleByNaddr } from '../services/articleService'
|
import { fetchArticleByNaddr } from '../services/articleService'
|
||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
@@ -14,7 +14,7 @@ interface UseArticleLoaderProps {
|
|||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: Dispatch<SetStateAction<Highlight[]>>
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
@@ -36,18 +36,26 @@ export function useArticleLoader({
|
|||||||
setCurrentArticle,
|
setCurrentArticle,
|
||||||
settings
|
settings
|
||||||
}: UseArticleLoaderProps) {
|
}: UseArticleLoaderProps) {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !naddr) return
|
if (!relayPool || !naddr) return
|
||||||
|
|
||||||
const loadArticle = async () => {
|
const loadArticle = async () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
// Keep highlights panel collapsed by default - only open on user interaction
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
markdown: article.markdown,
|
markdown: article.markdown,
|
||||||
@@ -63,49 +71,67 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleCoordinate(articleCoordinate)
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
setCurrentArticleEventId(article.event.id)
|
setCurrentArticleEventId(article.event.id)
|
||||||
setCurrentArticle?.(article.event)
|
setCurrentArticle?.(article.event)
|
||||||
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after article content is ready
|
|
||||||
// Don't wait for highlights to finish loading
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights asynchronously without blocking article display
|
// Fetch highlights asynchronously without blocking article display
|
||||||
// Stream them as they arrive for instant rendering
|
|
||||||
try {
|
try {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
setHighlights([]) // Clear old highlights
|
setHighlights([])
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
articleCoordinate,
|
articleCoordinate,
|
||||||
article.event.id,
|
article.event.id,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
// Deduplicate highlights by ID as they arrive
|
if (!mountedRef.current) return
|
||||||
if (!highlightsMap.has(highlight.id)) {
|
|
||||||
highlightsMap.set(highlight.id, highlight)
|
setHighlights((prev: Highlight[]) => {
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
const next = [highlight, ...prev]
|
||||||
}
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load article:', err)
|
console.error('Failed to load article:', err)
|
||||||
setReaderContent({
|
if (mountedRef.current) {
|
||||||
title: 'Error Loading Article',
|
setReaderContent({
|
||||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
title: 'Error Loading Article',
|
||||||
url: `nostr:${naddr}`
|
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
})
|
url: `nostr:${naddr}`
|
||||||
setReaderLoading(false)
|
})
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadArticle()
|
loadArticle()
|
||||||
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
naddr,
|
||||||
|
relayPool,
|
||||||
|
settings,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
|
setHighlights,
|
||||||
|
setHighlightsLoading,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId,
|
||||||
|
setCurrentArticle
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/hooks/useEventLoader.ts
Normal file
132
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { ReadableContent } from '../services/readerService'
|
||||||
|
import { eventManager } from '../services/eventManager'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
|
||||||
|
interface UseEventLoaderProps {
|
||||||
|
eventId?: string
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
setSelectedUrl: (url: string) => void
|
||||||
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
|
setReaderLoading: (loading: boolean) => void
|
||||||
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventLoader({
|
||||||
|
eventId,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed
|
||||||
|
}: UseEventLoaderProps) {
|
||||||
|
const displayEvent = useCallback((event: NostrEvent) => {
|
||||||
|
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||||
|
const escapedContent = event.content
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br />')
|
||||||
|
|
||||||
|
// Initial title
|
||||||
|
let title = `Note (${event.kind})`
|
||||||
|
if (event.kind === 1) {
|
||||||
|
title = `Note by @${event.pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately
|
||||||
|
const baseContent: ReadableContent = {
|
||||||
|
url: '',
|
||||||
|
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
|
||||||
|
title,
|
||||||
|
published: event.created_at
|
||||||
|
}
|
||||||
|
setReaderContent(baseContent)
|
||||||
|
|
||||||
|
// Background: resolve author profile for kind:1 and update title
|
||||||
|
if (event.kind === 1 && eventStore) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let resolved = ''
|
||||||
|
|
||||||
|
// First, try to get from event store cache
|
||||||
|
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
||||||
|
if (storedProfile) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||||
|
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found in event store, fetch from relays
|
||||||
|
if (!resolved && relayPool) {
|
||||||
|
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
||||||
|
if (profiles && profiles.length > 0) {
|
||||||
|
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||||
|
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore profile failures; keep fallback title
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}, [setReaderContent, relayPool, eventStore])
|
||||||
|
|
||||||
|
// Initialize event manager with services
|
||||||
|
useEffect(() => {
|
||||||
|
eventManager.setServices(eventStore || null, relayPool || null)
|
||||||
|
}, [eventStore, relayPool])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventId) return
|
||||||
|
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
|
||||||
|
setIsCollapsed(false)
|
||||||
|
|
||||||
|
// Fetch using event manager (handles cache, deduplication, and retry)
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
eventManager.fetchEvent(eventId).then(
|
||||||
|
(event) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
displayEvent(event)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const errorContent: ReadableContent = {
|
||||||
|
url: '',
|
||||||
|
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||||
|
title: 'Error'
|
||||||
|
}
|
||||||
|
setReaderContent(errorContent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useRef, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
@@ -48,6 +48,8 @@ export function useExternalUrlLoader({
|
|||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
}: UseExternalUrlLoaderProps) {
|
}: UseExternalUrlLoaderProps) {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
// Load cached URL-specific highlights from event store
|
// Load cached URL-specific highlights from event store
|
||||||
const urlFilter = useMemo(() => {
|
const urlFilter = useMemo(() => {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
@@ -61,79 +63,123 @@ export function useExternalUrlLoader({
|
|||||||
[url]
|
[url]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Load content and start streaming highlights when URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
const loadExternalUrl = async () => {
|
const loadExternalUrl = async () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(url)
|
setSelectedUrl(url)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
// Clear article-specific state
|
|
||||||
setCurrentArticleCoordinate(undefined)
|
setCurrentArticleCoordinate(undefined)
|
||||||
setCurrentArticleEventId(undefined)
|
setCurrentArticleEventId(undefined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(url)
|
const content = await fetchReadableContent(url)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after content is ready
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights for this URL asynchronously
|
// Fetch highlights for this URL asynchronously
|
||||||
try {
|
try {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
|
|
||||||
// Seed with cached highlights first
|
// Seed with cached highlights first
|
||||||
if (cachedUrlHighlights.length > 0) {
|
if (cachedUrlHighlights.length > 0) {
|
||||||
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
setHighlights((prev) => {
|
||||||
|
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
|
||||||
|
const localOnly = prev.filter(h => !seen.has(h.id))
|
||||||
|
const next = [...cachedUrlHighlights, ...localOnly]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setHighlights([])
|
setHighlights([])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
// Seed with cached IDs
|
|
||||||
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||||
|
|
||||||
await fetchHighlightsForUrl(
|
await fetchHighlightsForUrl(
|
||||||
relayPool,
|
relayPool,
|
||||||
url,
|
url,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
if (seen.has(highlight.id)) return
|
if (seen.has(highlight.id)) return
|
||||||
seen.add(highlight.id)
|
seen.add(highlight.id)
|
||||||
setHighlights((prev) => {
|
setHighlights((prev) => {
|
||||||
if (prev.some(h => h.id === highlight.id)) return prev
|
if (prev.some(h => h.id === highlight.id)) return prev
|
||||||
const next = [...prev, highlight]
|
const next = [highlight, ...prev]
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
undefined, // settings
|
undefined,
|
||||||
false, // force
|
false,
|
||||||
eventStore || undefined
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
// For videos and other media files, use the filename as the title
|
if (mountedRef.current) {
|
||||||
const filename = getFilenameFromUrl(url)
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: filename,
|
title: filename,
|
||||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadExternalUrl()
|
loadExternalUrl()
|
||||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
cachedUrlHighlights,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
|
setSelectedUrl,
|
||||||
|
setHighlights,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId,
|
||||||
|
setHighlightsLoading
|
||||||
|
])
|
||||||
|
|
||||||
|
// Keep UI highlights synced with cached store updates without reloading content
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return
|
||||||
|
if (cachedUrlHighlights.length === 0) return
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const seen = new Set<string>(prev.map(h => h.id))
|
||||||
|
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
|
||||||
|
if (additions.length === 0) return prev
|
||||||
|
const next = [...additions, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}, [cachedUrlHighlights, url, setHighlights])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/hooks/useMountedState.ts
Normal file
28
src/hooks/useMountedState.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track if component is mounted and prevent state updates after unmount.
|
||||||
|
* Returns a function to check if still mounted.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isMounted = useMountedState()
|
||||||
|
*
|
||||||
|
* async function loadData() {
|
||||||
|
* const data = await fetch(...)
|
||||||
|
* if (isMounted()) {
|
||||||
|
* setState(data)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function useMountedState(): () => boolean {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(() => mountedRef.current, [])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,12 +22,14 @@ export const useReadingPosition = ({
|
|||||||
completionHoldMs = 2000
|
completionHoldMs = 2000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
|
const positionRef = useRef(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
const lastSavedPosition = useRef(0)
|
const lastSavedPosition = useRef(0)
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const hasSavedOnce = useRef(false)
|
const hasSavedOnce = useRef(false)
|
||||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lastSavedAtRef = useRef<number>(0)
|
||||||
|
|
||||||
// Debounced save function
|
// Debounced save function
|
||||||
const scheduleSave = useCallback((currentPosition: number) => {
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
@@ -35,14 +37,49 @@ export const useReadingPosition = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't save if position hasn't changed significantly (less than 1%)
|
// Always save instantly when we reach completion (1.0)
|
||||||
// But always save if we've reached 100% (completion)
|
if (currentPosition === 1 && lastSavedPosition.current < 1) {
|
||||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
if (saveTimerRef.current) {
|
||||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
clearTimeout(saveTimerRef.current)
|
||||||
const isInitialSave = !hasSavedOnce.current
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
|
lastSavedPosition.current = 1
|
||||||
// Not significant enough to save
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
|
onSave(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least 5% progress change to consider saving
|
||||||
|
const MIN_DELTA = 0.05
|
||||||
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
|
||||||
|
|
||||||
|
// Enforce a minimum interval between saves (15s) to avoid spamming
|
||||||
|
const MIN_INTERVAL_MS = 15000
|
||||||
|
const nowMs = Date.now()
|
||||||
|
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
|
||||||
|
|
||||||
|
// Allow the very first meaningful save (when crossing 5%) regardless of interval
|
||||||
|
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
|
||||||
|
|
||||||
|
if (!hasSignificantChange && !isFirstMeaningful) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency
|
||||||
|
if (!enoughTimeElapsed && !isFirstMeaningful) {
|
||||||
|
// Clear and reschedule within the remaining window, but not sooner than MIN_INTERVAL_MS
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
const remaining = Math.max(0, MIN_INTERVAL_MS - (nowMs - lastSavedAtRef.current))
|
||||||
|
const delay = Math.max(autoSaveInterval, remaining)
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,27 +88,26 @@ export const useReadingPosition = ({
|
|||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule new save
|
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
|
||||||
|
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
lastSavedPosition.current = currentPosition
|
lastSavedPosition.current = currentPosition
|
||||||
hasSavedOnce.current = true
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(currentPosition)
|
onSave(currentPosition)
|
||||||
}, autoSaveInterval)
|
}, delay)
|
||||||
}, [syncEnabled, onSave, autoSaveInterval])
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
// Immediate save function
|
// Immediate save function
|
||||||
const saveNow = useCallback(() => {
|
const saveNow = useCallback(() => {
|
||||||
if (!syncEnabled || !onSave) return
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
// Cancel any pending saves
|
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always allow immediate save (including 0%)
|
|
||||||
lastSavedPosition.current = position
|
lastSavedPosition.current = position
|
||||||
hasSavedOnce.current = true
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(position)
|
onSave(position)
|
||||||
}, [syncEnabled, onSave, position])
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
@@ -96,14 +132,8 @@ export const useReadingPosition = ({
|
|||||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
// Only log on significant changes (every 5%) to avoid flooding console
|
|
||||||
const prevPercent = Math.floor(position * 20) // Groups by 5%
|
|
||||||
const newPercent = Math.floor(clampedProgress * 20)
|
|
||||||
if (prevPercent !== newPercent) {
|
|
||||||
// Position threshold crossed
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
|
positionRef.current = clampedProgress
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
// Schedule auto-save if sync is enabled
|
// Schedule auto-save if sync is enabled
|
||||||
@@ -115,7 +145,7 @@ export const useReadingPosition = ({
|
|||||||
if (clampedProgress === 1) {
|
if (clampedProgress === 1) {
|
||||||
if (!completionTimerRef.current) {
|
if (!completionTimerRef.current) {
|
||||||
completionTimerRef.current = setTimeout(() => {
|
completionTimerRef.current = setTimeout(() => {
|
||||||
if (!hasTriggeredComplete.current && position === 1) {
|
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
hasTriggeredComplete.current = true
|
hasTriggeredComplete.current = true
|
||||||
onReadingComplete?.()
|
onReadingComplete?.()
|
||||||
@@ -158,9 +188,7 @@ export const useReadingPosition = ({
|
|||||||
clearTimeout(completionTimerRef.current)
|
clearTimeout(completionTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface UseSettingsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||||
const [settings, setSettings] = useState<UserSettings>({})
|
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const loadAndWatch = async () => {
|
const loadAndWatch = async () => {
|
||||||
try {
|
try {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load settings:', err)
|
console.error('Failed to load settings:', err)
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
loadAndWatch()
|
loadAndWatch()
|
||||||
|
|
||||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
|
// Set image max-width based on full-width setting
|
||||||
|
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStyles()
|
applyStyles()
|
||||||
|
|||||||
306
src/hooks/useTextToSpeech.ts
Normal file
306
src/hooks/useTextToSpeech.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Web Speech API types
|
||||||
|
type SpeechSynthesisVoice = {
|
||||||
|
name: string
|
||||||
|
voiceURI: string
|
||||||
|
lang: string
|
||||||
|
localService: boolean
|
||||||
|
default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTTSOptions {
|
||||||
|
defaultLang?: string
|
||||||
|
defaultRate?: number
|
||||||
|
defaultPitch?: number
|
||||||
|
defaultVolume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTTS {
|
||||||
|
supported: boolean
|
||||||
|
speaking: boolean
|
||||||
|
paused: boolean
|
||||||
|
voices: SpeechSynthesisVoice[]
|
||||||
|
voice: SpeechSynthesisVoice | null
|
||||||
|
rate: number
|
||||||
|
pitch: number
|
||||||
|
volume: number
|
||||||
|
setVoice: (v: SpeechSynthesisVoice | null) => void
|
||||||
|
setRate: (r: number) => void
|
||||||
|
setPitch: (p: number) => void
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
speak: (text: string, langOverride?: string) => void
|
||||||
|
pause: () => void
|
||||||
|
resume: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||||
|
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
|
||||||
|
const supported = !!synth
|
||||||
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
||||||
|
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
|
||||||
|
const [speaking, setSpeaking] = useState(false)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [rate, setRate] = useState(options.defaultRate ?? 2.1)
|
||||||
|
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
|
||||||
|
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
|
||||||
|
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||||
|
|
||||||
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||||
|
const spokenTextRef = useRef<string>('')
|
||||||
|
const charIndexRef = useRef<number>(0)
|
||||||
|
// Chunking state to reliably speak long texts from web URLs
|
||||||
|
const chunksRef = useRef<string[]>([])
|
||||||
|
const chunkIndexRef = useRef<number>(0)
|
||||||
|
const globalOffsetRef = useRef<number>(0)
|
||||||
|
const langRef = useRef<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// Update rate when defaultRate option changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (options.defaultRate !== undefined) {
|
||||||
|
console.debug('[tts] defaultRate changed ->', options.defaultRate)
|
||||||
|
setRate(options.defaultRate)
|
||||||
|
}
|
||||||
|
}, [options.defaultRate])
|
||||||
|
|
||||||
|
// Load voices (async in many browsers)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return
|
||||||
|
const load = () => {
|
||||||
|
const v = synth!.getVoices()
|
||||||
|
setVoices(v)
|
||||||
|
if (!voice && v.length) {
|
||||||
|
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||||
|
setVoice(byLang || v[0] || null)
|
||||||
|
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
const handleVoicesChanged = () => load()
|
||||||
|
synth!.addEventListener('voiceschanged', handleVoicesChanged)
|
||||||
|
return () => {
|
||||||
|
synth!.removeEventListener('voiceschanged', handleVoicesChanged)
|
||||||
|
}
|
||||||
|
}, [supported, defaultLang, voice, synth])
|
||||||
|
|
||||||
|
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
|
||||||
|
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
||||||
|
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||||
|
const resolvedLang = langOverride || voice?.lang || defaultLang
|
||||||
|
u.lang = resolvedLang
|
||||||
|
if (langOverride) {
|
||||||
|
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
||||||
|
if (match) {
|
||||||
|
u.voice = match
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
|
u.rate = rate
|
||||||
|
u.pitch = pitch
|
||||||
|
u.volume = volume
|
||||||
|
|
||||||
|
const self = u
|
||||||
|
|
||||||
|
u.onstart = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
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')
|
||||||
|
// Continue with next chunk if available
|
||||||
|
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||||
|
if (hasMore) {
|
||||||
|
chunkIndexRef.current += 1
|
||||||
|
globalOffsetRef.current += self.text.length
|
||||||
|
const next = chunksRef.current[chunkIndexRef.current] || ''
|
||||||
|
const nextUtterance = createUtterance(next, langRef.current)
|
||||||
|
utteranceRef.current = nextUtterance
|
||||||
|
synth!.speak(nextUtterance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
|
utteranceRef.current = null
|
||||||
|
}
|
||||||
|
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 = globalOffsetRef.current + ev.charIndex
|
||||||
|
if (newIndex > charIndexRef.current) {
|
||||||
|
charIndexRef.current = newIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
|
||||||
|
|
||||||
|
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||||
|
if (normalized.length <= maxLen) return [normalized]
|
||||||
|
const sentences = normalized.split(/(?<=[.!?])\s+/)
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const s of sentences) {
|
||||||
|
if ((current + (current ? ' ' : '') + s).length > maxLen) {
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
if (s.length > maxLen) {
|
||||||
|
// Hard split very long sentence
|
||||||
|
for (let i = 0; i < s.length; i += maxLen) {
|
||||||
|
chunks.push(s.slice(i, i + maxLen))
|
||||||
|
}
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current = s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current ? `${current} ${s}` : s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
return chunks
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startSpeakingChunks = useCallback((text: string) => {
|
||||||
|
chunksRef.current = splitIntoChunks(text)
|
||||||
|
chunkIndexRef.current = 0
|
||||||
|
globalOffsetRef.current = 0
|
||||||
|
const first = chunksRef.current[0] || ''
|
||||||
|
const u = createUtterance(first, langRef.current)
|
||||||
|
utteranceRef.current = u
|
||||||
|
synth!.speak(u)
|
||||||
|
}, [createUtterance, splitIntoChunks, synth])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
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
|
||||||
|
langRef.current = langOverride
|
||||||
|
startSpeakingChunks(text)
|
||||||
|
}, [supported, synth, startSpeakingChunks, rate])
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (synth!.speaking && !synth!.paused) {
|
||||||
|
console.debug('[tts] pause')
|
||||||
|
synth!.pause()
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
}, [supported, synth])
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (synth!.speaking && synth!.paused) {
|
||||||
|
console.debug('[tts] resume')
|
||||||
|
synth!.resume()
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
}, [supported, synth])
|
||||||
|
|
||||||
|
// Update rate in real-time: while speaking, restart from last boundary with new rate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (!utteranceRef.current) return
|
||||||
|
|
||||||
|
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))
|
||||||
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
|
||||||
|
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
||||||
|
synth!.cancel()
|
||||||
|
// restart chunked from current global index
|
||||||
|
spokenTextRef.current = remainingText
|
||||||
|
charIndexRef.current = 0
|
||||||
|
// keep current language selection; no change needed here
|
||||||
|
startSpeakingChunks(remainingText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (utteranceRef.current) {
|
||||||
|
utteranceRef.current.rate = rate
|
||||||
|
}
|
||||||
|
}, [rate, supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
|
const updateRate = useCallback((newRate: number) => {
|
||||||
|
setRate(newRate)
|
||||||
|
if (!supported) return
|
||||||
|
if (!utteranceRef.current) return
|
||||||
|
|
||||||
|
if (synth!.speaking && !synth!.paused) {
|
||||||
|
const fullText = spokenTextRef.current
|
||||||
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||||
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
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
|
||||||
|
u.rate = newRate
|
||||||
|
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])
|
||||||
|
|
||||||
|
// stop TTS when unmounting
|
||||||
|
useEffect(() => stop, [stop])
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
supported,
|
||||||
|
speaking,
|
||||||
|
paused,
|
||||||
|
voices,
|
||||||
|
voice,
|
||||||
|
rate,
|
||||||
|
setRate: updateRate,
|
||||||
|
pitch, setPitch,
|
||||||
|
volume, setVolume,
|
||||||
|
setVoice,
|
||||||
|
speak, pause, resume, stop
|
||||||
|
}), [supported, speaking, paused, voices, voice, rate, updateRate, pitch, volume, setVoice, speak, pause, resume, stop])
|
||||||
|
}
|
||||||
|
|
||||||
197
src/services/archiveController.ts
Normal file
197
src/services/archiveController.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ARCHIVE_EMOJI } from './reactionService'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
type MarkedChangeCallback = (markedIds: Set<string>) => void
|
||||||
|
|
||||||
|
class ArchiveController {
|
||||||
|
private markedIds: Set<string> = new Set()
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private listeners: MarkedChangeCallback[] = []
|
||||||
|
private generation = 0
|
||||||
|
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||||
|
private pendingEventIds: Set<string> = new Set()
|
||||||
|
|
||||||
|
onMarked(cb: MarkedChangeCallback): () => void {
|
||||||
|
this.listeners.push(cb)
|
||||||
|
// Emit current state immediately to new subscribers
|
||||||
|
cb(new Set(this.markedIds))
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
const snapshot = new Set(this.markedIds)
|
||||||
|
this.listeners.forEach(cb => cb(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
mark(id: string): void {
|
||||||
|
if (!this.markedIds.has(id)) {
|
||||||
|
this.markedIds.add(id)
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmark(id: string): void {
|
||||||
|
if (this.markedIds.delete(id)) {
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMarked(id: string): boolean {
|
||||||
|
return this.markedIds.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkedIds(): string[] {
|
||||||
|
return Array.from(this.markedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
this.markedIds = new Set()
|
||||||
|
this.pendingEventIds = new Set()
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = options
|
||||||
|
const startGen = this.generation
|
||||||
|
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as loaded immediately (fetch runs non-blocking)
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
|
||||||
|
// Handlers for streaming queries
|
||||||
|
const handleUrlReaction = (evt: NostrEvent) => {
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||||
|
if (!rTag) return
|
||||||
|
this.markedIds.add(rTag)
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventReaction = (evt: NostrEvent) => {
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
// Direct coordinate tag ('a') - can be mapped immediately
|
||||||
|
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
|
||||||
|
if (aTag) {
|
||||||
|
try {
|
||||||
|
const [kindStr, pubkey, identifier] = aTag.split(':')
|
||||||
|
const kind = Number(kindStr)
|
||||||
|
if (kind === KINDS.BlogPost && pubkey && identifier) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed a-tag */ }
|
||||||
|
}
|
||||||
|
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||||
|
if (!eTag) return
|
||||||
|
this.pendingEventIds.add(eTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stream kind:17 and kind:7 in parallel
|
||||||
|
const [kind17, kind7] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
|
||||||
|
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (startGen !== this.generation) return
|
||||||
|
|
||||||
|
// Include EOSE events
|
||||||
|
kind17.forEach(handleUrlReaction)
|
||||||
|
kind7.forEach(handleEventReaction)
|
||||||
|
|
||||||
|
if (this.pendingEventIds.size > 0) {
|
||||||
|
// Fetch referenced articles (kind:30023) and map event IDs to naddr
|
||||||
|
const ids = Array.from(this.pendingEventIds)
|
||||||
|
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
|
||||||
|
for (const article of articleEvents) {
|
||||||
|
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) continue
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
} catch {
|
||||||
|
// skip invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try immediate mapping via eventStore for any still-pending e-ids
|
||||||
|
if (this.pendingEventIds.size > 0) {
|
||||||
|
const stillPending = new Set<string>()
|
||||||
|
for (const eId of this.pendingEventIds) {
|
||||||
|
try {
|
||||||
|
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
|
||||||
|
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
|
||||||
|
if (evt && evt.kind === KINDS.BlogPost) {
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stillPending.add(eId)
|
||||||
|
}
|
||||||
|
} catch (e) { stillPending.add(eId) }
|
||||||
|
}
|
||||||
|
this.pendingEventIds = stillPending
|
||||||
|
if (stillPending.size > 0) {
|
||||||
|
// Subscribe to future 30023 arrivals to finalize mapping
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
|
||||||
|
const genAtSub = this.generation
|
||||||
|
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
|
||||||
|
if (genAtSub !== this.generation) return
|
||||||
|
for (const evt of events) {
|
||||||
|
if (!this.pendingEventIds.has(evt.id)) continue
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) continue
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
this.pendingEventIds.delete(evt.id)
|
||||||
|
this.emit()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-blocking fetch; ignore errors here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const archiveController = new ArchiveController()
|
||||||
|
|
||||||
|
|
||||||
@@ -3,7 +3,8 @@ import { Helpers, EventStore } from 'applesauce-core'
|
|||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { EventPointer } from 'nostr-tools/nip19'
|
import { EventPointer } from 'nostr-tools/nip19'
|
||||||
import { merge } from 'rxjs'
|
import { from } from 'rxjs'
|
||||||
|
import { mergeMap } from 'rxjs/operators'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -69,6 +70,7 @@ class BookmarkController {
|
|||||||
private eventStore = new EventStore()
|
private eventStore = new EventStore()
|
||||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||||
|
private externalEventStore: EventStore | null = null
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
this.rawEventListeners.push(cb)
|
||||||
@@ -138,8 +140,11 @@ class BookmarkController {
|
|||||||
// Convert IDs to EventPointers
|
// Convert IDs to EventPointers
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||||
|
|
||||||
// Use EventLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
// This prevents overwhelming relays with 96+ simultaneous requests
|
||||||
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.eventLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -153,6 +158,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -183,8 +193,10 @@ class BookmarkController {
|
|||||||
identifier: c.identifier
|
identifier: c.identifier
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Use AddressLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -194,6 +206,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
idToEvent.set(event.id, event)
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -244,30 +261,42 @@ class BookmarkController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
const deduped = dedupeBookmarksById(allItems)
|
||||||
|
|
||||||
// Separate hex IDs from coordinates
|
// Separate hex IDs from coordinates for fetching
|
||||||
const noteIds: string[] = []
|
const noteIds: string[] = []
|
||||||
const coordinates: string[] = []
|
const coordinates: string[] = []
|
||||||
|
|
||||||
allItems.forEach(i => {
|
// Request hydration for all items that don't have content yet
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
deduped.forEach(i => {
|
||||||
noteIds.push(i.id)
|
// If item has no content, we need to fetch it
|
||||||
} else if (i.id.includes(':')) {
|
if (!i.content || i.content.length === 0) {
|
||||||
coordinates.push(i.id)
|
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||||
|
noteIds.push(i.id)
|
||||||
|
} else if (i.id.includes(':')) {
|
||||||
|
coordinates.push(i.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
|
||||||
|
|
||||||
// Helper to build and emit bookmarks
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
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(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
])
|
]
|
||||||
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
content: b.content || ''
|
// Prefer hydrated content; fallback to any cached event content in external store
|
||||||
|
content: b.content && b.content.length > 0
|
||||||
|
? b.content
|
||||||
|
: (this.externalEventStore?.getEvent(b.id)?.content || '')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedBookmarks = enriched
|
const sortedBookmarks = enriched
|
||||||
@@ -324,8 +353,12 @@ class BookmarkController {
|
|||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
activeAccount: unknown
|
activeAccount: unknown
|
||||||
accountManager: { getActive: () => unknown }
|
accountManager: { getActive: () => unknown }
|
||||||
|
eventStore?: EventStore
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { relayPool, activeAccount, accountManager } = options
|
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||||
|
|
||||||
|
// Store the external event store reference for adding hydrated events
|
||||||
|
this.externalEventStore = eventStore || null
|
||||||
|
|
||||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
|
|||||||
export const processApplesauceBookmarks = (
|
export const processApplesauceBookmarks = (
|
||||||
bookmarks: unknown,
|
bookmarks: unknown,
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
isPrivate: boolean
|
isPrivate: boolean,
|
||||||
|
parentCreatedAt?: number
|
||||||
): IndividualBookmark[] => {
|
): IndividualBookmark[] => {
|
||||||
if (!bookmarks) return []
|
if (!bookmarks) return []
|
||||||
|
|
||||||
@@ -76,14 +77,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: parentCreatedAt || 0,
|
||||||
pubkey: note.author || activeAccount.pubkey,
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
kind: 1, // Short note kind
|
kind: 1, // Short note kind
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
added_at: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -96,14 +97,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: coordinate,
|
id: coordinate,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: parentCreatedAt || 0,
|
||||||
pubkey: article.pubkey,
|
pubkey: article.pubkey,
|
||||||
kind: article.kind, // Usually 30023 for long-form articles
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
added_at: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,14 +115,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `hashtag-${hashtag}`,
|
id: `hashtag-${hashtag}`,
|
||||||
content: `#${hashtag}`,
|
content: `#${hashtag}`,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: parentCreatedAt || 0,
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['t', hashtag]],
|
tags: [['t', hashtag]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
added_at: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -132,14 +133,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `url-${url}`,
|
id: `url-${url}`,
|
||||||
content: url,
|
content: url,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: parentCreatedAt || 0,
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['r', url]],
|
tags: [['r', url]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
added_at: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -153,14 +154,14 @@ export const processApplesauceBookmarks = (
|
|||||||
.map((bookmark: BookmarkData) => ({
|
.map((bookmark: BookmarkData) => ({
|
||||||
id: bookmark.id!,
|
id: bookmark.id!,
|
||||||
content: bookmark.content || '',
|
content: bookmark.content || '',
|
||||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
created_at: bookmark.created_at || parentCreatedAt || 0,
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: bookmark.kind || 30001,
|
kind: bookmark.kind || 30001,
|
||||||
tags: bookmark.tags || [],
|
tags: bookmark.tags || [],
|
||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
added_at: bookmark.created_at || parentCreatedAt || 0
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,29 +170,38 @@ export function hydrateItems(
|
|||||||
items: IndividualBookmark[],
|
items: IndividualBookmark[],
|
||||||
idToEvent: Map<string, NostrEvent>
|
idToEvent: Map<string, NostrEvent>
|
||||||
): IndividualBookmark[] {
|
): IndividualBookmark[] {
|
||||||
return items.map(item => {
|
return items
|
||||||
const ev = idToEvent.get(item.id)
|
.map(item => {
|
||||||
if (!ev) return item
|
const ev = idToEvent.get(item.id)
|
||||||
|
if (!ev) return item
|
||||||
// For long-form articles (kind:30023), use the article title as content
|
|
||||||
let content = ev.content || item.content || ''
|
// For long-form articles (kind:30023), use the article title as content
|
||||||
if (ev.kind === 30023) {
|
let content = ev.content || item.content || ''
|
||||||
const articleTitle = getArticleTitle(ev)
|
if (ev.kind === 30023) {
|
||||||
if (articleTitle) {
|
const articleTitle = getArticleTitle(ev)
|
||||||
content = articleTitle
|
if (articleTitle) {
|
||||||
|
content = articleTitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Ensure all events with content get parsed content for proper rendering
|
||||||
return {
|
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||||
...item,
|
|
||||||
pubkey: ev.pubkey || item.pubkey,
|
return {
|
||||||
content,
|
...item,
|
||||||
created_at: ev.created_at || item.created_at,
|
pubkey: ev.pubkey || item.pubkey,
|
||||||
kind: ev.kind || item.kind,
|
content,
|
||||||
tags: ev.tags || item.tags,
|
created_at: ev.created_at || item.created_at,
|
||||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
kind: ev.kind || item.kind,
|
||||||
}
|
tags: ev.tags || item.tags,
|
||||||
})
|
parsedContent: parsedContent || item.parsedContent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => {
|
||||||
|
// Filter out bookmark list events (they're containers, not content)
|
||||||
|
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
|
||||||
|
return !isBookmarkListEvent
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async function decryptEvent(
|
|||||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||||
privateItems.push(
|
privateItems.push(
|
||||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -84,7 +84,7 @@ async function decryptEvent(
|
|||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItems.push(
|
privateItems.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -155,7 +155,7 @@ export async function collectBookmarksFromEvents(
|
|||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -181,7 +181,7 @@ export async function collectBookmarksFromEvents(
|
|||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
|
|||||||
148
src/services/eventManager.ts
Normal file
148
src/services/eventManager.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.fetchFromRelay(eventId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually fetch the event from relay
|
||||||
|
*/
|
||||||
|
private fetchFromRelay(eventId: string): void {
|
||||||
|
// If no loader yet, schedule retry
|
||||||
|
if (!this.relayPool || !this.eventLoader) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.eventLoader && this.pendingRequests.has(eventId)) {
|
||||||
|
this.fetchFromRelay(eventId)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
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))
|
||||||
|
this.rejectPending(eventId, error)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
// Completed without next - consider not found
|
||||||
|
if (!delivered) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
this.rejectPending(eventId, new Error('Event not found'))
|
||||||
|
}
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Safety timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!delivered) {
|
||||||
|
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
}, 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.fetchFromRelay(eventId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const eventManager = new EventManager()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export interface BlogPostPreview {
|
|||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
* @param onPost - Optional callback for streaming posts
|
* @param onPost - Optional callback for streaming posts
|
||||||
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
* @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
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
@@ -29,7 +30,8 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
onPost?: (post: BlogPostPreview) => void,
|
onPost?: (post: BlogPostPreview) => void,
|
||||||
limit: number | null = 100
|
limit: number | null = 100,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
@@ -45,12 +47,17 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||||
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||||
|
|
||||||
await queryEvents(
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
filter,
|
filter,
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
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 dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const key = `${event.pubkey}:${dTag}`
|
const key = `${event.pubkey}:${dTag}`
|
||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
@@ -73,6 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided (safety net for any missed during streaming)
|
||||||
|
if (eventStore) {
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
import { ARCHIVE_EMOJI } from './reactionService'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
|
||||||
@@ -30,15 +29,15 @@ export async function fetchReadArticles(
|
|||||||
try {
|
try {
|
||||||
// Fetch kind:7 and kind:17 reactions in parallel
|
// Fetch kind:7 and kind:17 reactions in parallel
|
||||||
const [kind7Events, kind17Events] = await Promise.all([
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
|
||||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
|
||||||
])
|
])
|
||||||
|
|
||||||
const readArticles: ReadArticle[] = []
|
const readArticles: ReadArticle[] = []
|
||||||
|
|
||||||
// Process kind:7 reactions (nostr-native articles)
|
// Process kind:7 reactions (nostr-native articles)
|
||||||
for (const event of kind7Events) {
|
for (const event of kind7Events) {
|
||||||
if (event.content === MARK_AS_READ_EMOJI) {
|
if (event.content === ARCHIVE_EMOJI) {
|
||||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||||
@@ -58,7 +57,7 @@ export async function fetchReadArticles(
|
|||||||
|
|
||||||
// Process kind:17 reactions (external URLs)
|
// Process kind:17 reactions (external URLs)
|
||||||
for (const event of kind17Events) {
|
for (const event of kind17Events) {
|
||||||
if (event.content === MARK_AS_READ_EMOJI) {
|
if (event.content === ARCHIVE_EMOJI) {
|
||||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||||
|
|
||||||
if (rTag && rTag[1]) {
|
if (rTag && rTag[1]) {
|
||||||
@@ -115,8 +114,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
const articleEvents = await queryEvents(
|
const articleEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
{ kinds: [KINDS.BlogPost], ids: eventIds }
|
||||||
{ relayUrls: RELAYS }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deduplicate article events by ID
|
// Deduplicate article events by ID
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { EventFactory } from 'applesauce-factory'
|
|
||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||||
import { RELAYS } from '../config/relays'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { getActiveRelayUrls } from './relayManager'
|
||||||
|
|
||||||
const MARK_AS_READ_EMOJI = '📚'
|
const ARCHIVE_EMOJI = '📚'
|
||||||
|
|
||||||
export { MARK_AS_READ_EMOJI }
|
export { ARCHIVE_EMOJI }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
||||||
@@ -23,7 +23,8 @@ export async function createEventReaction(
|
|||||||
eventAuthor: string,
|
eventAuthor: string,
|
||||||
eventKind: number,
|
eventKind: number,
|
||||||
account: IAccount,
|
account: IAccount,
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool,
|
||||||
|
options?: { aCoord?: string }
|
||||||
): Promise<NostrEvent> {
|
): Promise<NostrEvent> {
|
||||||
const factory = new EventFactory({ signer: account })
|
const factory = new EventFactory({ signer: account })
|
||||||
|
|
||||||
@@ -32,10 +33,13 @@ export async function createEventReaction(
|
|||||||
['p', eventAuthor],
|
['p', eventAuthor],
|
||||||
['k', eventKind.toString()]
|
['k', eventKind.toString()]
|
||||||
]
|
]
|
||||||
|
if (options?.aCoord) {
|
||||||
|
tags.push(['a', options.aCoord])
|
||||||
|
}
|
||||||
|
|
||||||
const draft = await factory.create(async () => ({
|
const draft = await factory.create(async () => ({
|
||||||
kind: 7, // Reaction
|
kind: 7, // Reaction
|
||||||
content: MARK_AS_READ_EMOJI,
|
content: ARCHIVE_EMOJI,
|
||||||
tags,
|
tags,
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
}))
|
}))
|
||||||
@@ -44,7 +48,7 @@ export async function createEventReaction(
|
|||||||
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||||
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
@@ -85,7 +89,7 @@ export async function createWebsiteReaction(
|
|||||||
|
|
||||||
const draft = await factory.create(async () => ({
|
const draft = await factory.create(async () => ({
|
||||||
kind: 17, // Reaction to a website
|
kind: 17, // Reaction to a website
|
||||||
content: MARK_AS_READ_EMOJI,
|
content: ARCHIVE_EMOJI,
|
||||||
tags,
|
tags,
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
}))
|
}))
|
||||||
@@ -94,12 +98,33 @@ export async function createWebsiteReaction(
|
|||||||
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||||
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a deletion request (NIP-09) for a reaction event to effectively un-archive.
|
||||||
|
* The caller must know the reaction event id to delete.
|
||||||
|
*/
|
||||||
|
export async function deleteReaction(
|
||||||
|
reactionEventId: string,
|
||||||
|
account: IAccount,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<NostrEvent> {
|
||||||
|
const factory = new EventFactory({ signer: account })
|
||||||
|
const draft = await factory.create(async () => ({
|
||||||
|
kind: 5, // Deletion per NIP-09
|
||||||
|
content: 'unarchive',
|
||||||
|
tags: [['e', reactionEventId]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}))
|
||||||
|
const signed = await factory.sign(draft)
|
||||||
|
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the user has already marked a nostr event as read
|
* Checks if the user has already marked a nostr event as read
|
||||||
* @param eventId The ID of the event to check
|
* @param eventId The ID of the event to check
|
||||||
@@ -120,7 +145,7 @@ export async function hasMarkedEventAsRead(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events$ = relayPool
|
const events$ = relayPool
|
||||||
.req(RELAYS, filter)
|
.req(getActiveRelayUrls(relayPool), filter)
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
completeOnEose(),
|
completeOnEose(),
|
||||||
@@ -130,8 +155,8 @@ export async function hasMarkedEventAsRead(
|
|||||||
|
|
||||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||||
|
|
||||||
// Check if any reaction has our mark-as-read emoji
|
// Check if any reaction has our archive emoji
|
||||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
|
||||||
|
|
||||||
return hasReadReaction
|
return hasReadReaction
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,7 +198,7 @@ export async function hasMarkedWebsiteAsRead(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events$ = relayPool
|
const events$ = relayPool
|
||||||
.req(RELAYS, filter)
|
.req(getActiveRelayUrls(relayPool), filter)
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
completeOnEose(),
|
completeOnEose(),
|
||||||
@@ -183,8 +208,8 @@ export async function hasMarkedWebsiteAsRead(
|
|||||||
|
|
||||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||||
|
|
||||||
// Check if any reaction has our mark-as-read emoji
|
// Check if any reaction has our archive emoji
|
||||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
|
||||||
|
|
||||||
return hasReadReaction
|
return hasReadReaction
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Filter, NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { processReadingProgress } from './readingDataProcessor'
|
import { processReadingProgress } from './readingDataProcessor'
|
||||||
import { ReadItem } from './readsService'
|
import { ReadItem } from './readsService'
|
||||||
|
import { ARCHIVE_EMOJI } from './reactionService'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
|
||||||
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||||
type LoadingCallback = (loading: boolean) => void
|
type LoadingCallback = (loading: boolean) => void
|
||||||
@@ -20,11 +22,14 @@ const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
|||||||
class ReadingProgressController {
|
class ReadingProgressController {
|
||||||
private progressListeners: ProgressMapCallback[] = []
|
private progressListeners: ProgressMapCallback[] = []
|
||||||
private loadingListeners: LoadingCallback[] = []
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
private markedAsReadListeners: (() => void)[] = []
|
||||||
|
|
||||||
private currentProgressMap: Map<string, number> = new Map()
|
private currentProgressMap: Map<string, number> = new Map()
|
||||||
|
private markedAsReadIds: Set<string> = new Set()
|
||||||
private lastLoadedPubkey: string | null = null
|
private lastLoadedPubkey: string | null = null
|
||||||
private generation = 0
|
private generation = 0
|
||||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||||
|
private isLoading = false
|
||||||
|
|
||||||
onProgress(cb: ProgressMapCallback): () => void {
|
onProgress(cb: ProgressMapCallback): () => void {
|
||||||
this.progressListeners.push(cb)
|
this.progressListeners.push(cb)
|
||||||
@@ -40,6 +45,13 @@ class ReadingProgressController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMarkedAsReadChanged(cb: () => void): () => void {
|
||||||
|
this.markedAsReadListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.markedAsReadListeners = this.markedAsReadListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setLoading(loading: boolean): void {
|
private setLoading(loading: boolean): void {
|
||||||
this.loadingListeners.forEach(cb => cb(loading))
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
}
|
}
|
||||||
@@ -48,6 +60,10 @@ class ReadingProgressController {
|
|||||||
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitMarkedAsReadChanged(): void {
|
||||||
|
this.markedAsReadListeners.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current reading progress map without triggering a reload
|
* Get current reading progress map without triggering a reload
|
||||||
*/
|
*/
|
||||||
@@ -91,6 +107,20 @@ class ReadingProgressController {
|
|||||||
return this.currentProgressMap.get(naddr)
|
return this.currentProgressMap.get(naddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if article is marked as read
|
||||||
|
*/
|
||||||
|
isMarkedAsRead(naddr: string): boolean {
|
||||||
|
return this.markedAsReadIds.has(naddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all marked as read IDs (for debugging)
|
||||||
|
*/
|
||||||
|
getMarkedAsReadIds(): string[] {
|
||||||
|
return Array.from(this.markedAsReadIds)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if reading progress is loaded for a specific pubkey
|
* Check if reading progress is loaded for a specific pubkey
|
||||||
*/
|
*/
|
||||||
@@ -113,24 +143,11 @@ class ReadingProgressController {
|
|||||||
this.timelineSubscription = null
|
this.timelineSubscription = null
|
||||||
}
|
}
|
||||||
this.currentProgressMap = new Map()
|
this.currentProgressMap = new Map()
|
||||||
|
this.markedAsReadIds = new Set()
|
||||||
this.lastLoadedPubkey = null
|
this.lastLoadedPubkey = null
|
||||||
this.emitProgress(this.currentProgressMap)
|
this.emitProgress(this.currentProgressMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get last synced timestamp for incremental loading
|
|
||||||
*/
|
|
||||||
private getLastSyncedAt(pubkey: string): number | null {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
|
||||||
if (!data) return null
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
return parsed[pubkey] || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update last synced timestamp
|
* Update last synced timestamp
|
||||||
*/
|
*/
|
||||||
@@ -157,13 +174,19 @@ class ReadingProgressController {
|
|||||||
const { relayPool, eventStore, pubkey, force = false } = params
|
const { relayPool, eventStore, pubkey, force = false } = params
|
||||||
const startGeneration = this.generation
|
const startGeneration = this.generation
|
||||||
|
|
||||||
|
|
||||||
// Skip if already loaded for this pubkey and not forcing
|
// Skip if already loaded for this pubkey and not forcing
|
||||||
if (!force && this.isLoadedFor(pubkey)) {
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent starts
|
||||||
|
if (this.isLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
this.lastLoadedPubkey = pubkey
|
this.isLoading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Seed from local cache immediately (survives refresh/flight mode)
|
// Seed from local cache immediately (survives refresh/flight mode)
|
||||||
@@ -173,8 +196,8 @@ class ReadingProgressController {
|
|||||||
this.emitProgress(this.currentProgressMap)
|
this.emitProgress(this.currentProgressMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to local timeline for immediate and reactive updates
|
// Subscribe to local eventStore timeline for immediate and reactive updates
|
||||||
// Clean up any previous subscription first
|
// This handles both local writes and synced events from relays
|
||||||
if (this.timelineSubscription) {
|
if (this.timelineSubscription) {
|
||||||
try {
|
try {
|
||||||
this.timelineSubscription.unsubscribe()
|
this.timelineSubscription.unsubscribe()
|
||||||
@@ -190,49 +213,47 @@ class ReadingProgressController {
|
|||||||
})
|
})
|
||||||
const generationAtSubscribe = this.generation
|
const generationAtSubscribe = this.generation
|
||||||
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
|
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
|
||||||
// Ignore if controller generation has changed (e.g., logout/login)
|
|
||||||
if (generationAtSubscribe !== this.generation) return
|
if (generationAtSubscribe !== this.generation) return
|
||||||
if (!Array.isArray(localEvents) || localEvents.length === 0) return
|
if (!Array.isArray(localEvents) || localEvents.length === 0) return
|
||||||
this.processEvents(localEvents)
|
this.processEvents(localEvents)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Query events from relays
|
// Mark as loaded immediately - queries run in background non-blocking
|
||||||
// Force full sync if map is empty (first load) or if explicitly forced
|
this.lastLoadedPubkey = pubkey
|
||||||
const needsFullSync = force || this.currentProgressMap.size === 0
|
|
||||||
const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey)
|
// Query reading progress from relays in background (non-blocking, fire-and-forget)
|
||||||
|
queryEvents(relayPool, {
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [KINDS.ReadingProgress],
|
kinds: [KINDS.ReadingProgress],
|
||||||
authors: [pubkey]
|
authors: [pubkey]
|
||||||
}
|
})
|
||||||
|
.then((relayEvents) => {
|
||||||
if (lastSynced && !needsFullSync) {
|
if (startGeneration !== this.generation) return
|
||||||
filter.since = lastSynced
|
if (relayEvents.length > 0) {
|
||||||
}
|
relayEvents.forEach(e => eventStore.add(e))
|
||||||
|
this.processEvents(relayEvents)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
this.updateLastSyncedAt(pubkey, now)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[readingProgress] Background reading progress query failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
|
// Load mark-as-read reactions in background (non-blocking, streaming)
|
||||||
|
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
|
||||||
if (startGeneration !== this.generation) {
|
.then(() => {
|
||||||
return
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
|
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
if (relayEvents.length > 0) {
|
|
||||||
// Add to event store
|
|
||||||
relayEvents.forEach(e => eventStore.add(e))
|
|
||||||
|
|
||||||
// Process and emit (merge with existing)
|
|
||||||
this.processEvents(relayEvents)
|
|
||||||
|
|
||||||
// Update last synced
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
this.updateLastSyncedAt(pubkey, now)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('📊 [ReadingProgress] Failed to load:', err)
|
console.error('📊 [ReadingProgress] Failed to setup:', err)
|
||||||
} finally {
|
} finally {
|
||||||
if (startGeneration === this.generation) {
|
if (startGeneration === this.generation) {
|
||||||
this.setLoading(false)
|
this.setLoading(false)
|
||||||
}
|
}
|
||||||
|
this.isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +292,82 @@ class ReadingProgressController {
|
|||||||
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mark-as-read reactions in background (non-blocking)
|
||||||
|
*/
|
||||||
|
private async loadMarkAsReadReactions(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
_eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
generation: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
|
||||||
|
const seenReactionIds = new Set<string>()
|
||||||
|
|
||||||
|
const handleUrlReaction = (evt: NostrEvent) => {
|
||||||
|
if (seenReactionIds.has(evt.id)) return
|
||||||
|
seenReactionIds.add(evt.id)
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||||
|
if (!rTag) return
|
||||||
|
this.markedAsReadIds.add(rTag)
|
||||||
|
this.emitMarkedAsReadChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingEventIds = new Set<string>()
|
||||||
|
const handleEventReaction = (evt: NostrEvent) => {
|
||||||
|
if (seenReactionIds.has(evt.id)) return
|
||||||
|
seenReactionIds.add(evt.id)
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||||
|
if (!eTag) return
|
||||||
|
pendingEventIds.add(eTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire queries with onEvent callbacks for streaming behavior
|
||||||
|
const [kind17Events, kind7Events] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
|
||||||
|
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (generation !== this.generation) return
|
||||||
|
|
||||||
|
// Include any reactions that arrived only at EOSE
|
||||||
|
kind17Events.forEach(handleUrlReaction)
|
||||||
|
kind7Events.forEach(handleEventReaction)
|
||||||
|
|
||||||
|
if (pendingEventIds.size > 0) {
|
||||||
|
// Fetch referenced 30023 events, streaming not required here
|
||||||
|
const ids = Array.from(pendingEventIds)
|
||||||
|
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
|
||||||
|
const eventIdToNaddr = new Map<string, string>()
|
||||||
|
for (const article of articleEvents) {
|
||||||
|
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) continue
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||||
|
eventIdToNaddr.set(article.id, naddr)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[readingProgress] Failed to encode naddr for article:', article.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map pending event IDs to naddrs and emit
|
||||||
|
for (const eId of pendingEventIds) {
|
||||||
|
const naddr = eventIdToNaddr.get(eId)
|
||||||
|
if (naddr) {
|
||||||
|
this.markedAsReadIds.add(naddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emitMarkedAsReadChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readingProgressController = new ReadingProgressController()
|
export const readingProgressController = new ReadingProgressController()
|
||||||
|
|||||||
276
src/services/readsController.ts
Normal file
276
src/services/readsController.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { merge } from 'rxjs'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { readingProgressController } from './readingProgressController'
|
||||||
|
import { archiveController } from './archiveController'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||||
|
|
||||||
|
export interface ReadItem {
|
||||||
|
id: string // naddr coordinate
|
||||||
|
source: 'reading-progress' | 'marked-as-read' | 'bookmark'
|
||||||
|
type: 'article' | 'external'
|
||||||
|
|
||||||
|
// Article data
|
||||||
|
event?: NostrEvent
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author?: string
|
||||||
|
|
||||||
|
// Reading metadata
|
||||||
|
readingProgress?: number // 0-1
|
||||||
|
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||||
|
markedAsRead?: boolean
|
||||||
|
markedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadsCallback = (reads: ReadItem[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads controller - manages read articles with progressive hydration
|
||||||
|
* Follows the same pattern as bookmarkController
|
||||||
|
*/
|
||||||
|
class ReadsController {
|
||||||
|
private readsListeners: ReadsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentReads: Map<string, ReadItem> = new Map()
|
||||||
|
private isLoading = false
|
||||||
|
private hydrationGeneration = 0
|
||||||
|
|
||||||
|
// Address loader for efficient batching
|
||||||
|
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||||
|
private eventStore: IEventStore | null = null
|
||||||
|
|
||||||
|
onReads(cb: ReadsCallback): () => void {
|
||||||
|
this.readsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.readsListeners = this.readsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.hydrationGeneration++
|
||||||
|
this.currentReads.clear()
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
if (this.isLoading !== loading) {
|
||||||
|
this.isLoading = loading
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getReads(): ReadItem[] {
|
||||||
|
return Array.from(this.currentReads.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate article events by coordinates using AddressLoader (auto-batching, streaming)
|
||||||
|
*/
|
||||||
|
private hydrateArticles(
|
||||||
|
coordinates: string[],
|
||||||
|
onProgress: () => void,
|
||||||
|
generation: number
|
||||||
|
): void {
|
||||||
|
if (!this.addressLoader) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinates.length === 0) return
|
||||||
|
|
||||||
|
// Parse coordinates into pointers
|
||||||
|
const pointers: Array<{ kind: number; pubkey: string; identifier: string }> = []
|
||||||
|
|
||||||
|
for (const coord of coordinates) {
|
||||||
|
try {
|
||||||
|
// Decode naddr to get article coordinates
|
||||||
|
if (coord.startsWith('naddr1')) {
|
||||||
|
const decoded = nip19.decode(coord)
|
||||||
|
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||||
|
pointers.push({
|
||||||
|
kind: decoded.data.kind,
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
identifier: decoded.data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode article coordinate:', coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointers.length === 0) return
|
||||||
|
|
||||||
|
// 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] || ''
|
||||||
|
|
||||||
|
// Build naddr from event
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: event.kind,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = this.currentReads.get(naddr)
|
||||||
|
if (item) {
|
||||||
|
// Enrich the item with article data
|
||||||
|
item.event = event
|
||||||
|
item.title = getArticleTitle(event) || 'Untitled'
|
||||||
|
item.summary = getArticleSummary(event)
|
||||||
|
item.image = getArticleImage(event)
|
||||||
|
item.published = getArticlePublished(event)
|
||||||
|
item.author = event.pubkey
|
||||||
|
|
||||||
|
// Store in event store if available
|
||||||
|
if (this.eventStore) {
|
||||||
|
this.eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to encode naddr for event:', event.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Silent error - AddressLoader handles retries
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build ReadItems from reading progress and emit them
|
||||||
|
*/
|
||||||
|
private buildAndEmitReads(): void {
|
||||||
|
const progressMap = readingProgressController.getProgressMap()
|
||||||
|
const markedIds = Array.from(new Set([
|
||||||
|
...readingProgressController.getMarkedAsReadIds(),
|
||||||
|
...archiveController.getMarkedIds()
|
||||||
|
]))
|
||||||
|
|
||||||
|
// Build read items from progress map
|
||||||
|
const readItems: ReadItem[] = []
|
||||||
|
|
||||||
|
for (const [id, progress] of progressMap.entries()) {
|
||||||
|
const existing = this.currentReads.get(id)
|
||||||
|
const item: ReadItem = existing || {
|
||||||
|
id,
|
||||||
|
source: 'reading-progress',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: progress,
|
||||||
|
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
item.readingProgress = progress
|
||||||
|
item.markedAsRead = markedIds.includes(id)
|
||||||
|
|
||||||
|
readItems.push(item)
|
||||||
|
this.currentReads.set(id, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include items that are only marked-as-read (no progress event yet)
|
||||||
|
for (const id of markedIds) {
|
||||||
|
if (!this.currentReads.has(id) && id.startsWith('naddr1')) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'article',
|
||||||
|
markedAsRead: true,
|
||||||
|
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
readItems.push(item)
|
||||||
|
this.currentReads.set(id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit current state (items without article data yet)
|
||||||
|
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
|
||||||
|
|
||||||
|
// Fetch missing articles in background (progressive hydration)
|
||||||
|
const generation = this.hydrationGeneration
|
||||||
|
const onProgress = () => {
|
||||||
|
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinatesToFetch = readItems
|
||||||
|
.filter(item => !item.event && item.type === 'article')
|
||||||
|
.map(item => item.id)
|
||||||
|
|
||||||
|
this.hydrateArticles(coordinatesToFetch, onProgress, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore } = options
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight hydration
|
||||||
|
this.hydrationGeneration++
|
||||||
|
this.eventStore = eventStore
|
||||||
|
|
||||||
|
// Initialize loader for this session
|
||||||
|
this.addressLoader = createAddressLoader(relayPool, {
|
||||||
|
eventStore,
|
||||||
|
extraRelays: RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Subscribe to reading progress changes
|
||||||
|
const unsubProgress = readingProgressController.onProgress(() => {
|
||||||
|
this.buildAndEmitReads()
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMarked = archiveController.onMarked(() => {
|
||||||
|
this.buildAndEmitReads()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build initial reads
|
||||||
|
this.buildAndEmitReads()
|
||||||
|
|
||||||
|
// Cleanup subscriptions on next start
|
||||||
|
setTimeout(() => {
|
||||||
|
unsubProgress()
|
||||||
|
unsubMarked()
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load reads:', error)
|
||||||
|
this.readsListeners.forEach(cb => cb([]))
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const readsController = new ReadsController()
|
||||||
|
|
||||||
@@ -1,38 +1,20 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { fetchReadArticles } from './libraryService'
|
import { fetchReadArticles } from './libraryService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
import type { ReadItem } from './readsController'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
export interface ReadItem {
|
// Re-export ReadItem from readsController for consistency
|
||||||
id: string // event ID or URL or coordinate
|
export type { ReadItem } from './readsController'
|
||||||
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
|
||||||
type: 'article' | 'external' // article=kind:30023, external=URL
|
|
||||||
|
|
||||||
// Article data
|
|
||||||
event?: NostrEvent
|
|
||||||
url?: string
|
|
||||||
title?: string
|
|
||||||
summary?: string
|
|
||||||
image?: string
|
|
||||||
published?: number
|
|
||||||
author?: string
|
|
||||||
|
|
||||||
// Reading metadata
|
|
||||||
readingProgress?: number // 0-1
|
|
||||||
readingTimestamp?: number // Unix timestamp of last reading activity
|
|
||||||
markedAsRead?: boolean
|
|
||||||
markedAt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all reads from multiple sources:
|
* Fetches all reads from multiple sources:
|
||||||
@@ -61,7 +43,7 @@ export async function fetchAllReads(
|
|||||||
try {
|
try {
|
||||||
// Fetch all data sources in parallel
|
// Fetch all data sources in parallel
|
||||||
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
|
||||||
fetchReadArticles(relayPool, userPubkey)
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -72,7 +54,7 @@ export async function fetchAllReads(
|
|||||||
processMarkedAsRead(markedAsReadArticles, readsMap)
|
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||||
if (onItem) {
|
if (onItem) {
|
||||||
readsMap.forEach(item => {
|
readsMap.forEach(item => {
|
||||||
if (item.type === 'article') onItem(item)
|
onItem(item)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +99,14 @@ export async function fetchAllReads(
|
|||||||
// Try to decode as naddr
|
// Try to decode as naddr
|
||||||
if (coord.startsWith('naddr1')) {
|
if (coord.startsWith('naddr1')) {
|
||||||
const decoded = nip19.decode(coord)
|
const decoded = nip19.decode(coord)
|
||||||
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
if (decoded.type === 'naddr') {
|
||||||
articlesToFetch.push({
|
const data = decoded.data as AddressPointer
|
||||||
pubkey: decoded.data.pubkey,
|
if (data.kind === KINDS.BlogPost) {
|
||||||
identifier: decoded.data.identifier || ''
|
articlesToFetch.push({
|
||||||
})
|
pubkey: data.pubkey,
|
||||||
|
identifier: data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try coordinate format (kind:pubkey:identifier)
|
// Try coordinate format (kind:pubkey:identifier)
|
||||||
@@ -144,8 +129,7 @@ export async function fetchAllReads(
|
|||||||
|
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
|
||||||
{ relayUrls: RELAYS }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Merge event data into ReadItems and emit
|
// Merge event data into ReadItems and emit
|
||||||
|
|||||||
180
src/services/relayListService.ts
Normal file
180
src/services/relayListService.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
|
||||||
|
export interface UserRelayInfo {
|
||||||
|
url: string
|
||||||
|
mode?: 'read' | 'write' | 'both'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user's relay list from kind 10002 (NIP-65)
|
||||||
|
*/
|
||||||
|
export async function loadUserRelayList(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
pubkey: string,
|
||||||
|
options?: {
|
||||||
|
onUpdate?: (relays: UserRelayInfo[]) => void
|
||||||
|
}
|
||||||
|
): Promise<UserRelayInfo[]> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
|
||||||
|
// Try querying with streaming callback for faster results
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
const eventsMap = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
|
const result = await queryEvents(relayPool, {
|
||||||
|
kinds: [10002],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 10
|
||||||
|
}, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Deduplicate by id and keep most recent
|
||||||
|
const existing = eventsMap.get(evt.id)
|
||||||
|
if (!existing || evt.created_at > existing.created_at) {
|
||||||
|
eventsMap.set(evt.id, evt)
|
||||||
|
// Update events array with deduplicated events
|
||||||
|
events.length = 0
|
||||||
|
events.push(...Array.from(eventsMap.values()))
|
||||||
|
|
||||||
|
// Stream immediate updates to caller using the newest event
|
||||||
|
if (options?.onUpdate) {
|
||||||
|
const tags = evt.tags || []
|
||||||
|
const relays: UserRelayInfo[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag[0] === 'r' && tag[1]) {
|
||||||
|
const url = tag[1]
|
||||||
|
const mode = (tag[2] as 'read' | 'write' | undefined) || 'both'
|
||||||
|
relays.push({ url, mode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (relays.length > 0) {
|
||||||
|
options.onUpdate(relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use the streaming results if we got any, otherwise fall back to the full result
|
||||||
|
const finalEvents = events.length > 0 ? events : result
|
||||||
|
|
||||||
|
// Also try a broader query to see if we get any events at all
|
||||||
|
await queryEvents(relayPool, {
|
||||||
|
kinds: [10002],
|
||||||
|
limit: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (finalEvents.length === 0) return []
|
||||||
|
|
||||||
|
// Get most recent event
|
||||||
|
const sortedEvents = finalEvents.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
const relayListEvent = sortedEvents[0]
|
||||||
|
|
||||||
|
const relays: UserRelayInfo[] = []
|
||||||
|
for (const tag of relayListEvent.tags) {
|
||||||
|
if (tag[0] === 'r' && tag[1]) {
|
||||||
|
const url = tag[1]
|
||||||
|
const mode = tag[2] as 'read' | 'write' | undefined
|
||||||
|
relays.push({
|
||||||
|
url,
|
||||||
|
mode: mode || 'both'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return relays
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user relay list:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads blocked relays from kind 10006 (NIP-51 mute list)
|
||||||
|
*/
|
||||||
|
export async function loadBlockedRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
pubkey: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const events = await queryEvents(relayPool, {
|
||||||
|
kinds: [10006],
|
||||||
|
authors: [pubkey]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (events.length === 0) return []
|
||||||
|
|
||||||
|
// Get most recent event
|
||||||
|
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
const muteListEvent = sortedEvents[0]
|
||||||
|
|
||||||
|
const blocked: string[] = []
|
||||||
|
for (const tag of muteListEvent.tags) {
|
||||||
|
if (tag[0] === 'r' && tag[1]) {
|
||||||
|
blocked.push(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocked
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load blocked relays:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes final relay set by merging inputs and removing blocked relays
|
||||||
|
*/
|
||||||
|
export function computeRelaySet(params: {
|
||||||
|
hardcoded: string[]
|
||||||
|
bunker?: string[]
|
||||||
|
userList?: UserRelayInfo[]
|
||||||
|
blocked?: string[]
|
||||||
|
alwaysIncludeLocal: string[]
|
||||||
|
}): string[] {
|
||||||
|
const {
|
||||||
|
hardcoded,
|
||||||
|
bunker = [],
|
||||||
|
userList = [],
|
||||||
|
blocked = [],
|
||||||
|
alwaysIncludeLocal
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const relaySet = new Set<string>()
|
||||||
|
const blockedSet = new Set(blocked)
|
||||||
|
|
||||||
|
// Helper to check if relay should be included
|
||||||
|
const shouldInclude = (url: string): boolean => {
|
||||||
|
// Always include local relays
|
||||||
|
if (alwaysIncludeLocal.includes(url)) return true
|
||||||
|
// Otherwise check if blocked
|
||||||
|
return !blockedSet.has(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hardcoded relays
|
||||||
|
for (const url of hardcoded) {
|
||||||
|
if (shouldInclude(url)) relaySet.add(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bunker relays
|
||||||
|
for (const url of bunker) {
|
||||||
|
if (shouldInclude(url)) relaySet.add(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user relays (treating 'both' and 'read' as applicable for queries)
|
||||||
|
for (const relay of userList) {
|
||||||
|
if (shouldInclude(relay.url)) relaySet.add(relay.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure local relays are present
|
||||||
|
for (const url of alwaysIncludeLocal) {
|
||||||
|
relaySet.add(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(relaySet)
|
||||||
|
}
|
||||||
|
|
||||||
91
src/services/relayManager.ts
Normal file
91
src/services/relayManager.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local relays that are always included
|
||||||
|
*/
|
||||||
|
export const ALWAYS_LOCAL_RELAYS = [
|
||||||
|
'ws://localhost:10547',
|
||||||
|
'ws://localhost:4869'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded relays that are always included
|
||||||
|
*/
|
||||||
|
export const HARDCODED_RELAYS = [
|
||||||
|
'wss://relay.nostr.band'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets active relay URLs from the relay pool
|
||||||
|
*/
|
||||||
|
export function getActiveRelayUrls(relayPool: RelayPool): string[] {
|
||||||
|
const urls = Array.from(relayPool.relays.keys())
|
||||||
|
return prioritizeLocalRelays(urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a relay URL to match what applesauce-relay stores internally
|
||||||
|
* Adds trailing slash for URLs without a path
|
||||||
|
*/
|
||||||
|
function normalizeRelayUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// If the pathname is empty or just "/", ensure it ends with "/"
|
||||||
|
if (parsed.pathname === '' || parsed.pathname === '/') {
|
||||||
|
return url.endsWith('/') ? url : url + '/'
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, return as-is
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a new relay set to the pool: adds missing relays, removes extras
|
||||||
|
*/
|
||||||
|
export function applyRelaySetToPool(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
finalUrls: string[]
|
||||||
|
): void {
|
||||||
|
// Normalize all URLs to match pool's internal format
|
||||||
|
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
||||||
|
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Add new relays (use original URLs for adding, not normalized)
|
||||||
|
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
||||||
|
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
relayPool.group(toAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove relays not in target (but always keep local relays)
|
||||||
|
const toRemove: string[] = []
|
||||||
|
for (const url of currentUrls) {
|
||||||
|
// Check if this normalized URL is in the target set
|
||||||
|
if (!normalizedTargetUrls.has(url)) {
|
||||||
|
// Also check if it's a local relay (check both normalized and original forms)
|
||||||
|
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
|
||||||
|
normalizeRelayUrl(localUrl) === url || localUrl === url
|
||||||
|
)
|
||||||
|
if (!isLocal) {
|
||||||
|
toRemove.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (const url of toRemove) {
|
||||||
|
const relay = relayPool.relays.get(url)
|
||||||
|
if (relay) {
|
||||||
|
relay.close()
|
||||||
|
relayPool.relays.delete(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -58,11 +58,21 @@ export interface UserSettings {
|
|||||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||||
// Reading settings
|
// Reading settings
|
||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
|
fullWidthImages?: boolean // default: false
|
||||||
|
renderVideoLinksAsEmbeds?: boolean // default: false
|
||||||
// Reading position sync
|
// Reading position sync
|
||||||
syncReadingPosition?: boolean // default: false (opt-in)
|
syncReadingPosition?: boolean // default: false (opt-in)
|
||||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||||
// Bookmark filtering
|
// Bookmark filtering
|
||||||
hideBookmarksWithoutCreationDate?: boolean // default: false
|
hideBookmarksWithoutCreationDate?: boolean // default: false
|
||||||
|
// Content filtering
|
||||||
|
hideBotArticlesByName?: boolean // default: true - hide authors whose profile name includes "bot"
|
||||||
|
// TTS language selection
|
||||||
|
ttsUseSystemLanguage?: boolean // default: false
|
||||||
|
ttsDetectContentLanguage?: boolean // default: true
|
||||||
|
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
||||||
|
// Text-to-Speech settings
|
||||||
|
ttsDefaultSpeed?: number // default: 2.1
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
121
src/services/unarchiveService.ts
Normal file
121
src/services/unarchiveService.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { ARCHIVE_EMOJI, deleteReaction } from './reactionService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's archive reactions (kind:7) for a given event id.
|
||||||
|
*/
|
||||||
|
export async function findArchiveReactionsForEvent(
|
||||||
|
eventId: string,
|
||||||
|
userPubkey: string,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
try {
|
||||||
|
const filter = {
|
||||||
|
kinds: [7],
|
||||||
|
authors: [userPubkey],
|
||||||
|
'#e': [eventId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const events$ = relayPool
|
||||||
|
.req(RELAYS, filter)
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(2000)),
|
||||||
|
toArray()
|
||||||
|
)
|
||||||
|
|
||||||
|
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||||
|
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[unarchive] findArchiveReactionsForEvent error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's archive reactions (kind:17) for a given website URL.
|
||||||
|
*/
|
||||||
|
export async function findArchiveReactionsForWebsite(
|
||||||
|
url: string,
|
||||||
|
userPubkey: string,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
try {
|
||||||
|
// Normalize URL same as creation
|
||||||
|
let normalizedUrl = url
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
parsed.hash = ''
|
||||||
|
normalizedUrl = parsed.toString()
|
||||||
|
if (normalizedUrl.endsWith('/')) normalizedUrl = normalizedUrl.slice(0, -1)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[unarchive] URL normalize failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
kinds: [17],
|
||||||
|
authors: [userPubkey],
|
||||||
|
'#r': [normalizedUrl]
|
||||||
|
}
|
||||||
|
|
||||||
|
const events$ = relayPool
|
||||||
|
.req(RELAYS, filter)
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(2000)),
|
||||||
|
toArray()
|
||||||
|
)
|
||||||
|
|
||||||
|
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||||
|
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[unarchive] findArchiveReactionsForWebsite error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends deletion requests for all of the user's archive reactions to an event.
|
||||||
|
* Returns the number of deletion requests published.
|
||||||
|
*/
|
||||||
|
export async function unarchiveEvent(
|
||||||
|
eventId: string,
|
||||||
|
account: IAccount,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const reactions = await findArchiveReactionsForEvent(eventId, account.pubkey, relayPool)
|
||||||
|
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
|
||||||
|
return reactions.length
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[unarchive] unarchiveEvent error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends deletion requests for all of the user's archive reactions to a website URL.
|
||||||
|
* Returns the number of deletion requests published.
|
||||||
|
*/
|
||||||
|
export async function unarchiveWebsite(
|
||||||
|
url: string,
|
||||||
|
account: IAccount,
|
||||||
|
relayPool: RelayPool
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const reactions = await findArchiveReactionsForWebsite(url, account.pubkey, relayPool)
|
||||||
|
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
|
||||||
|
return reactions.length
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[unarchive] unarchiveWebsite error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||||
|
import { getActiveRelayUrls } from './relayManager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified write helper: add event to EventStore, detect connectivity,
|
* Unified write helper: add event to EventStore, detect connectivity,
|
||||||
@@ -27,10 +27,13 @@ export async function publishEvent(
|
|||||||
|
|
||||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||||
|
|
||||||
|
// Get active relay URLs from the pool
|
||||||
|
const activeRelays = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
// Determine which relays we expect to succeed
|
// Determine which relays we expect to succeed
|
||||||
const expectedSuccessRelays = hasRemoteConnection
|
const expectedSuccessRelays = hasRemoteConnection
|
||||||
? RELAYS
|
? activeRelays
|
||||||
: RELAYS.filter(isLocalRelay)
|
: activeRelays.filter(isLocalRelay)
|
||||||
|
|
||||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export async function publishEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish to all configured relays in the background (non-blocking)
|
// Publish to all configured relays in the background (non-blocking)
|
||||||
relayPool.publish(RELAYS, event)
|
relayPool.publish(activeRelays, event)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
text-align: var(--paragraph-alignment, justify);
|
text-align: var(--paragraph-alignment, justify);
|
||||||
}
|
}
|
||||||
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||||
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
||||||
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
.login-highlight {
|
.login-highlight {
|
||||||
background-color: var(--highlight-color-mine, #fde047);
|
background-color: var(--highlight-color-mine, #fde047);
|
||||||
color: var(--color-text);
|
color: #000000;
|
||||||
padding: 0.125rem 0.25rem;
|
padding: 0.125rem 0.25rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
/* Video container - responsive wrapper following react-player docs */
|
/* Video container - responsive wrapper following react-player docs */
|
||||||
.reader-video {
|
.reader-video {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 80vw; /* 80% of viewport width */
|
width: 100%;
|
||||||
min-width: 400px; /* Minimum width */
|
max-width: 100%;
|
||||||
max-width: 1000px; /* Maximum width */
|
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
margin: 0 0 1rem 0; /* align with reader padding, no bleed */
|
||||||
background: rgb(0 0 0); /* black */
|
background: rgb(0 0 0); /* black */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.reader.empty { color: var(--color-text-secondary); }
|
.reader.empty { color: var(--color-text-secondary); }
|
||||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
|
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
|
||||||
@@ -54,7 +54,15 @@
|
|||||||
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
|
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
|
||||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
.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 */
|
/* Tame images from external content */
|
||||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
.reader .reader-html img, .reader .reader-markdown img {
|
||||||
|
max-width: var(--image-max-width, 100%);
|
||||||
|
max-height: 70vh;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0.75rem auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
/* Headlines with Tailwind typography */
|
/* Headlines with Tailwind typography */
|
||||||
.reader-markdown h1, .reader-html h1 {
|
.reader-markdown h1, .reader-html h1 {
|
||||||
font-size: 2.25rem; /* text-4xl */
|
font-size: 2.25rem; /* text-4xl */
|
||||||
|
|||||||
@@ -14,6 +14,31 @@
|
|||||||
50% { opacity: 1; transform: scale(1.1); }
|
50% { opacity: 1; transform: scale(1.1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle success burst used for archive action */
|
||||||
|
@keyframes success-burst {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45), 0 0 0 0 rgba(16, 185, 129, 0.25);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply archive animation when button enters animating state */
|
||||||
|
.mark-as-read-btn.animating {
|
||||||
|
animation: success-burst 600ms ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-as-read-btn.animating svg {
|
||||||
|
animation: pulse 600ms ease-in-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes highlight-pulse-animation {
|
@keyframes highlight-pulse-animation {
|
||||||
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||||
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||||
|
|||||||
34
src/sw.ts
34
src/sw.ts
@@ -98,10 +98,42 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log fetch errors for debugging (doesn't affect functionality)
|
// Handle Web Share Target POST requests
|
||||||
sw.addEventListener('fetch', (event: FetchEvent) => {
|
sw.addEventListener('fetch', (event: FetchEvent) => {
|
||||||
const url = new URL(event.request.url)
|
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)
|
// Don't interfere with WebSocket connections (relay traffic)
|
||||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||||
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
|
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
|
||||||
import ResolvedMention from '../components/ResolvedMention'
|
import ResolvedMention from '../components/ResolvedMention'
|
||||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
|
||||||
|
|
||||||
export const formatDate = (timestamp: number) => {
|
export const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
|
|||||||
@@ -50,19 +50,23 @@ export function filterByReadingProgress(
|
|||||||
|
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
const progress = item.readingProgress || 0
|
const progress = item.readingProgress || 0
|
||||||
const isMarked = item.markedAsRead || false
|
// Reading progress filters MUST ignore emoji/archive reactions
|
||||||
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
|
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
|
||||||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
|
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
|
||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'unopened':
|
case 'unopened':
|
||||||
return progress === 0 && !isMarked
|
return progress === 0
|
||||||
case 'started':
|
case 'started':
|
||||||
return progress > 0 && progress <= 0.10 && !isMarked
|
return progress > 0 && progress <= 0.10
|
||||||
case 'reading':
|
case 'reading':
|
||||||
return progress > 0.10 && progress <= 0.94 && !isMarked
|
return progress > 0.10 && progress <= 0.94
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return progress >= 0.95 || isMarked
|
// Completed is 95%+ progress only (no emoji fallback)
|
||||||
|
return progress >= 0.95
|
||||||
|
case 'archive':
|
||||||
|
// Archive filter handled upstream; keep fallback as false to avoid mixing
|
||||||
|
return false
|
||||||
case 'highlighted':
|
case 'highlighted':
|
||||||
return hasHighlights
|
return hasHighlights
|
||||||
case 'all':
|
case 'all':
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ export default defineConfig({
|
|||||||
background_color: '#0b1220',
|
background_color: '#0b1220',
|
||||||
orientation: 'any',
|
orientation: 'any',
|
||||||
categories: ['productivity', 'social', 'utilities'],
|
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: [
|
icons: [
|
||||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
|||||||
Reference in New Issue
Block a user