Compare commits

...

160 Commits

Author SHA1 Message Date
Gigi
a1a7f0e4a4 chore: bump version to 0.10.9 2025-10-22 01:41:14 +02:00
Gigi
cde8e30ab2 fix(events): improve /e/ reliability with retry + backoff in eventManager
- Add multi-attempt fetch with backoff
- Retry on not-found, errors, and timeouts before failing
- Keep deduplication and cache-first behavior
2025-10-22 01:40:26 +02:00
Gigi
aa7e532950 fix(bookmarks): use per-item added_at/created_at when available
- Read / from applesauce pointers for notes/articles
- Fallback to eventStore event  during enrichment
- Keeps sorting by  then  consistent
2025-10-22 01:35:06 +02:00
Gigi
c9208cfff2 chore: remove all debug console logs
- Remove console.log from bookmark hydration
- Remove console.log from relay initialization
- Remove all console.debug calls from TTS hook and controls
- Remove debug logging from RouteDebug component
- Fix useCallback dependency warning in speak function
2025-10-22 01:26:42 +02:00
Gigi
2fb4132342 docs: update CHANGELOG for v0.10.8 2025-10-22 01:25:41 +02:00
Gigi
81180c8ba8 chore: bump version to 0.10.8 2025-10-22 01:23:13 +02:00
Gigi
1c48adf44e Merge pull request #23 from dergigi/e-path
feat: add /e/:eventId path for individual event rendering
2025-10-22 01:22:52 +02:00
Gigi
366e10b23a feat(/e/): check eventStore first for author profile
- Try to load author profile from eventStore cache first
- Only fetch from relays if not found in cache
- Instant title update if profile already loaded
2025-10-22 01:19:09 +02:00
Gigi
bb66823915 fix(/e/): Search button opens note via /e/ path not search portal
- For kind:1 notes, open directly via /e/{eventId}
- For articles (kind:30023), continue using search portal
- Removes nostr-event: prefix in URLs
2025-10-22 01:18:51 +02:00
Gigi
f09973c858 feat(/e/): display publication date in top-right like articles
- Remove inline metadata HTML from note content
- Pass event.created_at as published timestamp via ReadableContent
- ReaderHeader now displays date in top-right corner
2025-10-22 01:18:14 +02:00
Gigi
d03726801d feat(/e/): title 'Note by @author' with background profile fetch
- Immediate fallback title using short pubkey
- Fetch kind:0 profile in background; update title when available
- Keeps UI responsive while improving attribution
2025-10-22 01:16:30 +02:00
Gigi
164e941a1f fix(events): make direct event loading robust
- Add completion and timeout handling to eventManager.fetchEvent
- Resolve/reject all pending promises correctly
- Prevent silent completes when event not found
- Improves /e/:eventId reliability on cold loads
2025-10-22 01:09:36 +02:00
Gigi
6def58f128 fix(bookmarks): show eventStore content as fallback for bookmarks without hydrated content
- Enrich bookmarks with content from externalEventStore when hydration hasn't populated yet
- Keeps sidebar from showing only event IDs while background hydration continues
2025-10-22 01:04:23 +02:00
Gigi
347e23ff6f fix: only request hydration for items without content
- Only fetch events for bookmarks that don't have content yet
- Bookmarks with existing content (web bookmarks, etc.) don't need fetching
- This reduces unnecessary fetches and focuses on what's needed
- Should show much better content in bookmarks list
2025-10-22 01:01:23 +02:00
Gigi
934768ebf2 chore: remove debug logging from hydration 2025-10-22 01:01:04 +02:00
Gigi
60e9ede9cf debug: add more detail to hydration logging 2025-10-22 00:59:06 +02:00
Gigi
c70e6bc2aa debug: log hydration progress to track content population
- Add logging to see how many hydrated items have content
- This will help diagnose why bookmarks are showing IDs instead of content
2025-10-22 00:57:47 +02:00
Gigi
ab8665815b chore: remove debug logging from bookmarkHelpers
- Remove 'NO MATCHES' debug logs from hydrateItems
- Console is now clean, hydration is working properly
2025-10-22 00:56:40 +02:00
Gigi
1929b50892 fix: properly implement eventManager with promise-based API
- Fix eventManager to handle async fetching with proper promise resolution
- Track pending requests and deduplicate concurrent requests for same event
- Auto-retry when relay pool becomes available
- Resolve all pending callbacks when event arrives
- Update useEventLoader to use eventManager.fetchEvent
- Simplify useEventLoader with just one effect for fetching
- Handles both instant cache hits and deferred relay fetching
2025-10-22 00:55:20 +02:00
Gigi
160dca628d fix: simplify eventManager and restore working event fetching
- Revert eventManager to simpler role: initialization and service coordination
- Restore original working fetching logic in useEventLoader
- eventManager now provides: getCachedEvent, getEventLoader, setServices
- Fixes broken bookmark hydration and direct event loading
- Uses eventManager for cache checking but direct subscription for fetching
2025-10-22 00:54:33 +02:00
Gigi
c04ba0c787 feat: add centralized eventManager for event fetching
- Create eventManager singleton for fetching and caching events
- Handles deduplication of concurrent requests for same event
- Waits for relay pool to become available before fetching
- Provides both async/await and callback-based APIs
- Update useEventLoader to use eventManager instead of direct loader
- Simplifies event fetching logic and enables better reuse across app
2025-10-22 00:52:15 +02:00
Gigi
479d9314bd fix: make event loading non-blocking and wait for relay pool
- Don't show error if relayPool isn't available yet
- Instead, keep loading state and wait for relayPool to become available
- Effect will re-run automatically when relayPool is set
- Enables smooth loading when navigating directly to /e/ URLs on page load
- Fetching happens in background without blocking user
2025-10-22 00:50:14 +02:00
Gigi
b9d5e501f4 improve: better error messages when direct event loading fails
- Show error if relayPool is not available when loading direct URL
- Improved error message wording to be clearer
- These messages will help diagnose direct /e/ path loading issues
2025-10-22 00:49:50 +02:00
Gigi
43e0dd76c4 fix: don't show user highlights when viewing events on /e/ path
- Set selectedUrl and ReadableContent url to empty string for events
- This prevents ThreePaneLayout from displaying user highlights for event views
- Events should only show event-specific content, not global user highlights
- Fixes issue where 422 highlights were always shown for all notes
2025-10-22 00:48:43 +02:00
Gigi
dc9a49e895 chore: remove debug logging from event loader and compact view
- Remove debug logs from useEventLoader hook
- Remove debug logs from Bookmarks component
- Remove empty kind:1 bookmark debug logging from CompactView
- Clean console output now that features are working correctly
2025-10-22 00:46:44 +02:00
Gigi
3200bdf378 fix: add hydrated bookmark events to global eventStore
- bookmarkController now accepts eventStore in start() options
- All hydrated events (both by ID and by coordinates) are added to the external eventStore
- This makes hydrated bookmark events available to useEventLoader and other hooks
- Fixes issue where /e/ path couldn't find events because they weren't in the global eventStore
- Now instant loading works for all bookmarked events
2025-10-22 00:42:25 +02:00
Gigi
2254586960 perf: check eventStore before setting loading state for instant cached event display
- Synchronously check eventStore first before setting loading state
- If event is cached, display it immediately without loading spinner
- Only set loading state if event not found in cache
- Provides instant display of events that are already hydrated
- Improves perceived performance when navigating to bookmarked events
2025-10-22 00:38:42 +02:00
Gigi
18c78c19be fix: render events as plain text html instead of markdown
- kind:1 notes are plain text, not markdown
- Changed from markdown to html rendering
- HTML-escape content to prevent injection
- Preserve whitespace and newlines for plain text display
- Display event metadata in styled HTML header
2025-10-22 00:36:55 +02:00
Gigi
167d5f2041 fix: clear reader content when loading event and set proper selectedUrl
- Clear readerContent at start of loading to ensure old content doesn't persist
- Set selectedUrl to nostr:eventId to match pattern used in other loaders
- This ensures consistent behavior across all content loaders
2025-10-22 00:35:33 +02:00
Gigi
cce7507e50 fix: properly extract eventId from route params
- Add eventId to useParams instead of manually parsing pathname
- useParams automatically extracts eventId from /e/:eventId route
- Add debug logging to track event loading
- This fixes the issue where eventId wasn't being passed to useEventLoader
2025-10-22 00:30:54 +02:00
Gigi
e83d4dbcdb feat: render notes like articles with markdown processing
- Change useEventLoader to set markdown instead of html
- Notes now get proper markdown processing and rendering similar to articles
- Use markdown comments for event metadata instead of HTML
- This enables proper styling and markdown features for note display
2025-10-22 00:28:29 +02:00
Gigi
a5bdde68fc fix: resolve all linter and type check errors
- Fix mergeMap concurrency syntax (pass as second parameter, not object)
- Fix type casting in CompactView debug logging
- Update useEventLoader to use ReadableContent type
- Fix eventStore type compatibility in useEventLoader
- All linter and TypeScript checks now pass
2025-10-22 00:27:45 +02:00
Gigi
5551cc3a55 feat: add relay.nostr.band as hardcoded relay
- Create HARDCODED_RELAYS constant with relay.nostr.band
- Always include hardcoded relays in relay pool
- Update computeRelaySet calls to use HARDCODED_RELAYS
- Ensures we can fetch events even if user has no relay list
- relay.nostr.band is a public searchable relay that indexes all events
2025-10-22 00:23:01 +02:00
Gigi
145ff138b0 feat: integrate event viewer into three-pane layout for /e/:eventId
- Create useEventLoader hook to fetch and display individual events
- Events display in middle pane with metadata (ID, timestamp, kind)
- Integrates with existing Bookmarks three-pane layout
- Remove standalone EventViewer component
- Route /e/:eventId now uses Bookmarks component
- Metadata displayed above event content for context
2025-10-22 00:22:04 +02:00
Gigi
5bd5686805 feat: add /e/:eventId route to display individual notes
- New EventViewer component to display kind:1 notes and other events
- Shows event ID, creation time, and content with RichContent rendering
- Add /e/:eventId route in App.tsx
- Update CompactView to navigate to /e/:eventId when clicking kind:1 bookmarks
- Mobile-optimized styling with back button and full viewport display
- Fallback for missing events with error message
2025-10-22 00:19:20 +02:00
Gigi
d2c1a16ca6 chore: remove verbose debug logging from hydration
- Clean up console output after diagnosing ID mismatch issue
- Keep error logging for when matches aren't found
- Deduplication before hydration now working
2025-10-22 00:17:03 +02:00
Gigi
b8242312b5 fix: deduplicate bookmarks before requesting hydration
- Collect all items, then dedupe before separating IDs/coordinates
- Prevents requesting hydration for 410 duplicate items
- Only requests ~96 unique event IDs instead
- Events are still hydrated for both public and private lists
- Dedupe after combining hydrated results
2025-10-22 00:15:27 +02:00
Gigi
96ef227f79 debug: log all fetched events to identify ID mismatch
- Show sample of note IDs being requested
- Log every event fetched with kind and content length
- Helps diagnose why kind:1 events aren't in the hydration map
2025-10-22 00:13:38 +02:00
Gigi
30ed5fb436 fix: batch event hydration with concurrency limit
- Replace merge(...map(eventLoader)) with mergeMap concurrency: 5
- Prevents overwhelming relays with 96+ simultaneous requests
- EventLoader now properly throttles to 5 concurrent requests at a time
- Fixes issue where only ~7 out of 96 events were being fetched
2025-10-22 00:12:34 +02:00
Gigi
42d7143845 debug: add logging for event ID requests
- Log how many note IDs and coordinates we're requesting
- Log how many unique event IDs are passed to EventLoader
- Track if all bookmarks are being requested for hydration
2025-10-22 00:11:06 +02:00
Gigi
f02bc21faf debug: simplify hydration logging for easier diagnosis
- Show how many items were matched in the map
- If zero matches, show actual IDs from both sides
- Makes it easy to see ID mismatch issues
2025-10-22 00:10:13 +02:00
Gigi
0f5d42465d debug: add detailed logging to hydrateItems
- Log which kind:1 items are being processed
- Show how many match events in the idToEvent map
- Compare sample IDs from items vs map keys
- Identify ID mismatch issue between bookmarks and fetched events
2025-10-22 00:08:47 +02:00
Gigi
004367bab6 debug: log the actual Bookmark object being emitted to component
- Show what's actually in individualBookmarks when emitted
- Check if content is present in the emitted object vs what component receives
- Identify if the issue is in hydration or state propagation
2025-10-22 00:05:04 +02:00
Gigi
312adea9f9 debug: add hydration logging to diagnose empty bookmarks
- Log when kind:1 events are fetched from relays
- Log when bookmarks are emitted with hydration status
- Track how many events are in the idToEvent map
- Check if event IDs match between bookmarks and fetched events
2025-10-22 00:03:14 +02:00
Gigi
a081b26333 feat: show event IDs for empty bookmarks and add debug logging
- Display event ID (first 12 chars) when bookmark content is missing
- Shows ID in dimmed code font as fallback for empty items
- Add debug console logging to identify which bookmarks are empty
- Helps diagnose hydration issues and identify events that aren't loading
2025-10-22 00:02:11 +02:00
Gigi
51e48804fe debug: remove console logging for kind:1 hydration
- Removed 📝, 💧, 🎨 and 📊 debug logs
- These were added for troubleshooting but are no longer needed
- Kind:1 content hydration and rendering is working correctly
2025-10-21 23:58:16 +02:00
Gigi
e08ce0e477 debug: add BookmarkList logging to track kind:1 filtering
- Log how many kind:1 bookmarks make it past the hasContent filter
- Show sample content to verify hydration is reaching the list
- Help identify where bookmarks are being filtered out
2025-10-21 23:55:10 +02:00
Gigi
2791c69ebe debug: add logging to CompactView to diagnose missing content rendering
- Log when kind:1 without URLs is being rendered
- Check if bookmark.content is actually present at render time
- Help diagnose why text isn't displaying even though it's hydrated
2025-10-21 23:54:15 +02:00
Gigi
96451e6173 debug: add logging to track kind:1 event hydration
- Log when kind:1 events are fetched by EventLoader
- Log when kind:1 events are hydrated with content
- Helps diagnose why text content isn't displaying for bookmarked notes
2025-10-21 23:52:39 +02:00
Gigi
d20cc684c3 feat: ensure kind:1 events display their text content in bookmarks bar
- Update hydrateItems to parse content for all events with text
- Previously, kind:1 events without URLs would appear empty in the bookmarks list
- Now any kind:1 event will display its text content appropriately
- Improves handling of short-form text notes in bookmarks
2025-10-21 23:50:12 +02:00
Gigi
4316c46a4d docs: update CHANGELOG for v0.10.7 2025-10-21 23:40:05 +02:00
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
47 changed files with 2163 additions and 487 deletions

