Compare commits

...

143 Commits

Author SHA1 Message Date
Gigi
e382310c88 chore: bump version to 0.10.7 2025-10-21 23:39:11 +02:00
Gigi
e6b99490dd refactor: simplify profile background fetching
- Remove unnecessary .then() callback
- Extract relayUrls variable for clarity
- Make error handlers consistent
- Add clearer comment about no-limit fetching
2025-10-21 23:35:56 +02:00
Gigi
09ee05861d fix: ensure all writings are stored in eventStore for profile pages
- Add eventStore parameter to fetchBlogPostsFromAuthors
- Store events as they stream in, not just at the end
- Update all callers to pass eventStore parameter
- This fixes issue where profile pages don't show all writings
2025-10-21 23:28:27 +02:00
Gigi
205988a6b0 docs: update CHANGELOG for v0.10.6 2025-10-21 23:15:50 +02:00
Gigi
8012752a39 chore: bump version to 0.10.6 2025-10-21 23:14:18 +02:00
Gigi
c3302da11d chore(me): remove debug logs after fixing tab switching 2025-10-21 23:13:10 +02:00
Gigi
60e1e3c821 fix(me): remove loadedTabs from useCallback deps to prevent infinite loop 2025-10-21 23:11:22 +02:00
Gigi
6c2247249a fix(me): use propActiveTab directly to avoid infinite update loop 2025-10-21 23:07:51 +02:00
Gigi
33a31df2b4 fix(me): restore useEffect to sync propActiveTab to local state on route changes 2025-10-21 23:05:17 +02:00
Gigi
f9dda1c5d4 fix(me): add key to tab content div to force re-render on tab switch 2025-10-21 22:59:09 +02:00
Gigi
6522a2871c fix(me): derive activeTab directly from route prop to update instantly on navigation 2025-10-21 22:54:48 +02:00
Gigi
f39b926e7b fix(tts): remove self-assignment in rate-change handler; keep current lang without no-op 2025-10-21 22:48:01 +02:00
Gigi
144cf5cbd1 fix(explore): subscribe-first loading model for contacts, writings, highlights; no timeouts; hydrate on first result; non-blocking nostrverse streams 2025-10-21 22:44:49 +02:00
Gigi
4b9de7cd07 feat(tts): make Web TTS reliable by chunking long text and resuming by chunks 2025-10-21 22:26:51 +02:00
Gigi
2be58332bb chore: bump version to 0.10.5 2025-10-21 22:18:00 +02:00
Gigi
6fc93cbd0f fix(pwa): accept link/Link/url form fields in Web Share Target POST handler 2025-10-21 22:04:34 +02:00
Gigi
5df426a863 fix(pwa): include share_target in build manifest via vite-plugin-pwa 2025-10-21 21:57:33 +02:00
Gigi
8ca4671bea chore: update package-lock.json 2025-10-21 21:37:09 +02:00
Gigi
ad1a808c6d docs: update CHANGELOG for v0.10.4 2025-10-21 21:36:22 +02:00
Gigi
ae118a0581 chore: bump version to 0.10.4 2025-10-21 21:35:47 +02:00
Gigi
3cddcd850e feat: add Web Share Target support for auto-saving shared URLs
- Add share_target to manifest.webmanifest with POST method
- Implement service worker handler for POST /share-target requests
- Create ShareTargetHandler component to process and save shared URLs
- Add /share-target route in App.tsx
- Auto-saves shared URLs as web bookmarks (NIP-B0)
- Handles Android case where url param is omitted from share data
2025-10-21 21:32:50 +02:00
Gigi
cadf4dcb48 perf(reading): debounce reading position saves (>=5% delta, 15s min interval, instant on completion) 2025-10-21 21:19:45 +02:00
Gigi
47d257faaf feat: add hardcoded bot pubkey filtering 2025-10-21 09:01:10 +02:00
Gigi
f542cee4cc docs: update CHANGELOG for v0.10.3 2025-10-21 08:29:00 +02:00
Gigi
8274eb26c2 chore: bump version to 0.10.3 2025-10-21 08:28:11 +02:00
Gigi
35018fef91 style: update bot filter setting to 'Hide content posted by bots' 2025-10-21 08:27:06 +02:00
Gigi
1fd08bb64a style: simplify bot filter setting text 2025-10-21 08:25:06 +02:00
Gigi
d953542c93 style: remove example bots text from setting 2025-10-21 08:23:52 +02:00
Gigi
8c0b73ad0c fix: resolve all linting and type checking issues 2025-10-21 08:21:36 +02:00
Gigi
a5d2ed8b07 feat: hide articles from bot accounts by name; add setting (default on) 2025-10-21 07:36:00 +02:00
Gigi
67fec91ab3 chore: bump version to 0.10.2 2025-10-21 07:29:34 +02:00
Gigi
868fe68ce2 chore: remove console.log debug output across app and relay services 2025-10-21 07:27:32 +02:00
Gigi
66c4bfc449 refactor: remove all eslint-disable comments; fix types and deps; clean unused imports 2025-10-21 07:26:00 +02:00
Gigi
29918f78f9 refactor: remove eslint-disable comments by typing publish, fixing unused-vars, and updating effect deps 2025-10-21 07:21:01 +02:00
Gigi
18fcf6064e feat: swap position of refresh and list/group buttons in bookmarks bar 2025-10-21 07:12:24 +02:00
Gigi
35766d5691 docs: update CHANGELOG.md for v0.10.1 2025-10-20 23:20:42 +02:00
Gigi
7450ba4251 chore: bump version to 0.10.1 2025-10-20 23:20:19 +02:00
Gigi
95c770c083 deps: update package-lock.json 2025-10-20 23:20:13 +02:00
Gigi
14a7e1138e feat: differentiate between American and British English in TTS 2025-10-20 23:16:26 +02:00
Gigi
9c45c71c8a feat: add top 10 TTS languages to speaker language selector 2025-10-20 23:15:14 +02:00
Gigi
23b9224272 style: remove 'Test Example' label from TTS settings 2025-10-20 23:10:26 +02:00
Gigi
bcd4a12542 content: update TTS example text to Boris mission statement 2025-10-20 23:10:03 +02:00
Gigi
d82e22ce1c refactor: use TTSControls component in TTS settings for consistent UI 2025-10-20 23:09:36 +02:00
Gigi
ea5c173745 feat: add example text section to test TTS in settings 2025-10-20 23:08:47 +02:00
Gigi
a214c487cc style: increase padding-right on dropdown chevron to 1.75rem 2025-10-20 23:07:06 +02:00
Gigi
43f56fc29a style: add more padding-right to dropdown selector for better spacing 2025-10-20 23:06:06 +02:00
Gigi
cfbc3efeeb style: use consistent setting-select class for speaker language dropdown 2025-10-20 23:05:20 +02:00
Gigi
bb9e98ff16 docs: update CHANGELOG.md for v0.10.0 2025-10-20 23:04:45 +02:00
Gigi
073bb3867f chore: bump version to 0.10.0 2025-10-20 23:04:08 +02:00
Gigi
1ac7fb26b2 Merge pull request #22 from dergigi/tts
feat: Add comprehensive Text-to-Speech (TTS) functionality
2025-10-20 23:03:22 +02:00
Gigi
a551234a29 feat(tts): use Speaker language mode (system|content) with fallback to legacy flags 2025-10-20 22:59:26 +02:00
Gigi
227f062456 feat(settings): consolidate TTS language into Speaker language dropdown (default: content) 2025-10-20 22:58:36 +02:00
Gigi
6c42ee88ea fix(lint): avoid empty catch in TTSControls detection 2025-10-20 22:56:16 +02:00
Gigi
fc138f3ceb feat(tts): select voice by detected/system language per utterance 2025-10-20 22:55:15 +02:00
Gigi
831f701c04 feat(tts): detect content language with tinyld and honor system lang toggle 2025-10-20 22:54:06 +02:00
Gigi
94b9d89225 feat(deps): add tinyld for client-side language detection 2025-10-20 22:53:14 +02:00
Gigi
2793a6dd44 feat(settings): add toggles for TTS language (system, content detection) 2025-10-20 22:35:25 +02:00
Gigi
9086692e29 feat(settings): set defaults for TTS language flags (system=false, content=true) 2025-10-20 22:35:04 +02:00
Gigi
f8c4bbb99c feat(settings): add TTS language flags (system, content detection) to UserSettings 2025-10-20 22:34:35 +02:00
Gigi
b14842c6fe fix(lint): wrap createUtterance in useCallback and correct deps for hooks 2025-10-20 22:29:45 +02:00
Gigi
7cdf0673bd fix(tts): guard events to current utterance and force restart via updateRate() 2025-10-20 22:25:54 +02:00
Gigi
bbed20d679 chore(tts-debug): add temporary console debug logs for speed changes and state 2025-10-20 22:22:38 +02:00
Gigi
7594d30fd2 feat(tts): restart from word boundary on speed change for immediate effect 2025-10-20 22:14:56 +02:00
Gigi
67506d9040 fix(tts): apply rate changes immediately including when paused 2025-10-20 22:13:10 +02:00
Gigi
e2d0bc2acf fix(tts): sync default rate changes from settings without refresh 2025-10-20 22:11:21 +02:00
Gigi
2283f4ec08 fix: remove eslint-disable and use proper type casting for SpeechSynthesisUtterance 2025-10-20 22:10:55 +02:00
Gigi
463ac8f44c fix(tts): apply rate changes whether utterance is speaking or paused 2025-10-20 22:10:18 +02:00
Gigi
e2de6f2d91 fix: resolve linter and type check errors in TTS code 2025-10-20 22:09:28 +02:00
Gigi
fdb52fe3b2 style(tts-settings): use setting-buttons layout like Default Bookmark View 2025-10-20 22:07:31 +02:00
Gigi
ae14064822 style(tts-settings): use same speed cycling button as TTSControls 2025-10-20 22:06:25 +02:00
Gigi
5526bfc425 chore(settings): reorder TTS settings above Layout & Behavior 2025-10-20 22:06:02 +02:00
Gigi
b3f4b03229 style(tts): remove button labels, show icons only 2025-10-20 22:05:21 +02:00
Gigi
b92f5716dc feat(tts): use default speed from settings in TTSControls 2025-10-20 22:05:04 +02:00
Gigi
177f8c1e70 feat(settings): integrate TTSSettings into settings page 2025-10-20 22:05:01 +02:00
Gigi
0407769206 feat(settings): create TTSSettings component 2025-10-20 22:04:58 +02:00
Gigi
eb75e7722d feat(tts): add ttsDefaultSpeed to UserSettings 2025-10-20 22:04:55 +02:00
Gigi
81aa414d2e fix(tts): apply speed changes immediately during playback 2025-10-20 22:03:05 +02:00
Gigi
c82fb65745 style(tts): remove Stop button, keep Play/Pause and Speed 2025-10-20 22:02:00 +02:00
Gigi
cc1b9f042f feat(tts): extend speed range to 3x with 2.1x default 2025-10-20 22:01:13 +02:00
Gigi
c2bf4b4a9a feat(tts): replace speed dropdown with cycling button 2025-10-20 22:00:46 +02:00
Gigi
13a47e4fdc style(tts): use design system colors and typography 2025-10-20 22:00:27 +02:00
Gigi
24b652847c style(tts): right-align TTS controls 2025-10-20 21:59:47 +02:00
Gigi
c623dc8d84 style(tts): reduce button and text sizes for compact layout 2025-10-20 21:59:31 +02:00
Gigi
31987010b8 docs(tts): add TTS feature to FEATURES.md 2025-10-20 21:42:02 +02:00
Gigi
b3206d5e79 feat(reader): integrate TTS controls in ContentPanel 2025-10-20 21:41:31 +02:00
Gigi
34f44c59b5 feat(tts): add TTSControls component with play/pause/stop and rate 2025-10-20 21:41:19 +02:00
Gigi
a51fbd25d7 feat(tts): add Web Speech API hook 2025-10-20 21:41:07 +02:00
Gigi
95f6949ab7 docs(changelog): add 0.9.1 release notes and update compare links 2025-10-20 21:31:42 +02:00
Gigi
1e613bd2a2 chore: bump version to 0.9.1 2025-10-20 21:26:25 +02:00
Gigi
95b882b0d1 fix(css): constrain video player to prevent horizontal overflow
- Set .reader-video to width: 100%, max-width: 100%, aspect-ratio: 16/9
- Remove negative margins and viewport-based sizing
- Add overflow: hidden to contain player within reader bounds
- Fixes video bleeding to the right on smaller screens
2025-10-20 21:26:05 +02:00
Gigi
be00f1434d feat(settings): default renderVideoLinksAsEmbeds to true
- Initialize settings with renderVideoLinksAsEmbeds: true
- Merge default when loading and watching settings events
- Ensures video links are embedded by default
2025-10-20 21:20:39 +02:00
Gigi
568890e131 fix: prevent ReactMarkdown img renderer from injecting unknown props
- Remove props spread to avoid node="[object Object]" artifacts
- Ensures downstream VideoEmbedProcessor can cleanly replace video <img> tags
2025-10-20 21:19:27 +02:00
Gigi
f000ac3be1 feat: embed <video> blocks and <img> video src in VideoEmbedProcessor
- Replace entire <video>...</video> and <img> tags with placeholders
- Extract URLs in same order to align with placeholders
- Also replace bare file URLs and platform-classified video URLs
- Ensures no broken tags remain; uses ReactPlayer for rendering
2025-10-20 21:15:46 +02:00
Gigi
2fed1cc6e7 fix: robustly replace img tags with video URLs
- Changed approach to find ALL img tags first, then check if they contain video URLs
- Properly escapes regex special characters in img tags before replacement
- Fixes issue where img tags with video src attributes were not being replaced
- Handles edge cases like React-added attributes (node=[object Object])
- Now correctly converts markdown video images to embedded players
2025-10-20 21:11:58 +02:00
Gigi
4bdcfcaeb4 feat: properly handle video URLs in markdown img tags 2025-10-20 21:10:16 +02:00
Gigi
a5494ba15c fix: improve URL regex patterns to prevent text artifacts
- Updated VideoEmbedProcessor regex patterns to use lookahead assertions
- This prevents capturing HTML attribute syntax like quotes and angle brackets
- Fixes text artifact appearing in UI when processing video URLs in HTML content
2025-10-20 20:45:22 +02:00
Gigi
64aad42be3 fix: prevent double video player rendering
- Modified ContentPanel to disable VideoEmbedProcessor when isExternalVideo is true
- This prevents both ContentPanel and VideoEmbedProcessor from rendering ReactPlayer for the same video URL
- Fixes issue where video players were showing twice
2025-10-20 20:44:38 +02:00
Gigi
3673849a9a feat: enable media display options by default
- Set fullWidthImages default to true
- Set renderVideoLinksAsEmbeds default to true
- Users now get enhanced media experience out of the box
- Can still be disabled in settings if preferred
2025-10-20 20:40:17 +02:00
Gigi
c6795f7c18 fix: resolve linting and TypeScript errors
- Remove unused faExpand import from ReadingDisplaySettings
- Fix TypeScript type errors in VideoEmbedProcessor
- Add explicit string[] type annotations for regex match results
- All linting and type checking now passes
2025-10-20 20:40:03 +02:00
Gigi
b27f26b639 refactor: create dedicated Media Display settings section
- Create new MediaDisplaySettings component for media-related settings
- Move full-width images and video embed settings from Reading & Display
- Add MediaDisplaySettings to main Settings component
- Improve settings organization and user experience
- Keep media settings logically grouped together
2025-10-20 20:38:46 +02:00
Gigi
975399e293 feat: add video embed setting and processor
- Add renderVideoLinksAsEmbeds setting to UserSettings interface
- Add checkbox control in ReadingDisplaySettings component
- Create VideoEmbedProcessor component to handle video link embedding
- Integrate VideoEmbedProcessor into ContentPanel for article rendering
- Support .mp4, .webm, .ogg, .mov, .avi, .mkv, .m4v video formats
- Use ReactPlayer for embedded video playback
- Default to false (render as links)
- When enabled, video links are rendered as embedded players
2025-10-20 20:37:45 +02:00
Gigi
53b8356373 feat: add full-width images setting
- Add fullWidthImages setting to UserSettings interface
- Add checkbox control in ReadingDisplaySettings component
- Implement CSS custom property --image-max-width
- Set property in useSettings hook based on user preference
- Default to false (constrained width)
- When enabled, images use max-width: none for full-width display
2025-10-20 20:35:24 +02:00
Gigi
8c5225b271 perf: optimize support page loading with instant display and skeletons
- Remove blocking full-screen spinner on support page
- Show page content immediately with skeleton placeholders
- Load supporters data in background without blocking UI
- Fetch profiles asynchronously to avoid blocking
- Add SupporterSkeleton component with proper animations
- Significantly improve perceived loading performance
2025-10-20 20:33:10 +02:00
Gigi
dfac7a5089 feat: sort writings by publication date, newest first
- Add sorting to Profile component's cachedWritings
- Sort by publication date (or created_at as fallback) with newest first
- Ensures consistent sorting across all writings displays
- Uses useMemo for performance optimization
2025-10-20 20:31:28 +02:00
Gigi
9fe09b813b fix: include period in 'your own highlights' highlight
- Update highlight to include the period: 'your own highlights.'
- Ensures complete phrase is highlighted for better visual consistency
- Maintains proper sentence structure in the highlighted text
2025-10-20 20:30:08 +02:00
Gigi
ea30c136f2 feat: highlight 'Connect your npub' in login text
- Add highlight styling to 'Connect your npub' text in login screen
- Now both 'Connect your npub' and 'your own highlights' are highlighted
- Uses same login-highlight class for consistent styling
- Improves visual emphasis on key action phrases
2025-10-20 20:29:39 +02:00
Gigi
623856ffe9 feat: center images in article view
- Update CSS to center images in reader content
- Change margin from '0.75rem 0' to '0.75rem auto' for horizontal centering
- Applies to both HTML and Markdown content in article view
- Improves visual presentation of images in articles
2025-10-20 20:28:50 +02:00
Gigi
d08071def2 fix: improve contrast for highlighted text in login screen
- Change login-highlight text color from var(--color-text) to #000000
- Ensures proper contrast against bright yellow highlight background in dark mode
- Fixes readability issue where light gray text was hard to read on yellow background
2025-10-20 20:28:04 +02:00
Gigi
556e8f2f7d docs: update CHANGELOG for v0.9.0
- Added user relay list integration (NIP-65) and blocked relays (NIP-51)
- Improved relay list loading performance with streaming callbacks
- Enhanced relay URL handling and normalization
- Fixed all linting issues and TypeScript type safety
- Added relay list debug capabilities
- Cleaned up temporary test relays and debug output
2025-10-20 20:13:33 +02:00
Gigi
9ab6847501 chore: bump version to 0.9.0 2025-10-20 20:13:15 +02:00
Gigi
31afe3792e fix: replace any types with proper NostrEvent types in relayListService
- Import NostrEvent type from nostr-tools
- Replace any[] with NostrEvent[] for events array
- Replace Map<string, any> with Map<string, NostrEvent> for eventsMap
- Resolves ESLint warnings about explicit any usage
2025-10-20 20:13:05 +02:00
Gigi
ebe8ecf63b feat: stream user relay list into pool immediately and finalize after blocked relays
- loadUserRelayList accepts onUpdate callback to stream first user relay list
- App.tsx applies interim relay set on first event, keeps alive, then recomputes with blocked relays
- Keeps startup non-blocking and matches Debug page behavior
2025-10-20 20:10:08 +02:00
Gigi
c418000a0c fix: add streaming callback to relay list service for faster results
- Add onEvent streaming callback to relayListService queryEvents call
- Process events as they arrive instead of waiting for all relays to respond
- Deduplicate events by id and keep most recent version
- Remove artificial delay since streaming provides immediate results
- Should resolve hanging issue where debug works but app query hangs
2025-10-20 20:03:16 +02:00
Gigi
15fd19f6a4 fix: resolve all linting issues
- Remove unused DebugBus import from App.tsx
- Remove unused NostrEvent import from relayListService.ts
- Add comment to empty catch block in ContentPanel.tsx
- Remove unused targetUrlsMap variable from relayManager.ts
- All linting errors resolved, TypeScript type checking passes
2025-10-20 20:01:40 +02:00
Gigi
2a44b4e3c0 cleanup: remove temporary test relays from hardcoded list
- Remove temporary relay additions that were added for debugging
- Restore clean hardcoded relay list now that dynamic relay integration is working
- The non-blocking relay loading implementation handles user relay lists properly
2025-10-20 20:01:02 +02:00
Gigi
aa7807e3d2 fix: make relay list loading non-blocking in App.tsx
- Start with hardcoded relays immediately when user logs in
- Load user relay list and blocked relays in background Promise
- Apply user relay preferences when they become available
- Remove blocking await that was preventing immediate relay setup
- Update keep-alive subscription and address loader when user relays load
- Continue with initial relay set if user relay loading fails
2025-10-20 19:58:55 +02:00
Gigi
359d3d0dd6 feat: add relay list debug section to Debug component
- Add state variables for relay list loading (isLoadingRelayList, relayListEvents, timing)
- Add handleLoadRelayList function to query kind 10002 events
- Add handleClearRelayList function to clear loaded data
- Add UI section with Load/Clear buttons and event display
- Show relay URLs and permissions for each relay list event
- Add loadRelayList to live timing type definition
2025-10-20 19:56:00 +02:00
Gigi
d40b3c0048 debug: add more detailed logging to relay list query including broader query test 2025-10-20 19:54:07 +02:00
Gigi
7b4ca50b16 debug: add timeout to relay list query and temporarily add user's relays to hardcoded set to test relay list loading 2025-10-20 19:52:40 +02:00
Gigi
76e001aba4 debug: add logging to relay list loading to diagnose why user relay list is not found 2025-10-20 19:51:42 +02:00
Gigi
0b42aeb383 refactor: remove non-relay console.log statements
- Remove console.log statements from ContentPanel.tsx (archive/content related)
- Remove console.log statements from readingProgressController.ts (reading progress related)
- Remove console.log statements from reactionService.ts (reaction related)
- Remove debug console.log block from Me.tsx (archive/me related)
- Preserve all relay-related console.log statements in App.tsx and relayManager.ts
2025-10-20 19:46:50 +02:00
Gigi
a4554e5176 chore: remove non-relay debug output
Remove bunker-related debug logs and keep-alive subscription warnings.
Keep only relay-related logs ([relay-init] and [relayManager]) for debugging
relay loading and management.
2025-10-20 19:35:39 +02:00
Gigi
2e844fc26b fix: use user's relay list exclusively when logged in
When logged in:
- If user has relay list (kind:10002): use ONLY user relays + bunker + localhost
- If user has NO relay list: fall back to hardcoded RELAYS

This ensures the relay list changes when you log in based on your NIP-65 relay list.

Added debug logging to show user relay list, blocked relays, and final relay set.
2025-10-20 19:31:21 +02:00
Gigi
8c0a4cac16 config: remove relay.dergigi.com from default relays
Keep only wot.dergigi.com (WoT relay) in the default relay list.
2025-10-20 19:30:00 +02:00
Gigi
c6eccc9589 fix: normalize relay URLs to match applesauce-relay internal format
applesauce-relay adds trailing slashes to relay URLs without paths,
but our RELAYS config doesn't include them. This caused applyRelaySetToPool
to think they were different URLs and remove all relays except the proxy.

Now we normalize URLs before comparison to match the pool's format.
2025-10-20 19:26:43 +02:00
Gigi
2e5536c331 debug: add logging to relay initialization to diagnose single relay issue 2025-10-20 19:18:03 +02:00
Gigi
fc025b9579 feat: integrate user relay lists (NIP-65) and blocked relays (NIP-51)
- Add relayListService to load kind:10002 (user relay list) and kind:10006 (blocked relays)
- Add relayManager to compute active relay set and dynamically manage pool membership
- Update App.tsx to fetch and apply user relays on login, reset on logout
- Replace all hardcoded RELAYS usages with dynamic getActiveRelayUrls() across services and components
- Always preserve localhost relays (ws://localhost:10547, ws://localhost:4869) regardless of user blocks
- Merge bunker relays, user relays, and hardcoded relays while excluding blocked relays
- Update keep-alive subscription and address loaders to use dynamic relay set
- Modified files: App.tsx, relayListService.ts (new), relayManager.ts (new), readsService.ts, readingProgressController.ts, archiveController.ts, libraryService.ts, reactionService.ts, writeService.ts, HighlightItem.tsx, ContentPanel.tsx, BookmarkList.tsx, Profile.tsx
2025-10-20 18:40:23 +02:00
Gigi
88db14c352 docs: update CHANGELOG for v0.8.6 2025-10-20 18:07:46 +02:00
Gigi
49c5f0c3ad chore: bump version to 0.8.6 2025-10-20 18:07:24 +02:00
Gigi
dbed4ad253 fix: revert to inline mount tracking with useRef
- Replace useMountedState custom hook with inline useRef approach
- Set mountedRef.current = true at start of each effect run
- Ensures proper reset when navigating between articles
- Simpler and more reliable than custom hook approach
2025-10-20 18:05:02 +02:00
Gigi
b117b1e6cf fix: remove isMounted from useEffect dependencies
- isMounted is a stable function from useMountedState and shouldn't be in deps
- Including it was preventing effects from running correctly
- Fixes issue where articles wouldn't load (stuck on spinner)
2025-10-20 17:46:41 +02:00
Gigi
627ffd6c5d fix: resolve React Hooks violation in NostrMentionLink component
- Move useEventModel hook call to top level (Rules of Hooks)
- Extract pubkey before calling the hook
- Profile resolution now works correctly for npub and nprofile mentions
- Fixes issue where profiles weren't being fetched and displayed
2025-10-20 16:36:52 +02:00
Gigi
0d53027818 chore: bump version to 0.8.5 2025-10-20 16:34:30 +02:00
Gigi
811d96dee0 refactor: extract common isMounted pattern into reusable useMountedState hook
- Create useMountedState hook to track component mount status
- Refactor useArticleLoader to use shared hook
- Refactor useExternalUrlLoader to use shared hook
- Remove duplicated isMounted pattern across both loaders
- Cleaner, more DRY code with same functionality
2025-10-20 16:33:05 +02:00
Gigi
21335d56dc fix: prevent infinite loading spinner by fixing race conditions in article/URL loaders
- Add isMounted flag to track component lifecycle in useArticleLoader
- Add isMounted flag to track component lifecycle in useExternalUrlLoader
- Remove setter functions from useEffect dependencies to prevent re-triggers
- Add cleanup functions to cancel pending state updates on unmount
- Check isMounted before all state updates in async operations
- Fixes issue where spinner would spin forever when loading articles
2025-10-20 15:00:39 +02:00
Gigi
f7e50023a3 feat: replace ContentWithResolvedProfiles with comprehensive RichContent component
- Create RichContent component to handle ALL nostr URI types
- Support npub, nprofile, note, nevent, naddr with profile resolution
- Handle both 'nostr:npub1...' and plain 'npub1...' formats
- Replace all ContentWithResolvedProfiles usages in CardView, LargeView, and CompactView
- Now all bookmark content properly displays resolved nostr mentions
2025-10-20 14:57:39 +02:00
Gigi
6b09212fe9 feat: resolve user profiles for npub mentions in highlight comments
- Create NostrMentionLink component to fetch and display user names
- Replace truncated pubkey display with resolved profile names
- Fetch profiles in background non-blocking way using useEventModel
- Falls back to truncated pubkey if profile not available
2025-10-20 14:55:00 +02:00
Gigi
cecff6b8d5 fix: filter out bookmark list events from individual bookmarks display
- Bookmark list events (kind:10003, 30003, 30001) are containers, not content
- Add filter in hydrateItems to exclude these kinds after hydration
- Add debug logging to track which items are being filtered
- Prevents bookmark list events from showing as individual bookmarks in UI
2025-10-20 14:45:30 +02:00
Gigi
2b061afa47 debug: add [BOOKMARK_TS] logging to investigate timestamp issues
- Log parentCreatedAt value when processApplesauceBookmarks is called
- Log each bookmark event with its kind and created_at timestamp
- Log count and timestamp for notes, articles, and URLs being processed
- Prefixed with [BOOKMARK_TS] for easy console filtering
2025-10-20 13:56:07 +02:00
Gigi
7516013e67 fix: use parent event timestamp for bookmarks instead of placeholder
- Add parentCreatedAt parameter to processApplesauceBookmarks function
- Replace all Math.floor(Date.now() / 1000) placeholders with parentCreatedAt || 0
- Update all call sites in bookmarkProcessing.ts to pass evt.created_at
- Individual bookmarks now inherit timestamp from their bookmark list event
- Bookmarks without valid parent timestamp will show as 0 (epoch) and be filtered by hideBookmarksWithoutCreationDate setting
- Eliminates 'now' placeholder timestamps in bookmark sidebar
2025-10-20 13:51:26 +02:00
Gigi
567641de77 fix: improve detection of placeholder bookmarks without valid timestamps
- Enhanced hasCreationDate() to better detect unhydrated bookmark references
- Web bookmarks (kind 39701) always have real timestamps, always shown
- Filter out bookmarks with no content (failed hydration)
- Filter out URL-only bookmarks with minimal tags and synthetic IDs
- These are created during NIP-51 processing and show 'now' if not hydrated
- Fixes issue where placeholder timestamps would pass filter after time elapsed
2025-10-20 13:45:00 +02:00
Gigi
4e86907663 fix: apply hideBookmarksWithoutCreationDate setting to Me component
- Import hasCreationDate utility function in Me.tsx
- Add UserSettings to MeProps interface
- Pass settings prop from Bookmarks to Me component
- Filter out bookmarks without creation dates when setting is enabled
- This ensures bookmarks showing 'Now' are hidden by default
2025-10-20 13:41:45 +02:00
Gigi
ec34e00573 docs: update CHANGELOG for v0.8.4 release
- Document progressive article hydration feature for reads tab
- Document React type imports fix in useArticleLoader
2025-10-20 13:36:19 +02:00
57 changed files with 2672 additions and 733 deletions

View File

@@ -7,6 +7,292 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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 outofthebox 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
@@ -2085,7 +2371,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.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

View File

@@ -11,6 +11,7 @@
- **Distractionfree view**: Clean typography, optional hero image, summary, and published date.
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
- **Progress**: Reading progress indicator with completion state.
- **TexttoSpeech**: Listen to articles with browsernative TTS; play/pause/stop controls with adjustable speed (0.81.6x).
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.

21
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.8.4",
"version": "0.10.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.8.4",
"version": "0.10.5",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -35,6 +35,7 @@
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1"
},
"devDependencies": {
@@ -11215,6 +11216,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyld": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
"license": "MIT",
"bin": {
"tinyld": "bin/tinyld.js",
"tinyld-heavy": "bin/tinyld-heavy.js",
"tinyld-light": "bin/tinyld-light.js"
},
"engines": {
"node": ">= 12.10.0",
"npm": ">= 6.12.0",
"yarn": ">= 1.20.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.8.4",
"version": "0.10.7",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -38,6 +38,7 @@
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tinyld": "^1.3.4",
"use-pull-to-refresh": "^2.4.1"
},
"devDependencies": {

View File

@@ -9,6 +9,16 @@
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "link"
}
},
"icons": [
{
"src": "/icon-192.png",

View File

@@ -8,17 +8,20 @@ import { AccountManager, Accounts } from 'applesauce-accounts'
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { NostrConnectSigner } from 'applesauce-signers'
import type { NostrEvent } from 'nostr-tools'
import { getDefaultBunkerPermissions } from './services/nostrConnect'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Debug from './components/Debug'
import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast'
import ShareTargetHandler from './components/ShareTargetHandler'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
@@ -157,6 +160,10 @@ function AppRoutes({
return (
<Routes>
<Route
path="/share-target"
element={<ShareTargetHandler relayPool={relayPool} />}
/>
<Route
path="/a/:naddr"
element={
@@ -385,16 +392,9 @@ function App() {
// Wire the signer to use this pool; make publish non-blocking so callers don't
// wait for every relay send to finish. Responses still resolve the pending request.
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = pool.publish(relays, event as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (result && typeof (result as any).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// Return an already-resolved promise so upstream await finishes immediately
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
// Fire-and-forget publish; do not block callers
pool.publish(relays, event).catch(() => { /* ignore errors */ })
return Promise.resolve()
}
@@ -417,14 +417,10 @@ function App() {
if (account) {
accounts.setActive(activeId)
} else {
console.warn('[bunker] ⚠️ Active ID found but account not in list')
}
} else {
// No active account ID in localStorage
}
} catch (err) {
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
console.error('Failed to load accounts from storage:', err)
}
// Subscribe to accounts changes and persist to localStorage
@@ -493,61 +489,27 @@ function App() {
try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
} catch (err) { /* ignore */ }
// Replace the signer on the account
nostrConnectAccount.signer = recreatedSigner
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
// Fire-and-forget publish for bunker: trigger but don't wait for completion
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
try {
let method: string | undefined
const content = (event as { content?: unknown })?.content
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
method = parsed?.method
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
}
const summary = {
relays,
kind: (event as { kind?: number })?.kind,
method,
// include tags array for debugging (NIP-46 expects method tag)
tags: (event as { tags?: unknown })?.tags,
contentLength: typeof content === 'string' ? content.length : undefined
}
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
// Fire-and-forget publish: trigger the publish but do not return the
// Observable/Promise to upstream to avoid their awaiting of completion.
const result = originalPublish(relays, event)
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
// Return a benign object so callers that probe for a "subscribe" property
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
return {} as unknown as never
}
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
try {
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
return originalSubscribe(relays, filters)
}
// Just ensure the signer is listening for responses - don't call connect() again
// The fromBunkerURI already connected with permissions during login
if (!nostrConnectAccount.signer.listening) {
await nostrConnectAccount.signer.open()
} else {
// Signer already listening
}
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
@@ -557,7 +519,7 @@ function App() {
await nostrConnectAccount.signer.connect(undefined, permissions)
}
} catch (e) {
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
// Ignore reconnect errors
}
// Give the subscription a moment to fully establish before allowing decrypt operations
@@ -597,17 +559,132 @@ function App() {
// Mark this account as reconnected
reconnectedAccounts.add(account.id)
} catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error)
console.error('Failed to open signer:', error)
}
}
})
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
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: [],
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 ? [] : RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
applyRelaySetToPool(pool, finalRelays)
updateKeepAlive()
// Update address loader with new relays
const activeRelays = getActiveRelayUrls(pool)
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: activeRelays
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}).catch((error) => {
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
// Continue with initial relay set on error - no need to change anything
})
} else {
// User logged out - reset to hardcoded relays
applyRelaySetToPool(pool, RELAYS)
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {},
error: () => {}
})
poolWithSub._keepAliveSubscription = newKeepAliveSub
// Reset address loader
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: RELAYS
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
next: () => {},
error: () => {}
})
// Store subscription for cleanup
@@ -630,6 +707,7 @@ function App() {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
userRelaysSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {

View File

@@ -6,18 +6,26 @@ import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { isKnownBot } from '../config/bots'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
level?: 'mine' | 'friends' | 'nostrverse'
readingProgress?: number // 0-1 reading progress (optional)
hideBotByName?: boolean // default true
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
// Hide bot authors by name/display_name
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
return null
}
const publishedDate = post.published || post.event.created_at
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {

View File

@@ -145,8 +145,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
if (viewMode === 'compact') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { articleImage, ...compactProps } = sharedProps
const compactProps = {
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary,
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
return <CompactView {...compactProps} />
}

View File

@@ -17,8 +17,8 @@ import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWith
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import { getActiveRelayUrls } from '../services/relayManager'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
@@ -125,7 +125,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
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
@@ -285,6 +285,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
</div>
{activeAccount && (
<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 && (
<IconButton
icon={faRotate}
@@ -296,13 +303,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
spin={isRefreshing}
/>
)}
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

@@ -5,7 +5,7 @@ import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
@@ -147,19 +147,15 @@ export const CardView: React.FC<CardViewProps> = ({
)}
{isArticle && articleSummary ? (
<div className="bookmark-content article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="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)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
)}
{contentLength > 210 && (

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -66,7 +66,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
</span>
{displayText && (
<div className="compact-text">
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>

View File

@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
@@ -95,13 +95,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-content">
{isArticle && articleSummary ? (
<div className="large-text article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="large-text article-summary" />
) : bookmark.content && (
<div className="large-text">
<ContentWithResolvedProfiles content={bookmark.content} />
</div>
<RichContent content={bookmark.content} className="large-text" />
)}
{/* Reading progress indicator for articles - shown only if there's progress */}

View File

@@ -328,7 +328,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
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}
profile={showProfile && profilePubkey ? (
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null

View File

@@ -4,14 +4,15 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import VideoEmbedProcessor from './VideoEmbedProcessor'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -45,6 +46,7 @@ import {
loadReadingPosition,
saveReadingPosition
} from '../services/readingPositionService'
import TTSControls from './TTSControls'
interface ContentPanelProps {
loading: boolean
@@ -320,6 +322,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Extract plain text for TTS
const baseHtml = useMemo(() => {
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
return finalHtml || html || ''
}, [markdown, renderedMarkdownHtml, finalHtml, html])
const articleText = useMemo(() => {
const parts: string[] = []
if (title) parts.push(title)
if (summary) parts.push(summary)
if (baseHtml) {
const div = document.createElement('div')
div.innerHTML = baseHtml
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
if (txt) parts.push(txt)
}
return parts.join('. ')
}, [title, summary, baseHtml])
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
@@ -357,7 +378,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
@@ -579,9 +601,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
// Silently ignore encoding errors
}
}
} else {
@@ -593,7 +614,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -674,7 +694,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
@@ -686,7 +705,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
relayPool
)
archiveController.mark(selectedUrl)
console.log('[archive][content] optimistic mark url', selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -736,11 +754,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
components={{
img: ({ src, alt, ...props }) => (
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
{...props}
/>
)
}}
@@ -762,6 +779,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
/>
{isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
</div>
)}
{isExternalVideo ? (
<>
<div className="reader-video">
@@ -846,10 +868,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
<VideoEmbedProcessor
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
@@ -861,10 +884,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
<VideoEmbedProcessor
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>

View File

@@ -114,6 +114,12 @@ const Debug: React.FC<DebugProps> = ({
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
// Relay list loading state
const [isLoadingRelayList, setIsLoadingRelayList] = useState(false)
const [relayListEvents, setRelayListEvents] = useState<NostrEvent[]>([])
const [tLoadRelayList, setTLoadRelayList] = useState<number | null>(null)
const [tFirstRelayList, setTFirstRelayList] = useState<number | null>(null)
// Deduplicated reading progress from controller
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
@@ -127,6 +133,7 @@ const Debug: React.FC<DebugProps> = ({
loadHighlights?: { startTime: number }
loadReadingProgress?: { startTime: number }
loadMarkAsRead?: { startTime: number }
loadRelayList?: { startTime: number }
}>({})
// Web of Trust state
@@ -427,11 +434,7 @@ const Debug: React.FC<DebugProps> = ({
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadHighlights, ...rest } = prev
return rest
})
setLiveTiming(prev => ({ ...prev, loadHighlights: undefined }))
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
} catch (err) {
@@ -648,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
return timeB - timeA
})
})
}
},
100,
eventStore || undefined
)
setWritingPosts(posts)
@@ -791,11 +796,7 @@ const Debug: React.FC<DebugProps> = ({
const elapsed = Math.round(performance.now() - start)
setTLoadReadingProgress(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadReadingProgress, ...rest } = prev
return rest
})
setLiveTiming(prev => ({ ...prev, loadReadingProgress: undefined }))
const finalMap = readingProgressController.getProgressMap()
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
@@ -864,11 +865,7 @@ const Debug: React.FC<DebugProps> = ({
const totalEvents = kind7Events.length + kind17Events.length
const elapsed = Math.round(performance.now() - start)
setTLoadMarkAsRead(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadMarkAsRead, ...rest } = prev
return rest
})
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: undefined }))
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
} catch (err) {
@@ -886,6 +883,66 @@ const Debug: React.FC<DebugProps> = ({
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
}
const handleLoadRelayList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load relay list')
return
}
try {
setIsLoadingRelayList(true)
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Loading relay list (kind 10002)...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadRelayList: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
// Query for kind:10002 (relay list)
const events = await queryEvents(relayPool, {
kinds: [10002],
authors: [activeAccount.pubkey],
limit: 10
}, {
onEvent: (evt) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstRelayList(Math.round(firstEventTime))
}
setRelayListEvents(prev => [...prev, evt])
}
})
const elapsed = Math.round(performance.now() - start)
setTLoadRelayList(elapsed)
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)
// Log details about the events
events.forEach((event, index) => {
const relayCount = event.tags.filter(tag => tag[0] === 'r').length
DebugBus.info('debug', `Event ${index + 1}: ${relayCount} relays, created ${new Date(event.created_at * 1000).toISOString()}`)
})
} catch (err) {
console.error('Failed to load relay list:', err)
DebugBus.error('debug', `Failed to load relay list: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingRelayList(false)
}
}
const handleClearRelayList = () => {
setRelayListEvents([])
setTLoadRelayList(null)
setTFirstRelayList(null)
DebugBus.info('debug', 'Cleared relay list data')
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -1698,6 +1755,72 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Relay List Loading Section */}
<div className="settings-section">
<h3 className="section-title">Relay List Loading (kind 10002)</h3>
<div className="text-sm opacity-70 mb-3">Load your relay list to debug dynamic relay integration:</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadRelayList}
disabled={isLoadingRelayList || !relayPool || !activeAccount}
>
{isLoadingRelayList ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Relay List'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearRelayList}
disabled={relayListEvents.length === 0}
>
Clear
</button>
</div>
<div className="flex gap-4 mb-3 text-sm">
<Stat label="total" value={tLoadRelayList} />
<Stat label="first event" value={tFirstRelayList} />
</div>
{relayListEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Relay List Events ({relayListEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{relayListEvents.map((evt, idx) => {
const relayTags = evt.tags?.filter((t: string[]) => t[0] === 'r') || []
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Relay List Event #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Kind: {evt.kind}</div>
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
<div>Relays: {relayTags.length}</div>
</div>
<div className="mt-1">
<div className="text-[11px] opacity-70 mb-1">Relay URLs:</div>
{relayTags.map((tag, tagIdx) => (
<div key={tagIdx} className="text-[10px] opacity-60 break-all">
{tag[1]} {tag[2] ? `(${tag[2]})` : ''}
</div>
))}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { nip19, NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
// Contacts are managed via controller subscription
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
// import { KINDS } from '../config/kinds'
// import { eventToHighlight } from '../services/highlightEventProcessor'
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
import { contactsController } from '../services/contactsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
// Accessors from Helpers (currently unused here)
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps {
relayPool: RelayPool
@@ -55,27 +57,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
const hasHydratedRef = useRef(false)
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
// Remove unused loading state to avoid warnings
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached content from event store (instant display)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
// event,
// title: getArticleTitle(event) || 'Untitled',
// summary: getArticleSummary(event),
// image: getArticleImage(event),
// published: getArticlePublished(event),
// author: event.pubkey
// }), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
@@ -105,6 +108,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}, [])
// Subscribe to contacts stream and mirror into local state
useEffect(() => {
const unsubscribe = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => unsubscribe()
}, [])
// Ensure contacts controller is started for the active account (non-blocking)
useEffect(() => {
if (relayPool && activeAccount?.pubkey) {
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
}
}, [relayPool, activeAccount?.pubkey])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
@@ -230,242 +248,95 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}, [propActiveTab])
useEffect(() => {
const loadData = async () => {
try {
// begin load, but do not block rendering
setLoading(true)
// Load initial data and refresh on triggers
const loadData = useCallback(() => {
if (!relayPool) return
// If not logged in, only fetch nostrverse content with streaming posts
if (!activeAccount) {
// Logged out: rely entirely on centralized controllers; do not fetch here
setLoading(false)
}
// Seed from cache for instant UI
if (activeAccount) {
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
const cached = getCachedHighlights(activeAccount.pubkey)
if (cached && cached.length > 0) setHighlights(cached)
}
// Seed from in-memory cache if available to avoid empty flash
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
}
// Seed with cached content from event store (instant display)
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
setHighlights(prev => {
const all = dedupeHighlightsById([...prev, ...merged])
return all.sort((a, b) => b.created_at - a.created_at)
})
}
// Seed with cached writings from event store
if (cachedWritings.length > 0) {
setBlogPosts(prev => {
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
return all.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
setLoading(true)
// At this point, we have seeded any available data; lift the loading state
setLoading(false)
try {
// Prepare parallel fetches
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
fetchNostrverseBlogPosts(
relayPool,
activeAccount?.pubkey || '',
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
// When local contacts are available, kick off early fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors(
relayPool,
partialArray,
relayUrls,
(post) => {
setBlogPosts((prev) => {
// Deduplicate by author:d-tag (replaceable event key)
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
// If exists, only replace if this one is newer
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) {
return prev // Keep existing (newer or same)
}
// Replace with newer version
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
// New post, add it
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
// Deduplicate by author:d-tag (replaceable event key)
const byKey = new Map<string, BlogPostPreview>()
// Add existing posts
for (const p of prev) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
byKey.set(key, p)
}
// Merge in new posts (keeping newer versions)
for (const post of all) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
const merged = Array.from(byKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
// Fetch highlights
fetchHighlightsFromAuthors(
relayPool,
partialArray,
(highlight) => {
setHighlights((prev) => {
const exists = prev.some(h => h.id === highlight.id)
if (exists) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
// Always proceed to load nostrverse content even if no contacts
// (removed blocking error for empty contacts)
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// Fetch friends content and (optionally) nostrverse + mine content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
// Use centralized writingsController for my posts (non-blocking)
// pull from writingsController; no need to store promise
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
setHasLoadedMine(true)
const nostrversePostsPromise = visibility.nostrverse
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
// Stream nostrverse posts too when logged in
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
})
: Promise.resolve([] as BlogPostPreview[])
// Fire non-blocking fetches and merge as they resolve
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
.then((friendsPosts) => {
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return sorted
const merged = dedupeWritingsByReplaceable([...prev, post])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
fetchHighlightsFromAuthors(relayPool, contactsArray)
.then((friendsHighlights) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
return sorted
})
}).catch(() => {})
nostrversePostsPromise.then((nostrversePosts) => {
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}
).then((nostrversePosts) => {
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
}).catch(() => {})
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
}
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
// loading is already turned off after seeding
}
}
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
useEffect(() => {
loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
}, [loadData, refreshTrigger])
// Kick off friends fetches reactively when contacts arrive
useEffect(() => {
if (!relayPool) return
if (followedPubkeys.size === 0) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(followedPubkeys)
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, post])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, 100, eventStore).then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, highlight])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
}, eventStore || undefined).then((friendsHighlights) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged.sort((a, b) => b.created_at - a.created_at)
})
}).catch(() => {})
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
@@ -509,7 +380,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
// Lazy-load nostrverse highlights when user toggles it on (logged in)
useEffect(() => {
@@ -586,6 +462,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Hide bot authors by profile display name if setting enabled
if (settings?.hideBotArticlesByName !== false) {
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
// Keep list intact here; individual cards will render null if author is a bot
}
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
@@ -604,7 +486,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
@@ -653,6 +535,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
hideBotByName={settings?.hideBotArticlesByName !== false}
/>
))}
</div>

View File

@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -17,6 +17,7 @@ import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
// Helper to detect if a URL is an image
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
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
// Handle nostr: URIs - now with profile resolution
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
return (
<NostrMentionLink
key={index}
nostrUri={part}
onClick={(e) => e.stopPropagation()}
/>
)
}
// Handle http(s) URLs
@@ -236,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
publishedRelays: getActiveRelayUrls(relayPool),
isLocalOnly: false,
isOfflineCreated: false
}
@@ -250,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
})
return unsubscribe
}, [highlight, onHighlightUpdate])
}, [highlight, onHighlightUpdate, relayPool])
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -310,7 +224,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
@@ -346,7 +261,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
const targetRelays = getActiveRelayUrls(relayPool)
await relayPool.publish(targetRelays, event)
@@ -414,7 +329,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayNames = activeRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {

View File

@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
<div className="login-content">
<h2 className="login-title">Hi! I'm Boris.</h2>
<p className="login-description">
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
</p>
<div className="login-buttons">

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
@@ -23,7 +23,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
@@ -31,6 +31,7 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { readingProgressController } from '../services/readingProgressController'
import { archiveController } from '../services/archiveController'
import { UserSettings } from '../services/settingsService'
interface MeProps {
relayPool: RelayPool
@@ -38,6 +39,7 @@ interface MeProps {
activeTab?: TabType
bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
@@ -49,12 +51,13 @@ const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
bookmarks
bookmarks,
settings
}) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const activeTab = propActiveTab || 'highlights'
// Only for own profile
const viewingPubkey = activeAccount?.pubkey
@@ -126,13 +129,6 @@ const Me: React.FC<MeProps> = ({
}
}, [])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
// Sync filter state with URL changes
useEffect(() => {
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
@@ -202,15 +198,15 @@ const Me: React.FC<MeProps> = ({
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
// Tab-specific loading functions
const loadHighlightsTab = async () => {
const loadHighlightsTab = useCallback(async () => {
if (!viewingPubkey) return
// Highlights come from controller subscription (sync effect handles it)
setLoadedTabs(prev => new Set(prev).add('highlights'))
setLoading(false)
}
}, [viewingPubkey])
const loadWritingsTab = async () => {
const loadWritingsTab = useCallback(async () => {
if (!viewingPubkey) return
try {
@@ -227,28 +223,29 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load writings:', err)
setLoading(false)
}
}
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
const loadReadingListTab = async () => {
const loadReadingListTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
setLoadedTabs(prev => {
const hasBeenLoaded = prev.has('reading-list')
if (!hasBeenLoaded) setLoading(true)
// Bookmarks come from centralized loading in App.tsx
setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) {
console.error('Failed to load reading list:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
return new Set(prev).add('reading-list')
})
// Always turn off loading after a tick
setTimeout(() => setLoading(false), 0)
}, [viewingPubkey, activeAccount])
const loadReadsTab = async () => {
const loadReadsTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
let hasBeenLoaded = false
setLoadedTabs(prev => {
hasBeenLoaded = prev.has('reads')
return prev
})
try {
if (!hasBeenLoaded) setLoading(true)
@@ -267,12 +264,16 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, relayPool, eventStore])
const loadLinksTab = async () => {
const loadLinksTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
let hasBeenLoaded = false
setLoadedTabs(prev => {
hasBeenLoaded = prev.has('links')
return prev
})
try {
if (!hasBeenLoaded) setLoading(true)
@@ -307,10 +308,10 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
// Load active tab data
useEffect(() => {
const loadActiveTab = useCallback(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
@@ -343,8 +344,11 @@ const Me: React.FC<MeProps> = ({
loadLinksTab()
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
}, [viewingPubkey, activeTab, loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab, loadLinksTab])
useEffect(() => {
loadActiveTab()
}, [loadActiveTab])
// Sync myHighlights from controller
useEffect(() => {
@@ -489,6 +493,7 @@ const Me: React.FC<MeProps> = ({
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply bookmark filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
@@ -560,27 +565,6 @@ const Me: React.FC<MeProps> = ({
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
// Debug logs for archive filter issues
if (readingProgressFilter === 'archive') {
const ids = Array.from(new Set([
...archiveController.getMarkedIds(),
...readingProgressController.getMarkedAsReadIds()
]))
const readIds = new Set(reads.map(i => i.id))
const matches = ids.filter(id => readIds.has(id))
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
console.log('[archive][me] counts', {
reads: reads.length,
filteredReads: filteredReads.length,
links: links.length,
linksWithProgress: linksWithProgress.length,
filteredLinks: filteredLinks.length,
markedIds: ids.length,
sampleMarked: ids.slice(0, 3),
matches: matches.length,
nonMatches
})
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
@@ -846,6 +830,7 @@ const Me: React.FC<MeProps> = ({
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
hideBotByName={settings.hideBotArticlesByName !== false}
/>
))}
</div>

View 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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core'
@@ -8,8 +8,8 @@ import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { getActiveRelayUrls } from '../services/relayManager'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
@@ -57,6 +57,15 @@ const Profile: React.FC<ProfileProps> = ({
[pubkey]
)
// Sort writings by publication date, newest first
const sortedWritings = useMemo(() => {
return cachedWritings.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [cachedWritings])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -98,24 +107,14 @@ const Profile: React.FC<ProfileProps> = ({
useEffect(() => {
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)
.then(() => {
// Highlights fetched
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
})
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
})
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh
@@ -168,7 +167,7 @@ const Profile: React.FC<ProfileProps> = ({
}
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
const renderTabContent = () => {
switch (activeTab) {
@@ -209,13 +208,13 @@ const Profile: React.FC<ProfileProps> = ({
</div>
)
}
return cachedWritings.length === 0 ? (
return sortedWritings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
{cachedWritings.map((post) => (
{sortedWritings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}

View 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

View File

@@ -6,11 +6,13 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
import ExploreSettings from './Settings/ExploreSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import TTSSettings from './Settings/TTSSettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
import VersionFooter from './VersionFooter'
@@ -39,9 +41,15 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
fullWidthImages: true,
renderVideoLinksAsEmbeds: true,
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
ttsUseSystemLanguage: false,
ttsDetectContentLanguage: true,
ttsLanguageMode: 'content',
ttsDefaultSpeed: 2.1,
}
interface SettingsProps {
@@ -169,8 +177,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />

View File

@@ -51,6 +51,19 @@ const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate })
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
<input
id="hideBotArticlesByName"
type="checkbox"
checked={settings.hideBotArticlesByName !== false}
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
className="setting-checkbox"
/>
<span>Hide content posted by bots</span>
</label>
</div>
</div>
)
}

View 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

View File

@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">

View 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

View 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>
)
}

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
import { fetchProfiles } from '../services/profileService'
import { UserSettings } from '../services/settingsService'
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(false)
useEffect(() => {
const loadSupporters = async () => {
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
if (zappers.length > 0) {
const pubkeys = zappers.map(z => z.pubkey)
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
// Fetch profiles in background without blocking
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
}
setSupporters(zappers)
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
loadSupporters()
}, [relayPool, eventStore, settings])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
</div>
)
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
</p>
</div>
{supporters.length === 0 ? (
{loading ? (
<>
{/* Loading Skeletons */}
<div className="mb-16 md:mb-20">
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
Legends
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
{Array.from({ length: 3 }).map((_, i) => (
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
))}
</div>
</div>
<div className="mb-12">
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
Supporters
</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
{Array.from({ length: 12 }).map((_, i) => (
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
))}
</div>
</div>
</>
) : supporters.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
<p>No supporters yet. Be the first to zap Boris!</p>
</div>
@@ -231,5 +249,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
)
}
interface SupporterSkeletonProps {
isWhale: boolean
}
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
return (
<div className="flex flex-col items-center">
<div className="relative">
{/* Avatar Skeleton */}
<div
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
`}
style={{
backgroundColor: 'var(--color-bg-elevated)'
}}
>
<div
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
</div>
{/* Whale Badge Skeleton */}
{isWhale && (
<div
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
style={{
backgroundColor: 'var(--color-border)',
borderColor: 'var(--color-bg)'
}}
/>
)}
</div>
{/* Name and Total Skeleton */}
<div className="mt-2 text-center space-y-1">
<div
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
<div
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
style={{ backgroundColor: 'var(--color-border)' }}
/>
</div>
</div>
)
}
export default Support