View File

@@ -7,6 +7,313 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.8] - 2025-10-21
### Added
- Individual event rendering via `/e/:eventId` path
- Display `kind:1` notes and other events with article-like presentation
- Publication date displayed in top-right corner like articles
- Author attribution with "Note by @author" titles
- Direct event loading with intelligent caching from eventStore
- Centralized event fetching via new `eventManager` singleton
- Request deduplication for concurrent fetches
- Automatic retry logic when relay pool becomes available
- Non-blocking background fetching with 12-second timeout
- Seamless integration with eventStore for instant cached event display
### Fixed
- Bookmark hydration efficiency
- Only request content for bookmarks missing data (not all bookmarks)
- Use eventStore fallback for instant display of cached profiles
- Prevents over-fetching and improves initial load performance
- Search button behavior for notes
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
- Articles continue to use search portal with proper naddr encoding
- Removes unwanted `nostr-event:` prefix from URLs
- Author profile resolution
- Fetch author profiles from eventStore cache first before relay requests
- Instant title updates if profile already loaded
- Graceful fallback to short pubkey display if profile unavailable
## [0.10.7] - 2025-10-21
### Fixed
- Profile pages now display all writings correctly
- Events are now stored in eventStore as they stream in from relays
- `fetchBlogPostsFromAuthors` now accepts `eventStore` parameter like other fetch functions
- Ensures all writings appear on `/p/` routes, not just the first few
- Background fetching of highlights and writings uses consistent patterns
### Changed
- Simplified profile background fetching logic for better maintainability
- Extracted relay URLs to variable for clarity
- Consistent error handling patterns across fetch functions
- Clearer comments about no-limit fetching behavior
## [0.10.6] - 2025-10-21
### Added
- Text-to-speech reliability improvements
- Chunking support for long-form content to prevent WebSpeech API cutoffs
- Automatic chunk-based resumption for interrupted playback
- Better handling of content exceeding browser TTS limits
### Fixed
- Tab switching regression on `/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
@@ -2111,7 +2418,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.9.0",
"version": "0.10.9",
"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,18 +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 { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
@@ -93,7 +95,7 @@ function AppRoutes({
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
bookmarkController.start({ relayPool, activeAccount, accountManager })
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
}
// Load contacts
@@ -158,6 +160,10 @@ function AppRoutes({
return (
<Routes>
<Route
path="/share-target"
element={<ShareTargetHandler relayPool={relayPool} />}
/>
<Route
path="/a/:naddr"
element={
@@ -342,6 +348,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/e/:eventId"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/debug"
element={
@@ -386,23 +404,14 @@ 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()
}
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('[relay-init] Initial pool setup - added RELAYS:', RELAYS.length, 'relays')
console.log('[relay-init] Pool now has:', Array.from(pool.relays.keys()).length, 'relays')
// Load persisted accounts from localStorage
try {
@@ -569,8 +578,6 @@ function App() {
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
if (account) {
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
const pubkey = account.pubkey
@@ -582,7 +589,7 @@ function App() {
const signerData = nostrConnectAccount.toJSON().signer
bunkerRelays = signerData.relays || []
}
console.log('[relay-init] Bunker relays:', bunkerRelays.length, 'relays', bunkerRelays)
// Start with hardcoded + bunker relays immediately (non-blocking)
const initialRelays = computeRelaySet({
@@ -592,11 +599,10 @@ function App() {
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Initial relay set (hardcoded):', initialRelays.length, 'relays', initialRelays)
// Apply initial set immediately
applyRelaySetToPool(pool, initialRelays)
console.log('[relay-init] After initial applyRelaySetToPool, pool has:', Array.from(pool.relays.keys()).length, 'relays')
// Prepare keep-alive helper
const updateKeepAlive = () => {
@@ -619,32 +625,29 @@ function App() {
loadUserRelayList(pool, pubkey, {
onUpdate: (userRelays) => {
const interimRelays = computeRelaySet({
hardcoded: [],
hardcoded: HARDCODED_RELAYS,
bunker: bunkerRelays,
userList: userRelays,
blocked: [],
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Interim relay set from first user list:', interimRelays.length, 'relays', interimRelays)
applyRelaySetToPool(pool, interimRelays)
updateKeepAlive()
}
}).then(async (userRelayList) => {
const blockedRelays = await blockedPromise.catch(() => [])
console.log('[relay-init] User relay list (10002):', userRelayList.length, 'relays', userRelayList.map(r => r.url))
console.log('[relay-init] Blocked relays (10006):', blockedRelays.length, 'relays', blockedRelays)
const finalRelays = computeRelaySet({
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
})
console.log('[relay-init] Final relay set (with user preferences):', finalRelays.length, 'relays', finalRelays)
applyRelaySetToPool(pool, finalRelays)
console.log('[relay-init] After user relay list apply, pool has:', Array.from(pool.relays.keys()).length, 'relays')
console.log('[relay-init] Final relay URLs:', Array.from(pool.relays.keys()))
updateKeepAlive()
// Update address loader with new relays
@@ -661,10 +664,9 @@ function App() {
})
} else {
// User logged out - reset to hardcoded relays
console.log('[relay-init] Applying RELAYS for logged out user, RELAYS.length:', RELAYS.length)
applyRelaySetToPool(pool, RELAYS)
console.log('[relay-init] After applyRelaySetToPool (logged out), pool has:', Array.from(pool.relays.keys()).length, 'relays')
console.log('[relay-init] Relay URLs:', Array.from(pool.relays.keys()))
// Update keep-alive subscription
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }

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

@@ -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

@@ -1,4 +1,5 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
contentTypeIcon,
readingProgress
}) => {
const navigate = useNavigate()
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
const isNote = bookmark.kind === 1
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
// Calculate progress color (matching BlogPostCard logic)
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
// Calculate progress color
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
}
const handleCompactClick = () => {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
onSelectUrl?.(extractedUrls[0])
} else if (isNote) {
navigate(`/e/${bookmark.id}`)
}
}
// For articles, prefer summary; for others, use content
const displayText = isArticle && articleSummary
? articleSummary
: bookmark.content
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
@@ -64,10 +64,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-type-compact">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
{displayText ? (
<div className="compact-text">
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
) : (
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{/* CTA removed */}

View File

@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import { useEventLoader } from '../hooks/useEventLoader'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
bookmarksLoading,
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
const eventId = eventIdParam
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
@@ -255,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
setCurrentArticleEventId
})
// Load event if /e/:eventId route is used
useEventLoader({
eventId,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
})
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)

View File

@@ -4,6 +4,7 @@ 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'
@@ -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)
@@ -464,7 +485,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
const handleOpenSearch = () => {
if (articleLinks) {
// For regular notes (kind:1), open via /e/ path
if (currentArticle?.kind === 1) {
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
window.open(borisUrl, '_blank', 'noopener,noreferrer')
} else if (articleLinks) {
// For articles, use search portal
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
@@ -733,11 +759,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}
/>
)
}}
@@ -759,6 +784,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">
@@ -843,10 +873,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}
/>
@@ -858,10 +889,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

@@ -434,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) {
@@ -655,7 +651,9 @@ const Debug: React.FC<DebugProps> = ({
return timeB - timeA
})
})
}
},
100,
eventStore || undefined
)
setWritingPosts(posts)
@@ -798,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`)
@@ -871,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) {
@@ -929,11 +919,7 @@ const Debug: React.FC<DebugProps> = ({
const elapsed = Math.round(performance.now() - start)
setTLoadRelayList(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadRelayList, ...rest } = prev
return rest
})
setLiveTiming(prev => ({ ...prev, loadRelayList: undefined }))
DebugBus.info('debug', `Loaded ${events.length} relay list events in ${elapsed}ms`)

View File

@@ -0,0 +1 @@

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

@@ -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'
@@ -57,7 +57,7 @@ const Me: React.FC<MeProps> = ({
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const activeTab = propActiveTab || 'highlights'
// Only for own profile
const viewingPubkey = activeAccount?.pubkey
@@ -129,13 +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
@@ -205,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 {
@@ -230,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)
@@ -270,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)
@@ -310,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
@@ -346,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(() => {
@@ -829,6 +830,7 @@ const Me: React.FC<MeProps> = ({
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
hideBotByName={settings.hideBotArticlesByName !== false}
/>
))}
</div>

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'
@@ -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], getActiveRelayUrls(relayPool), 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

@@ -20,7 +20,7 @@ export default function RouteDebug() {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
// silent
}
}, [location, matchArticle])

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,113 @@
import React, { useMemo } from 'react'
import { useTextToSpeech } from '../hooks/useTextToSpeech'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import { detect } from 'tinyld'
interface Props {
text: string
defaultLang?: string
className?: string
settings?: UserSettings
}
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
const {
supported, speaking, paused,
speak, pause, resume,
rate, setRate
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
const canPlay = supported && text?.trim().length > 0
const resolvedSystemLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
return navigator?.language?.split('-')[0]
}
return undefined
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
const detectContentLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
if (mode) return mode === 'content'
return settings?.ttsDetectContentLanguage !== false
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
const specificLang = useMemo(() => {
const mode = settings?.ttsLanguageMode
// If mode is not 'system' or 'content', it's a specific language code
if (mode && mode !== 'system' && mode !== 'content') {
return mode
}
return undefined
}, [settings?.ttsLanguageMode])
const handlePlayPause = () => {
if (!canPlay) return
if (!speaking) {
let langOverride: string | undefined
// Priority: specific language > content detection > system language
if (specificLang) {
langOverride = specificLang
} else if (detectContentLang && text) {
try {
const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
} catch (err) {
// ignore detection errors
}
}
if (!langOverride && resolvedSystemLang) {
langOverride = resolvedSystemLang
}
speak(text, langOverride)
} else if (paused) {
resume()
} else {
pause()
}
}
const handleCycleSpeed = () => {
const currentIndex = SPEED_OPTIONS.indexOf(rate)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
const next = SPEED_OPTIONS[nextIndex]
setRate(next)
}
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
if (!supported) return null
return (
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
className="article-menu-btn"
onClick={handlePlayPause}
title={playLabel}
disabled={!canPlay}
>
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
</button>
<button
type="button"
className="article-menu-btn"
onClick={handleCycleSpeed}
title="Cycle speed"
>
<FontAwesomeIcon icon={faGauge} />
<span>{rate}x</span>
</button>
</div>
)
}
export default TTSControls

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