View 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

View 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
View 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)
}

View File

@@ -11,12 +11,11 @@ export const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
]

View File

@@ -1,4 +1,4 @@
import { useEffect, Dispatch, SetStateAction } from 'react'
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
@@ -36,18 +36,26 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
const loadArticle = async () => {
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!mountedRef.current) return
setReaderContent({
title: article.title,
markdown: article.markdown,
@@ -63,24 +71,22 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
// Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
// Stream them as they arrive for instant rendering
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Merge streaming results with existing UI state to preserve locally created highlights
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
@@ -92,19 +98,40 @@ export function useArticleLoader({
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
if (mountedRef.current) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
}
}
}
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
])
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useRef, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -48,6 +48,8 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
@@ -63,33 +65,37 @@ export function useExternalUrlLoader({
// Load content and start streaming highlights when URL changes
useEffect(() => {
mountedRef.current = true
if (!relayPool || !url) return
const loadExternalUrl = async () => {
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
setReaderContent(content)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights((prev) => {
// Seed with cache but keep any locally created highlights already in state
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
const localOnly = prev.filter(h => !seen.has(h.id))
const next = [...cachedUrlHighlights, ...localOnly]
@@ -99,16 +105,16 @@ export function useExternalUrlLoader({
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (!mountedRef.current) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
@@ -117,32 +123,51 @@ export function useExternalUrlLoader({
return next.sort((a, b) => b.created_at - a.created_at)
})
},
undefined, // settings
false, // force
undefined,
false,
eventStore || undefined
)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
if (mountedRef.current) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
}
}
}
loadExternalUrl()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
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(() => {

View 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, [])
}

View File

@@ -29,6 +29,7 @@ export const useReadingPosition = ({
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedAtRef = useRef<number>(0)
// Debounced save function
const scheduleSave = useCallback((currentPosition: number) => {
@@ -36,14 +37,49 @@ export const useReadingPosition = ({
return
}
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
const isInitialSave = !hasSavedOnce.current
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
// Not significant enough to save
// Always save instantly when we reach completion (1.0)
if (currentPosition === 1 && lastSavedPosition.current < 1) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
lastSavedPosition.current = 1
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
}
@@ -52,27 +88,26 @@ export const useReadingPosition = ({
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(() => {
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSave(currentPosition)
}, autoSaveInterval)
}, delay)
}, [syncEnabled, onSave, autoSaveInterval])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabled || !onSave) return
// Cancel any pending saves
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
// Always allow immediate save (including 0%)
lastSavedPosition.current = position
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSave(position)
}, [syncEnabled, onSave, position])
@@ -97,13 +132,6 @@ export const useReadingPosition = ({
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
// Only log on significant changes (every 5%) to avoid flooding console
const prevPercent = Math.floor(position * 20) // Groups by 5%
const newPercent = Math.floor(clampedProgress * 20)
if (prevPercent !== newPercent) {
// Position threshold crossed
}
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
@@ -160,9 +188,7 @@ export const useReadingPosition = ({
clearTimeout(completionTimerRef.current)
}
}
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {

View File

@@ -16,7 +16,7 @@ interface UseSettingsParams {
}
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
const [settings, setSettings] = useState<UserSettings>({})
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [toastType, setToastType] = useState<'success' | 'error'>('success')
@@ -27,7 +27,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const loadAndWatch = async () => {
try {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
if (loadedSettings) setSettings(loadedSettings)
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
} catch (err) {
console.error('Failed to load settings:', err)
}
@@ -36,7 +36,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
loadAndWatch()
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings(loadedSettings)
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => subscription.unsubscribe()
@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
// Set image max-width based on full-width setting
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
}
applyStyles()

View 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])
}

View File

@@ -3,7 +3,6 @@ import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
@@ -35,14 +34,12 @@ class ArchiveController {
if (!this.markedIds.has(id)) {
this.markedIds.add(id)
this.emit()
console.log('[archive] mark() added', id.slice(0, 48))
}
}
unmark(id: string): void {
if (this.markedIds.delete(id)) {
this.emit()
console.log('[archive] unmark() removed', id.slice(0, 48))
}
}
@@ -61,7 +58,7 @@ class ArchiveController {
reset(): void {
this.generation++
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
this.markedIds = new Set()
@@ -80,13 +77,11 @@ class ArchiveController {
const startGen = this.generation
if (!force && this.isLoadedFor(pubkey)) {
console.log('[archive] start() skipped - already loaded for pubkey')
return
}
// Mark as loaded immediately (fetch runs non-blocking)
this.lastLoadedPubkey = pubkey
console.log('[archive] start() begin for pubkey:', pubkey.slice(0, 12), '...')
// Handlers for streaming queries
const handleUrlReaction = (evt: NostrEvent) => {
@@ -95,7 +90,6 @@ class ArchiveController {
if (!rTag) return
this.markedIds.add(rTag)
this.emit()
console.log('[archive] mark url:', rTag)
}
const handleEventReaction = (evt: NostrEvent) => {
@@ -110,7 +104,6 @@ class ArchiveController {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
this.markedIds.add(naddr)
this.emit()
console.log('[archive] mark naddr via a-tag:', naddr.slice(0, 24), '...')
return
}
} catch { /* ignore malformed a-tag */ }
@@ -118,14 +111,13 @@ class ArchiveController {
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
this.pendingEventIds.add(eTag)
console.log('[archive] pending event id:', eTag)
}
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
@@ -133,27 +125,23 @@ class ArchiveController {
// Include EOSE events
kind17.forEach(handleUrlReaction)
kind7.forEach(handleEventReaction)
console.log('[archive] EOSE sizes kind17:', kind17.length, 'kind7:', kind7.length, 'pendingEventIds:', this.pendingEventIds.size)
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
console.log('[archive] fetched articles for mapping:', articleEvents.length)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] mark naddr:', naddr.slice(0, 24), '...')
} catch {
// skip invalid
}
}
this.emit()
}
console.log('[archive] total marked ids:', this.markedIds.size)
// Try immediate mapping via eventStore for any still-pending e-ids
if (this.pendingEventIds.size > 0) {
@@ -167,7 +155,6 @@ class ArchiveController {
if (dTag) {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] map via eventStore naddr:', naddr.slice(0, 24), '...')
}
} else {
stillPending.add(eId)
@@ -178,7 +165,7 @@ class ArchiveController {
if (stillPending.size > 0) {
// Subscribe to future 30023 arrivals to finalize mapping
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
this.timelineSubscription = null
}
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
@@ -193,16 +180,14 @@ class ArchiveController {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
this.pendingEventIds.delete(evt.id)
console.log('[archive] map via timeline naddr:', naddr.slice(0, 24), '...')
this.emit()
} catch (e) { console.warn('[archive] map via timeline encode error', e) }
} catch { /* ignore */ }
}
})
}
}
} catch (err) {
// Non-blocking fetch; ignore errors here
console.warn('[archive] start() error:', err)
}
}
}