@@ -120,7 +120,18 @@ export function useArticleLoader({
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naddr, relayPool, settings])
}, [
naddr,
relayPool,
settings,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
])
}

132
src/hooks/useEventLoader.ts Normal file
View File

@@ -0,0 +1,132 @@
import { useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService'
interface UseEventLoaderProps {
eventId?: string
relayPool?: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
}
export function useEventLoader({
eventId,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
}: UseEventLoaderProps) {
const displayEvent = useCallback((event: NostrEvent) => {
// Escape HTML in content and convert newlines to breaks for plain text display
const escapedContent = event.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />')
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by @${event.pubkey.slice(0, 8)}...`
}
// Emit immediately
const baseContent: ReadableContent = {
url: '',
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
title,
published: event.created_at
}
setReaderContent(baseContent)
// Background: resolve author profile for kind:1 and update title
if (event.kind === 1 && eventStore) {
(async () => {
try {
let resolved = ''
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
try {
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
}
}
// If not found in event store, fetch from relays
if (!resolved && relayPool) {
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
if (profiles && profiles.length > 0) {
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
try {
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
}
}
}
if (resolved) {
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
}
} catch {
// ignore profile failures; keep fallback title
}
})()
}
}, [setReaderContent, relayPool, eventStore])
// Initialize event manager with services
useEffect(() => {
eventManager.setServices(eventStore || null, relayPool || null)
}, [eventStore, relayPool])
useEffect(() => {
if (!eventId) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
setIsCollapsed(false)
// Fetch using event manager (handles cache, deduplication, and retry)
let cancelled = false
eventManager.fetchEvent(eventId).then(
(event) => {
if (!cancelled) {
displayEvent(event)
setReaderLoading(false)
}
},
(err) => {
if (!cancelled) {
const errorContent: ReadableContent = {
url: '',
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
title: 'Error'
}
setReaderContent(errorContent)
setReaderLoading(false)
}
}
)
return () => {
cancelled = true
}
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
}

View File

@@ -154,9 +154,20 @@ export function useExternalUrlLoader({
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, cachedUrlHighlights])
}, [
url,
relayPool,
eventStore,
cachedUrlHighlights,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
])
// Keep UI highlights synced with cached store updates without reloading content
useEffect(() => {
@@ -169,8 +180,6 @@ export function useExternalUrlLoader({
const next = [...additions, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
// setHighlights is intentionally excluded from dependencies - it's stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedUrlHighlights, url])
}, [cachedUrlHighlights, url, setHighlights])
}

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,288 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// Web Speech API types
type SpeechSynthesisVoice = {
name: string
voiceURI: string
lang: string
localService: boolean
default: boolean
}
export interface UseTTSOptions {
defaultLang?: string
defaultRate?: number
defaultPitch?: number
defaultVolume?: number
}
export interface UseTTS {
supported: boolean
speaking: boolean
paused: boolean
voices: SpeechSynthesisVoice[]
voice: SpeechSynthesisVoice | null
rate: number
pitch: number
volume: number
setVoice: (v: SpeechSynthesisVoice | null) => void
setRate: (r: number) => void
setPitch: (p: number) => void
setVolume: (v: number) => void
speak: (text: string, langOverride?: string) => void
pause: () => void
resume: () => void
stop: () => void
}
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
const supported = !!synth
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
const [speaking, setSpeaking] = useState(false)
const [paused, setPaused] = useState(false)
const [rate, setRate] = useState(options.defaultRate ?? 2.1)
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
const spokenTextRef = useRef<string>('')
const charIndexRef = useRef<number>(0)
// Chunking state to reliably speak long texts from web URLs
const chunksRef = useRef<string[]>([])
const chunkIndexRef = useRef<number>(0)
const globalOffsetRef = useRef<number>(0)
const langRef = useRef<string | undefined>(undefined)
// Update rate when defaultRate option changes
useEffect(() => {
if (options.defaultRate !== undefined) {
setRate(options.defaultRate)
}
}, [options.defaultRate])
// Load voices (async in many browsers)
useEffect(() => {
if (!supported) return
const load = () => {
const v = synth!.getVoices()
setVoices(v)
if (!voice && v.length) {
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null)
}
}
load()
const handleVoicesChanged = () => load()
synth!.addEventListener('voiceschanged', handleVoicesChanged)
return () => {
synth!.removeEventListener('voiceschanged', handleVoicesChanged)
}
}, [supported, defaultLang, voice, synth])
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
const resolvedLang = langOverride || voice?.lang || defaultLang
u.lang = resolvedLang
if (langOverride) {
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
if (match) {
u.voice = match
} else if (voice) {
u.voice = voice
}
} else if (voice) {
u.voice = voice
}
u.rate = rate
u.pitch = pitch
u.volume = volume
const self = u
u.onstart = () => {
if (utteranceRef.current !== self) return
setSpeaking(true)
setPaused(false)
}
u.onpause = () => {
if (utteranceRef.current !== self) return
setPaused(true)
}
u.onresume = () => {
if (utteranceRef.current !== self) return
setPaused(false)
}
u.onend = () => {
if (utteranceRef.current !== self) return
// Continue with next chunk if available
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
if (hasMore) {
chunkIndexRef.current++
charIndexRef.current += self.text.length
const nextChunk = chunksRef.current[chunkIndexRef.current]
const nextUtterance = createUtterance(nextChunk, langRef.current)
utteranceRef.current = nextUtterance
synth!.speak(nextUtterance)
} else {
setSpeaking(false)
setPaused(false)
}
}
u.onerror = () => {
if (utteranceRef.current !== self) return
setSpeaking(false)
setPaused(false)
}
u.onboundary = (ev: SpeechSynthesisEvent) => {
if (utteranceRef.current !== self) return
if (typeof ev.charIndex === 'number') {
const newIndex = globalOffsetRef.current + ev.charIndex
if (newIndex > charIndexRef.current) {
charIndexRef.current = newIndex
}
}
}
return u
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLen) return [normalized]
const sentences = normalized.split(/(?<=[.!?])\s+/)
const chunks: string[] = []
let current = ''
for (const s of sentences) {
if ((current + (current ? ' ' : '') + s).length > maxLen) {
if (current) chunks.push(current)
if (s.length > maxLen) {
// Hard split very long sentence
for (let i = 0; i < s.length; i += maxLen) {
chunks.push(s.slice(i, i + maxLen))
}
current = ''
} else {
current = s
}
} else {
current = current ? `${current} ${s}` : s
}
}
if (current) chunks.push(current)
return chunks
}, [])
const startSpeakingChunks = useCallback((text: string) => {
chunksRef.current = splitIntoChunks(text)
chunkIndexRef.current = 0
globalOffsetRef.current = 0
const first = chunksRef.current[0] || ''
const u = createUtterance(first, langRef.current)
utteranceRef.current = u
synth!.speak(u)
}, [createUtterance, splitIntoChunks, synth])
const stop = useCallback(() => {
if (!supported) return
synth!.cancel()
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
charIndexRef.current = 0
spokenTextRef.current = ''
chunksRef.current = []
chunkIndexRef.current = 0
globalOffsetRef.current = 0
}, [supported, synth])
const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return
synth!.cancel()
spokenTextRef.current = text
charIndexRef.current = 0
langRef.current = langOverride
startSpeakingChunks(text)
}, [supported, synth, startSpeakingChunks])
const pause = useCallback(() => {
if (!supported) return
if (synth!.speaking && !synth!.paused) {
synth!.pause()
setPaused(true)
}
}, [supported, synth])
const resume = useCallback(() => {
if (!supported) return
if (synth!.speaking && synth!.paused) {
synth!.resume()
setPaused(false)
}
}, [supported, synth])
// Update rate in real-time: while speaking, restart from last boundary with new rate.
useEffect(() => {
if (!supported) return
if (!utteranceRef.current) return
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
const remainingText = fullText.slice(startIndex)
synth!.cancel()
// restart chunked from current global index
spokenTextRef.current = remainingText
charIndexRef.current = 0
// keep current language selection; no change needed here
startSpeakingChunks(remainingText)
return
}
if (utteranceRef.current) {
utteranceRef.current.rate = rate
}
}, [rate, supported, synth, startSpeakingChunks])
const updateRate = useCallback((newRate: number) => {
setRate(newRate)
if (!supported) return
if (!utteranceRef.current) return
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
const remainingText = fullText.slice(startIndex)
synth!.cancel()
const u = createUtterance(remainingText)
// ensure the new rate is applied immediately on the new utterance
u.rate = newRate
utteranceRef.current = u
synth!.speak(u)
} else if (utteranceRef.current) {
utteranceRef.current.rate = newRate
}
}, [supported, synth, createUtterance])
// stop TTS when unmounting
useEffect(() => stop, [stop])
return useMemo(() => ({
supported,
speaking,
paused,
voices,
voice,
rate,
setRate: updateRate,
pitch, setPitch,
volume, setVolume,
setVoice,
speak, pause, resume, stop
}), [supported, speaking, paused, voices, voice, rate, updateRate, pitch, volume, setVoice, speak, pause, resume, stop])
}

View File

@@ -3,7 +3,8 @@ import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { merge } from 'rxjs'
import { from } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
@@ -69,6 +70,7 @@ class BookmarkController {
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
@@ -138,8 +140,11 @@ class BookmarkController {
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use EventLoader - it auto-batches and streams results
merge(...pointers.map(this.eventLoader)).subscribe({
// Use mergeMap with concurrency limit instead of merge to properly batch requests
// This prevents overwhelming relays with 96+ simultaneous requests
from(pointers).pipe(
mergeMap(pointer => this.eventLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
@@ -153,6 +158,11 @@ class BookmarkController {
idToEvent.set(coordinate, event)
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
@@ -183,8 +193,10 @@ class BookmarkController {
identifier: c.identifier
}))
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
// Use mergeMap with concurrency limit instead of merge to properly batch requests
from(pointers).pipe(
mergeMap(pointer => this.addressLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
@@ -194,6 +206,11 @@ class BookmarkController {
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
@@ -244,30 +261,38 @@ class BookmarkController {
})
const allItems = [...publicItemsAll, ...privateItemsAll]
const deduped = dedupeBookmarksById(allItems)
// Separate hex IDs from coordinates
// Separate hex IDs from coordinates for fetching
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
// Request hydration for all items that don't have content yet
deduped.forEach(i => {
// If item has no content, we need to fetch it
if (!i.content || i.content.length === 0) {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
}
}
})
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
const allBookmarks = dedupeBookmarksById([
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
// This preserves the original public/private split while still getting all the content
const allBookmarks = [
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
]
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: b.created_at || this.externalEventStore?.getEvent(b.id)?.created_at || b.created_at
}))
const sortedBookmarks = enriched
@@ -324,8 +349,12 @@ class BookmarkController {
relayPool: RelayPool
activeAccount: unknown
accountManager: { getActive: () => unknown }
eventStore?: EventStore
}): Promise<void> {
const { relayPool, activeAccount, accountManager } = options
const { relayPool, activeAccount, accountManager, eventStore } = options
// Store the external event store reference for adding hydrated events
this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
return

View File

@@ -21,12 +21,16 @@ export interface AddressPointer {
pubkey: string
identifier: string
relays?: string[]
added_at?: number
created_at?: number
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
added_at?: number
created_at?: number
}
export interface ApplesauceBookmarks {
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: parentCreatedAt || 0,
created_at: note.created_at || parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
added_at: note.added_at || parentCreatedAt || 0
})
})
}
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: parentCreatedAt || 0,
created_at: article.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: parentCreatedAt || 0
added_at: article.added_at || parentCreatedAt || 0
})
})
}
@@ -184,6 +188,9 @@ export function hydrateItems(
}
}
// Ensure all events with content get parsed content for proper rendering
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
return {
...item,
pubkey: ev.pubkey || item.pubkey,
@@ -191,7 +198,7 @@ export function hydrateItems(
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
parsedContent: parsedContent || item.parsedContent
}
})
.filter(item => {

View File

@@ -0,0 +1,162 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { createEventLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
type PendingRequest = {
resolve: (event: NostrEvent) => void
reject: (error: Error) => void
}
/**
* Centralized event manager for event fetching and caching
* Handles deduplication of concurrent requests and coordinate with relay pool
*/
class EventManager {
private eventStore: IEventStore | null = null
private relayPool: RelayPool | null = null
private eventLoader: ReturnType<typeof createEventLoader> | null = null
// Track pending requests to deduplicate and resolve all at once
private pendingRequests = new Map<string, PendingRequest[]>()
// Safety timeout for event fetches (ms)
private fetchTimeoutMs = 12000
// Retry policy
private maxAttempts = 4
private baseBackoffMs = 700
/**
* Initialize the event manager with event store and relay pool
*/
setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void {
this.eventStore = eventStore
this.relayPool = relayPool
// Recreate loader when services change
if (relayPool) {
this.eventLoader = createEventLoader(relayPool, {
eventStore: eventStore || undefined
})
// Retry any pending requests now that we have a loader
this.retryAllPending()
}
}
/**
* Get cached event from event store
*/
getCachedEvent(eventId: string): NostrEvent | null {
if (!this.eventStore) return null
return this.eventStore.getEvent(eventId) || null
}
/**
* Fetch an event by ID, returning a promise
* Automatically deduplicates concurrent requests for the same event
*/
fetchEvent(eventId: string): Promise<NostrEvent> {
// Check cache first
const cached = this.getCachedEvent(eventId)
if (cached) {
return Promise.resolve(cached)
}
return new Promise<NostrEvent>((resolve, reject) => {
// Check if we're already fetching this event
if (this.pendingRequests.has(eventId)) {
// Add to existing request queue
this.pendingRequests.get(eventId)!.push({ resolve, reject })
return
}
// Start a new fetch request
this.pendingRequests.set(eventId, [{ resolve, reject }])
this.fetchFromRelayWithRetry(eventId, 1)
})
}
private resolvePending(eventId: string, event: NostrEvent): void {
const requests = this.pendingRequests.get(eventId) || []
this.pendingRequests.delete(eventId)
requests.forEach(req => req.resolve(event))
}
private rejectPending(eventId: string, error: Error): void {
const requests = this.pendingRequests.get(eventId) || []
this.pendingRequests.delete(eventId)
requests.forEach(req => req.reject(error))
}
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
// If no loader yet, schedule retry
if (!this.relayPool || !this.eventLoader) {
setTimeout(() => {
if (this.pendingRequests.has(eventId)) {
this.fetchFromRelayWithRetry(eventId, attempt)
}
}, this.baseBackoffMs)
return
}
let delivered = false
const subscription = this.eventLoader({ id: eventId }).subscribe({
next: (event: NostrEvent) => {
delivered = true
clearTimeout(timeoutId)
this.resolvePending(eventId, event)
subscription.unsubscribe()
},
error: (err: unknown) => {
clearTimeout(timeoutId)
const error = err instanceof Error ? err : new Error(String(err))
// Retry on error until attempts exhausted
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, error)
}
subscription.unsubscribe()
},
complete: () => {
// Completed without next - consider not found, but retry a few times
if (!delivered) {
clearTimeout(timeoutId)
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, new Error('Event not found'))
}
}
subscription.unsubscribe()
}
})
// Safety timeout
const timeoutId = setTimeout(() => {
if (!delivered) {
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
subscription.unsubscribe()
this.fetchFromRelayWithRetry(eventId, attempt + 1)
} else {
subscription.unsubscribe()
this.rejectPending(eventId, new Error('Timed out fetching event'))
}
}
}, this.fetchTimeoutMs)
}
/**
* Retry all pending requests after relay pool becomes available
*/
private retryAllPending(): void {
const pendingIds = Array.from(this.pendingRequests.keys())
pendingIds.forEach(eventId => {
this.fetchFromRelayWithRetry(eventId, 1)
})
}
}
// Singleton instance
export const eventManager = new EventManager()

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

@@ -18,11 +18,7 @@ export async function loadUserRelayList(
}
): Promise<UserRelayInfo[]> {
try {
console.log('[relayListService] Loading user relay list for pubkey:', pubkey.slice(0, 16) + '...')
console.log('[relayListService] Available relays:', Array.from(relayPool.relays.keys()))
console.log('[relayListService] Starting query for kind 10002...')
const startTime = Date.now()
// Try querying with streaming callback for faster results
const events: NostrEvent[] = []
@@ -64,22 +60,13 @@ export async function loadUserRelayList(
// Use the streaming results if we got any, otherwise fall back to the full result
const finalEvents = events.length > 0 ? events : result
const queryTime = Date.now() - startTime
console.log('[relayListService] Query completed in', queryTime, 'ms')
// Also try a broader query to see if we get any events at all
console.log('[relayListService] Trying broader query for any kind 10002 events...')
const allEvents = await queryEvents(relayPool, {
await queryEvents(relayPool, {
kinds: [10002],
limit: 5
})
console.log('[relayListService] Found', allEvents.length, 'total kind 10002 events from any author')
console.log('[relayListService] Found', finalEvents.length, 'kind 10002 events')
if (finalEvents.length > 0) {
console.log('[relayListService] Event details:', finalEvents.map(e => ({ id: e.id, created_at: e.created_at, tags: e.tags.length })))
}
if (finalEvents.length === 0) return []
@@ -99,7 +86,6 @@ export async function loadUserRelayList(
}
}
console.log('[relayListService] Parsed', relays.length, 'relays from event')
return relays
} catch (error) {
console.error('Failed to load user relay list:', error)

View File

@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:4869'
]
/**
* Hardcoded relays that are always included
*/
export const HARDCODED_RELAYS = [
'wss://relay.nostr.band'
]
/**
* Gets active relay URLs from the relay pool
*/
@@ -46,13 +53,11 @@ export function applyRelaySetToPool(
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
console.log('[relayManager] applyRelaySetToPool called')
console.log('[relayManager] Current pool has:', currentUrls.size, 'relays')
console.log('[relayManager] Target has:', finalUrls.length, 'relays')
// Add new relays (use original URLs for adding, not normalized)
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
console.log('[relayManager] Will add:', toAdd.length, 'relays', toAdd)
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
@@ -71,7 +76,7 @@ export function applyRelaySetToPool(
}
}
}
console.log('[relayManager] Will remove:', toRemove.length, 'relays', toRemove)
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
@@ -81,6 +86,6 @@ export function applyRelaySetToPool(
}
}
console.log('[relayManager] After apply, pool has:', relayPool.relays.size, 'relays')
}

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

@@ -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

@@ -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' },