View File

@@ -62,7 +62,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
export const processApplesauceBookmarks = (
bookmarks: unknown,
activeAccount: ActiveAccount,
isPrivate: boolean
isPrivate: boolean,
parentCreatedAt?: number
): IndividualBookmark[] => {
if (!bookmarks) return []
@@ -76,14 +77,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -96,14 +97,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -114,14 +115,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -132,14 +133,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `url-${url}`,
content: url,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -153,14 +154,14 @@ export const processApplesauceBookmarks = (
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
added_at: bookmark.created_at || parentCreatedAt || 0
}))
}
@@ -169,29 +170,35 @@ export function hydrateItems(
items: IndividualBookmark[],
idToEvent: Map<string, NostrEvent>
): IndividualBookmark[] {
return items.map(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 || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
return items
.map(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 || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
}
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
.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`

View File

@@ -64,7 +64,7 @@ async function decryptEvent(
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItems.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -84,7 +84,7 @@ async function decryptEvent(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItems.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -155,7 +155,7 @@ export async function collectBookmarksFromEvents(
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -181,7 +181,7 @@ export async function collectBookmarksFromEvents(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

View File

@@ -1,6 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
@@ -22,6 +22,7 @@ export interface BlogPostPreview {
* @param relayUrls - Array of relay URLs to query
* @param onPost - Optional callback for streaming posts
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
@@ -29,7 +30,8 @@ export const fetchBlogPostsFromAuthors = async (
pubkeys: string[],
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void,
limit: number | null = 100
limit: number | null = 100,
eventStore?: IEventStore
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -45,12 +47,17 @@ export const fetchBlogPostsFromAuthors = async (
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
: { kinds: [KINDS.BlogPost], authors: pubkeys }
await queryEvents(
const events = await queryEvents(
relayPool,
filter,
{
relayUrls,
onEvent: (event: NostrEvent) => {
// Store in event store immediately if provided
if (eventStore) {
eventStore.add(event)
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
@@ -73,6 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
}
)
// Store all events in event store if provided (safety net for any missed during streaming)
if (eventStore) {
events.forEach(evt => eventStore.add(evt))
}
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())

View File

@@ -1,7 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { ARCHIVE_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
@@ -30,8 +29,8 @@ export async function fetchReadArticles(
try {
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
])
const readArticles: ReadArticle[] = []
@@ -115,8 +114,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], ids: eventIds }
)
// Deduplicate article events by ID

View File

@@ -2,8 +2,8 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { EventFactory } from 'applesauce-factory'
import { getActiveRelayUrls } from './relayManager'
const ARCHIVE_EMOJI = '📚'
@@ -35,7 +35,6 @@ export async function createEventReaction(
]
if (options?.aCoord) {
tags.push(['a', options.aCoord])
console.log('[archive] createEventReaction add a-tag:', options.aCoord)
}
const draft = await factory.create(async () => ({
@@ -49,7 +48,7 @@ export async function createEventReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
@@ -99,7 +98,7 @@ export async function createWebsiteReaction(
// Publish to relays
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
@@ -122,7 +121,7 @@ export async function deleteReaction(
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
await relayPool.publish(RELAYS, signed)
await relayPool.publish(getActiveRelayUrls(relayPool), signed)
return signed
}
@@ -146,7 +145,7 @@ export async function hasMarkedEventAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),
@@ -199,7 +198,7 @@ export async function hasMarkedWebsiteAsRead(
}
const events$ = relayPool
.req(RELAYS, filter)
.req(getActiveRelayUrls(relayPool), filter)
.pipe(
onlyEvents(),
completeOnEose(),

View File

@@ -3,13 +3,11 @@ import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
console.log('[readingProgress] Module loaded')
type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
@@ -176,17 +174,14 @@ class ReadingProgressController {
const { relayPool, eventStore, pubkey, force = false } = params
const startGeneration = this.generation
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
// Skip if already loaded for this pubkey and not forcing
if (!force && this.isLoadedFor(pubkey)) {
console.log('[readingProgress] Already loaded for pubkey, skipping')
return
}
// Prevent concurrent starts
if (this.isLoading) {
console.log('[readingProgress] Already loading, skipping concurrent start')
return
}
@@ -212,7 +207,6 @@ class ReadingProgressController {
this.timelineSubscription = null
}
console.log('[readingProgress] Setting up eventStore subscription...')
const timeline$ = eventStore.timeline({
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
@@ -223,20 +217,17 @@ class ReadingProgressController {
if (!Array.isArray(localEvents) || localEvents.length === 0) return
this.processEvents(localEvents)
})
console.log('[readingProgress] EventStore subscription ready - updates streaming')
// Mark as loaded immediately - queries run in background non-blocking
this.lastLoadedPubkey = pubkey
// Query reading progress from relays in background (non-blocking, fire-and-forget)
console.log('[readingProgress] Starting background relay query for reading progress...')
queryEvents(relayPool, {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}, { relayUrls: RELAYS })
})
.then((relayEvents) => {
if (startGeneration !== this.generation) return
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
if (relayEvents.length > 0) {
relayEvents.forEach(e => eventStore.add(e))
this.processEvents(relayEvents)
@@ -249,10 +240,8 @@ class ReadingProgressController {
})
// Load mark-as-read reactions in background (non-blocking, streaming)
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
.then(() => {
console.log('[readingProgress] Mark-as-read reactions loading complete')
})
.catch((err) => {
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
@@ -265,9 +254,6 @@ class ReadingProgressController {
this.setLoading(false)
}
this.isLoading = false
console.log('[readingProgress] === LOADED ===')
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
}
}
@@ -318,7 +304,6 @@ class ReadingProgressController {
): Promise<void> {
try {
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
const seenReactionIds = new Set<string>()
const handleUrlReaction = (evt: NostrEvent) => {
@@ -343,8 +328,8 @@ class ReadingProgressController {
// Fire queries with onEvent callbacks for streaming behavior
const [kind17Events, kind7Events] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
])
if (generation !== this.generation) return
@@ -356,7 +341,7 @@ class ReadingProgressController {
if (pendingEventIds.size > 0) {
// Fetch referenced 30023 events, streaming not required here
const ids = Array.from(pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
const eventIdToNaddr = new Map<string, string>()
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
@@ -379,7 +364,6 @@ class ReadingProgressController {
this.emitMarkedAsReadChanged()
}
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
} catch (err) {
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
}

View File

@@ -3,7 +3,6 @@ import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
@@ -44,7 +43,7 @@ export async function fetchAllReads(
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
fetchReadArticles(relayPool, userPubkey)
])
@@ -130,8 +129,7 @@ export async function fetchAllReads(
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
)
// Merge event data into ReadItems and emit

View 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)
}

View File

@@ -0,0 +1,84 @@
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'
]
/**
* 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)
}
}
}

View File

@@ -58,11 +58,21 @@ export interface UserSettings {
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify
fullWidthImages?: boolean // default: false
renderVideoLinksAsEmbeds?: boolean // default: false
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false
// Content filtering
hideBotArticlesByName?: boolean // default: true - hide authors whose profile name includes "bot"
// TTS language selection
ttsUseSystemLanguage?: boolean // default: false
ttsDetectContentLanguage?: boolean // default: true
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
// Text-to-Speech settings
ttsDefaultSpeed?: number // default: 2.1
}
export async function loadSettings(

View File

@@ -1,9 +1,9 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
import { getActiveRelayUrls } from './relayManager'
/**
* Unified write helper: add event to EventStore, detect connectivity,
@@ -27,10 +27,13 @@ export async function publishEvent(
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
// Get active relay URLs from the pool
const activeRelays = getActiveRelayUrls(relayPool)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
? activeRelays
: activeRelays.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
@@ -42,7 +45,7 @@ export async function publishEvent(
}
// Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event)
relayPool.publish(activeRelays, event)
.then(() => {
})
.catch((error) => {

View File

@@ -43,7 +43,7 @@
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: var(--color-primary); }
.font-select option { padding: 0.5rem; font-size: 1rem; }

View File

@@ -29,7 +29,7 @@
.login-highlight {
background-color: var(--highlight-color-mine, #fde047);
color: var(--color-text);
color: #000000;
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-weight: 500;

View File

@@ -14,12 +14,12 @@
/* Video container - responsive wrapper following react-player docs */
.reader-video {
position: relative;
width: 80vw; /* 80% of viewport width */
min-width: 400px; /* Minimum width */
max-width: 1000px; /* Maximum width */
width: 100%;
max-width: 100%;
aspect-ratio: 16/9;
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
margin: 0 0 1rem 0; /* align with reader padding, no bleed */
background: rgb(0 0 0); /* black */
overflow: hidden;
}
.reader.empty { color: var(--color-text-secondary); }
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
@@ -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-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
.reader .reader-html img, .reader .reader-markdown img {
max-width: var(--image-max-width, 100%);
max-height: 70vh;
height: auto;
width: auto;
display: block;
margin: 0.75rem auto;
border-radius: 6px;
}
/* Headlines with Tailwind typography */
.reader-markdown h1, .reader-html h1 {
font-size: 2.25rem; /* text-4xl */

View File

@@ -98,10 +98,42 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
}
})
// Log fetch errors for debugging (doesn't affect functionality)
// Handle Web Share Target POST requests
sw.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
// Handle POST to /share-target (Web Share Target API)
if (event.request.method === 'POST' && url.pathname === '/share-target') {
event.respondWith((async () => {
const formData = await event.request.formData()
const title = (formData.get('title') || '').toString()
const text = (formData.get('text') || '').toString()
// Accept multiple possible field names just in case different casings are used
let link = (
formData.get('link') ||
formData.get('Link') ||
formData.get('url') ||
''
).toString()
// Android often omits url param, extract from text
if (!link && text) {
const urlMatch = text.match(/https?:\/\/[^\s]+/)
if (urlMatch) {
link = urlMatch[0]
}
}
const queryParams = new URLSearchParams()
if (link) queryParams.set('link', link)
if (title) queryParams.set('title', title)
if (text) queryParams.set('text', text)
return Response.redirect(`/share-target?${queryParams.toString()}`, 303)
})())
return
}
// Don't interfere with WebSocket connections (relay traffic)
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
return

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
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) => {
const date = new Date(timestamp * 1000)

View File

@@ -114,6 +114,17 @@ export default defineConfig({
background_color: '#0b1220',
orientation: 'any',
categories: ['productivity', 'social', 'utilities'],
// Web Share Target configuration so the installed PWA shows up in the system share sheet
share_target: {
action: '/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'link'
}
},
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },