Compare commits

..

463 Commits

Author SHA1 Message Date
Gigi
fa052483b2 chore: bump version to 0.10.26 2025-10-31 00:27:14 +01:00
Gigi
0ae9e6321e Merge pull request #29 from dergigi/flight-mode-shenanigans
Fix flight mode detection and persist highlight metadata
2025-10-31 00:26:08 +01:00
Gigi
5623f2e595 chore: remove debug console.log statements
- Remove debug console.log from AddBookmarkModal.tsx (modal fetch and description extraction)
- Remove console.debug from relayManager.ts (relay closing errors)
- Keep console.warn and console.error for legitimate error handling
2025-10-31 00:24:42 +01:00
Gigi
2c94c1e3f0 fix: remove unused variables to resolve lint errors
- Remove unused relayNames variable from HighlightItem.tsx
- Remove unused failedRelays variable from highlightCreationService.ts
- All linting and type checks now pass
2025-10-31 00:18:58 +01:00
Gigi
19dc2f70f2 feat: persist highlight metadata and offline events to localStorage
- Add localStorage persistence for highlightMetadataCache Map
- Add localStorage persistence for offlineCreatedEvents Set
- Load both caches from localStorage on module initialization
- Save to localStorage whenever caches are updated
- Update metadata cache during sync operations (isSyncing changes)
- Ensures airplane icon displays correctly after page reloads
- Gracefully handles localStorage errors and corrupted data
2025-10-31 00:17:13 +01:00
Gigi
5013ccc552 perf: remove excessive debug logging for better performance
- Remove debug logs from highlight creation, publishing, and UI rendering
- Keep only essential error logging
- Improves performance by reducing console spam
- Flight mode detection still works via fallback mechanisms
2025-10-31 00:12:04 +01:00
Gigi
29eed3395f fix: prioritize isLocalOnly check to show airplane icon
- Check isLocalOnly first before checking publishedRelays length
- Show airplane icon if isLocalOnly is true, even if publishedRelays is empty
- This ensures flight mode highlights show airplane icon via offline sync fallback
- Add debug logs to track cache storage and retrieval
- Fixes issue where airplane icon doesn't show when creating highlights offline
2025-10-31 00:09:09 +01:00
Gigi
d6da27c634 fix: resolve all linting errors and type issues
- Remove unused setShowOfflineIndicator calls
- Remove unused areAllRelaysLocal import
- Fix duplicate key 'failedRelays' in console.log
- Replace 'any' types with proper HighlightEvent interface
- Add eslint-disable comments for intentionally excluded useEffect dependencies
- All linting errors resolved, type checks pass
2025-10-31 00:04:24 +01:00
Gigi
5551b52bce fix: replace require() with ES module imports
- Add isEventOfflineCreated and isLocalRelay to imports
- Remove require() calls that don't work in ES modules
- Fixes ReferenceError: require is not defined
2025-10-31 00:02:29 +01:00
Gigi
af7eb48893 fix: add fallback logic for detecting flight mode highlights
- Add isEventOfflineCreated function to check offline sync service
- Use offline sync service as fallback if isLocalOnly is undefined
- Also check if publishedRelays only contains local relays
- This provides multiple fallback mechanisms to detect flight mode highlights
- Should finally fix the airplane icon not showing
2025-10-31 00:01:09 +01:00
Gigi
51ce79f13a fix: use metadata cache to preserve highlight properties across EventStore
- Create highlightMetadataCache to store isLocalOnly and publishedRelays
- Properties stored as __highlightProps are lost during EventStore serialization
- Cache persists across storage/retrieval cycles
- eventToHighlight now checks cache first before __highlightProps
- This should finally fix the airplane icon not showing for flight mode highlights
2025-10-30 23:56:53 +01:00
Gigi
bcfc04c35c debug: add logging to investigate tooltip showing all relays
- Add debug log to see what highlight data is available when rendering
- Check if publishedRelays or seenOnRelays are being used
- This will help identify why tooltip shows all relays instead of just published ones
2025-10-30 23:54:07 +01:00
Gigi
d6911b2acb fix: publish only to connected relays to avoid long timeouts
- Only publish to currently connected relays instead of all configured relays
- This prevents waiting for disconnected/offline relays to timeout
- Improves performance significantly in flight mode
- Handle case when no relays are connected
2025-10-30 23:53:44 +01:00
Gigi
2a869f11e0 fix: prevent duplicate highlights and remove excessive logging
- Add deduplication when adding highlights via onHighlightCreated
- Remove excessive UI logging that was causing performance issues
- Fixes duplicate highlight warning and improves render performance
2025-10-30 21:29:35 +01:00
Gigi
deab9974fa fix: remove excessive logging from eventToHighlight
- Remove console.log that was spamming console with EVENT-TO-HIGHLIGHT logs
- This function is called frequently during renders, causing performance issues
- Keep the function logic but remove the logging
2025-10-30 21:05:45 +01:00
Gigi
49872337f3 fix: manually set highlight properties after eventToHighlight
- Set publishedRelays, isLocalOnly, and isSyncing directly on highlight object
- This bypasses the __highlightProps mechanism which wasn't working
- Add logging to verify properties are set correctly
- This should finally fix the airplane icon not showing in flight mode
2025-10-30 21:01:18 +01:00
Gigi
389b4de0eb debug: add logging to eventToHighlight conversion
- Add [EVENT-TO-HIGHLIGHT] logs to see if __highlightProps are being preserved
- Add [HIGHLIGHT-CREATION] logs before calling eventToHighlight
- This will help identify why isLocalOnly and publishedRelays are undefined in final highlight
2025-10-30 20:59:43 +01:00
Gigi
959ccac857 fix: store event in EventStore after updating properties
- Move eventStore.add() call to AFTER updating __highlightProps with final values
- This ensures highlights loaded from EventStore have correct isLocalOnly and publishedRelays
- Reduce UI logging spam by only logging when values are meaningful
- This should fix the airplane icon not showing and reduce excessive re-renders
2025-10-30 20:56:50 +01:00
Gigi
78c58693a5 debug: enhance logging in HighlightItem to debug icon issue
- Log entire highlight object to see what properties are actually present
- Log publishedRelayCount and actualPublishedRelaysInUI for clarity
- Add conditionResult to show what icon should be displayed
- This will help identify why airplane icon isn't showing despite isLocalOnly being true
2025-10-30 20:54:15 +01:00
Gigi
ab81fe5030 debug: add logging to useHighlightCreation hook
- Add [HIGHLIGHT-CREATION] logs to track highlight creation flow
- Log when createHighlight function is called
- Log highlight properties after creation to verify isLocalOnly is set
- This will help debug why [HIGHLIGHT-PUBLISH] logs are missing
2025-10-30 20:48:53 +01:00
Gigi
6bae30070e fix: preserve isLocalOnly and publishedRelays in eventToHighlight
- Attach custom properties to event as __highlightProps before storing
- Update eventToHighlight to preserve these properties when converting
- This ensures highlights loaded from EventStore maintain flight mode status
- Fixes issue where isLocalOnly was undefined in UI component
- The airplane icon should now show correctly for flight mode highlights
2025-10-30 20:44:58 +01:00
Gigi
1f6a904717 debug: add comprehensive logging for flight mode detection
- Add detailed logs with [HIGHLIGHT-PUBLISH] prefix for publication process
- Add detailed logs with [HIGHLIGHT-UI] prefix for UI rendering
- Log relay responses, success/failure analysis, and flight mode reasoning
- Log icon decision making process in UI component
- This will help debug why airplane icon isn't showing in flight mode
2025-10-30 20:42:02 +01:00
Gigi
9379475d1c feat: implement proper relay response tracking for flight mode
- Use pool.publish() which returns individual relay responses
- Track which relays actually accepted the event (response.ok === true)
- Set isLocalOnly = true only when only local relays accepted the event
- This provides accurate flight mode detection based on actual publishing success
- Debug logging shows all relay responses for troubleshooting
2025-10-30 20:41:24 +01:00
Gigi
77a5f4bd2a refactor: implement proper flight mode detection for highlights
- Always publish to all relays but track which ones are actually connected
- isLocalOnly = true when only local relays are connected (flight mode)
- Store event in EventStore first for immediate UI display
- Track actually connected relays instead of guessing based on connection status
- This should fix the airplane icon not showing in flight mode
2025-10-30 20:40:20 +01:00
Gigi
4fa01231cd fix: determine isLocalOnly before publishing, not after
- Move connection status check before publishEvent call
- Set isLocalOnly based on actual connection state at creation time
- This ensures airplane icon shows correctly in flight mode
- Previous logic was checking after publishing, which was too late
2025-10-30 20:35:51 +01:00
Gigi
1cd85507a7 debug: add logging to highlight creation isLocalOnly logic
- Add console.log to debug why isLocalOnly is not being set correctly
- Fix logic: isLocalOnly should be true when only local relays are connected
- Previous logic was checking expectedSuccessRelays instead of actual connections
- This will help identify why airplane icon doesn't show in flight mode
2025-10-30 20:34:23 +01:00
Gigi
b6f151c711 refactor: use isLocalOnly instead of isOfflineCreated
- isLocalOnly is more accurate - covers both offline and online-but-local-only scenarios
- Update tooltip to 'Local relays only - will sync when remote relays available'
- Better semantic meaning: highlights only published to local relays
- Covers cases where user is online but only connected to local relays
2025-10-30 20:32:43 +01:00
Gigi
e3d924f3fc refactor: remove redundant isLocalOnly flag
- Remove isLocalOnly field from Highlight type and creation logic
- Use only isOfflineCreated flag for flight mode highlights
- Simplify UI logic to check only isOfflineCreated
- Both flags were set to the same value, making them redundant
- Cleaner, more maintainable code with single source of truth
2025-10-30 20:31:09 +01:00
Gigi
5914df23d3 fix: show airplane icon for flight mode highlights
- Simplify logic to check highlight.isOfflineCreated || highlight.isLocalOnly
- Show airplane icon with 'Created offline - will sync when online' tooltip
- Remove complex showOfflineIndicator state management
- Fixes issue where flight mode highlights showed highlighter icon instead of plane icon
2025-10-30 20:30:04 +01:00
Gigi
2083c2b8c6 fix: remove eventStore and setter functions from useEffect dependencies
- Remove eventStore from useArticleLoader and useExternalUrlLoader dependencies
- Remove setter functions from dependencies as they shouldn't change
- Only keep naddr/url and previewData/cachedUrlHighlights as dependencies
- This prevents content loaders from re-running when going offline
- Fixes the core issue where loading skeleton appears immediately on offline detection
2025-10-30 20:23:23 +01:00
Gigi
35a8411d9b fix: check EventStore before setting loading state
- Move EventStore check before setReaderLoading(true) call
- Only show loading skeleton if content not found in store and no preview data
- This prevents loading skeleton from appearing when cached content is available
- Fixes the core issue where offline mode shows loading skeleton with cached content
2025-10-30 20:21:59 +01:00
Gigi
15b3b5b990 fix: remove relayPool dependency from content loaders
- Remove relayPool from useEffect dependencies in useArticleLoader and useExternalUrlLoader
- This prevents content reloading when relay status changes (going offline/online)
- Content loaders now only re-run when the actual content identifier changes
- Fixes issue where loading skeleton appears when going offline with cached content
2025-10-30 20:20:07 +01:00
Gigi
ad56acb712 fix: prevent unnecessary relay queries when article content is cached
- Return early from useArticleLoader when content is found in EventStore
- This prevents loading skeleton from showing when going offline with cached content
- Improves offline experience by using locally cached article content
2025-10-30 20:18:06 +01:00
Gigi
2f5fe87fc8 docs: update CHANGELOG for v0.10.25 2025-10-25 01:59:51 +02:00
Gigi
d313c71e24 chore: bump version to 0.10.25 2025-10-25 01:59:25 +02:00
Gigi
903b4a4ec1 Merge pull request #28 from dergigi/mid-size-cards
feat: redesign medium-sized bookmark cards with compact layout and improved UX
2025-10-25 01:58:56 +02:00
Gigi
a511b25b87 fix: correct TypeScript error in content type icon logic
- Change 'document' case to 'article' to match valid UrlType
- Fix TypeScript compilation error for invalid UrlType comparison
- Maintain proper type safety while preserving icon functionality
- All linting and type checks now passing
2025-10-25 01:57:57 +02:00
Gigi
e920cf9477 feat: make image placeholder reflect bookmark type
- Add content type icon logic to CardView component
- Import appropriate FontAwesome icons for different content types
- Replace generic link icon with type-specific icons:
  - Articles: newspaper icon
  - Videos: play button icon
  - Images: camera icon
  - Documents: file lines icon
  - Web links: globe icon
  - Notes: sticky note icon
- Improve visual context and user experience with meaningful placeholders
2025-10-25 01:56:31 +02:00
Gigi
708a1bfd54 fix: ensure consistent reading progress bar thickness in large cards
- Add minHeight property to ReadingProgressBar container and inner div
- Ensure empty progress bar maintains same 3px height as filled progress bar
- Fix visual consistency between empty and filled reading progress states
- Maintain proper visual separator thickness in large card view
2025-10-25 01:55:37 +02:00
Gigi
51842f55bf fix: resolve linting issues in CardView component
- Remove unused onSelectUrl parameter from CardView destructuring
- Fix ESLint no-unused-vars error
- Maintain code quality and linting standards
- All linting issues resolved, type checks passing
2025-10-25 01:54:59 +02:00
Gigi
52991f8e20 fix: eliminate 0 artefacts in compact view conditional rendering
- Change condition from 'readingProgress && readingProgress > 0' to 'readingProgress !== undefined && readingProgress > 0'
- Prevent React from rendering 0 values when readingProgress is 0
- Fix weird 0 artefacts appearing in compact list view
- Ensure clean conditional rendering without unwanted text output
2025-10-25 01:53:52 +02:00
Gigi
e3cd4454b4 fix: only show reading progress bar in compact view when there is actual progress
- Add conditional rendering for ReadingProgressBar in CompactView
- Only display progress bar when readingProgress > 0
- Remove empty progress bar separator from compact list view
- Maintain clean, minimal compact view without unnecessary visual elements
- Keep progress bar functionality for cards with actual reading progress
2025-10-25 01:52:57 +02:00
Gigi
78bc1f46dd style: eliminate excessive space between progress bar and footer
- Reduce ReadingProgressBar margins from 0.25rem to 0.125rem (75% reduction)
- Reduce bookmark footer padding-top from 0.25rem to 0.125rem (75% reduction)
- Reduce reading progress separator margin from 0.25rem to 0.125rem (75% reduction)
- Update responsive breakpoints for ultra-compact spacing
- Achieve minimal gap between progress bar and date/author footer
- Create ultra-tight vertical layout with almost no wasted space
2025-10-25 01:52:35 +02:00
Gigi
c8cd1e6e66 style: make bookmark cards significantly more compact
- Reduce card content gap from 0.5rem to 0.25rem (50% reduction)
- Reduce bookmark title margin-bottom from 0.5rem to 0.25rem (50% reduction)
- Reduce bookmark footer padding-top from 0.5rem to 0.25rem (50% reduction)
- Reduce reading progress separator margin from 0.5rem to 0.25rem (50% reduction)
- Update ReadingProgressBar component margins to 0.25rem
- Update responsive breakpoints for consistent compact spacing
- Achieve much tighter vertical layout with minimal wasted space
2025-10-25 01:50:46 +02:00
Gigi
5254697fe2 refactor: create reusable ReadingProgressBar component for DRY code
- Create ReadingProgressBar component with configurable props
- Remove duplicated progress color logic from all view components
- Replace inline progress bar code with reusable component
- Maintain consistent behavior across CardView, CompactView, and LargeView
- Reduce code duplication and improve maintainability
2025-10-25 01:49:45 +02:00
Gigi
13462efaed feat: ensure reading progress bar shows for all bookmark types across all view modes
- Update CompactView to always show progress bar for all bookmark types
- Update LargeView to always show progress bar for all bookmark types
- Remove conditional logic that only showed progress for articles
- Ensure consistent visual separator across CardView, CompactView, and LargeView
- Maintain empty state display (1px border line) when no progress available
2025-10-25 01:48:42 +02:00
Gigi
1df00fbfda feat: show reading progress bar for all bookmark types
- Remove isArticle condition from reading progress bar display
- Show progress bar for videos, links, and articles
- Maintain consistent visual separator for all bookmark types
- Ensure reading progress tracking works across all content types
2025-10-25 01:48:08 +02:00
Gigi
c2e220a1f2 style: reduce vertical spacing in medium-sized bookmark cards
- Reduce reading progress separator margin from 0.75rem to 0.5rem
- Reduce card content gap from 0.75rem to 0.5rem
- Reduce bookmark title margin-bottom from 0.75rem to 0.5rem
- Reduce bookmark footer padding-top from 0.75rem to 0.5rem
- Update responsive breakpoints for consistent compact spacing
- Make cards more compact and visually tighter
2025-10-25 01:47:46 +02:00
Gigi
00740aab6d fix: ensure empty reading progress bar is always visible for articles
- Change progress bar background from transparent to border color when no progress
- Reading progress separator now always shows 1px line for articles
- Maintains visual consistency between articles with and without reading progress
- Ensures proper visual separation between content and footer
2025-10-25 01:45:53 +02:00
Gigi
e12d67cc5f feat: remove type icon from medium-sized bookmark cards
- Remove contentTypeIcon from CardViewProps interface
- Remove type icon display from bookmark footer
- Replace contentTypeIcon with faLink in thumbnail placeholder
- Simplify card interface by removing content type indicator
- Clean up unused icon-related code
2025-10-25 01:45:29 +02:00
Gigi
e12aaa2b6c feat: remove text expansion mechanic from medium-sized cards
- Remove expanded state and shouldTruncate logic
- Remove chevron icons and expand/collapse buttons
- Simplify content display to show full content without truncation
- Remove unused faChevronDown and faChevronUp imports
- Streamline card interface for cleaner, simpler design
2025-10-25 01:44:16 +02:00
Gigi
9880a9ae34 fix: ensure timestamp and icon display on same line
- Change bookmark-type display to inline-flex for inline layout
- Add flex-wrap: nowrap to prevent wrapping
- Set timestamp elements to display: inline with white-space: nowrap
- Reduce gap between timestamp and icon to 0.5rem for tighter spacing
- Ensure both elements stay on the same horizontal line
2025-10-25 01:43:22 +02:00
Gigi
603db680f2 style: match type icon color to author text color
- Set type icon color to var(--color-text-secondary) to match author text
- Icon now has same muted color as author link instead of bright white
- Creates better visual consistency in footer metadata
2025-10-25 01:42:02 +02:00
Gigi
ae0471946e feat: move timestamp to footer next to type icon
- Move timestamp from header to footer positioned next to type icon
- Create bookmark-footer-right container for timestamp and type icon
- Hide empty bookmark-header since timestamp is now in footer
- Update footer layout: author (left), timestamp + type icon (right)
- Maintain proper spacing and alignment for all elements
2025-10-25 01:41:40 +02:00
Gigi
a48308d57d fix: remove primary color from global bookmark-type rule
- Remove color: var(--color-primary) from global .bookmark-type rule
- Icon was still blue due to global CSS rule overriding footer-specific rule
- Now uses default text color for subtle appearance
2025-10-25 01:41:05 +02:00
Gigi
f67b358148 style: remove primary color from bookmark type icon
- Remove var(--color-primary) color from bookmark type icon
- Use default text color for more subtle icon appearance
- Maintain font size and spacing for consistent layout
2025-10-25 01:40:08 +02:00
Gigi
46a0a3da1f feat: add title display for regular bookmarks/links
- Extract title from tags for all bookmark types, not just articles
- Display titles for regular bookmarks that have title tags
- Support both article titles and bookmark titles in card display
- Maintain existing article title functionality
- Improve title coverage across all bookmark types
2025-10-25 01:39:46 +02:00
Gigi
c92a620ea8 feat: move bookmark type icon to bottom right footer
- Remove type icon from header and move to footer
- Position author name on left, type icon on right in footer
- Update header to right-align date only
- Add flex layout to footer for proper spacing
- Maintain consistent styling and responsive design
2025-10-25 01:39:08 +02:00
Gigi
34de372509 feat: remove URL display from medium-sized bookmark cards
- Remove bookmark URLs section from CardView component
- Remove unused urlsExpanded state variable
- Clean up unused URL styling from CSS
- Simplify card layout by removing URL display
- Maintain all other card functionality (title, content, progress, author)
2025-10-25 01:38:38 +02:00
Gigi
a422084949 feat: add title display to medium-sized bookmark cards
- Add articleTitle prop to CardView component interface
- Display article titles for kind:30023 articles in card layout
- Style titles with proper typography and responsive design
- Position titles between header and URLs for optimal hierarchy
- Add line clamping for long titles (2 lines max)
- Update BookmarkItem to pass articleTitle to CardView
2025-10-25 01:38:11 +02:00
Gigi
bd0e075984 fix: remove unused variables to resolve linting errors
- Remove unused imageLoading and imageError state variables
- Clean up CardView component to pass ESLint checks
- Maintain all existing functionality while fixing linting issues
2025-10-25 01:37:04 +02:00
Gigi
38f4b69d48 feat: position bookmark type icon in top-left corner of card
- Move bookmark type icon to top-left corner as overlay
- Add bookmark-type-overlay with absolute positioning
- Style icon with background, border, and shadow for visibility
- Update responsive design for smaller screens
- Remove icon from bookmark header to avoid duplication
- Ensure icon is always visible and accessible
2025-10-25 01:36:01 +02:00
Gigi
9d1d944daf feat: position reading progress bar to span full card width
- Move reading progress bar outside of text content area
- Position progress bar between content and author name
- Update CSS to remove card-content scoping for full-width display
- Maintain 1px thickness and smooth transitions
- Ensure progress bar spans entire card width for better visual separation
2025-10-25 01:35:15 +02:00
Gigi
e56461cb12 feat: restructure card layout to position author in bottom-left corner
- Move thumbnail to be next to text content instead of blocking author position
- Create card-content-header with thumbnail + text-content flex layout
- Position author name in bottom-left corner of card footer
- Update responsive design for new layout structure
- Maintain thumbnail functionality while fixing author positioning
2025-10-25 01:34:40 +02:00
Gigi
f6b6747f09 feat: always show reading progress bar as 1px separator
- Show reading progress bar for all article cards, even without progress
- Change progress bar thickness from 4px to 1px for subtle separation
- Remove fallback separator since progress bar is always shown
- Empty progress bars show as transparent fill with border background
- Maintain consistent visual separation across all article cards
2025-10-25 01:33:14 +02:00
Gigi
180c26c47a feat: use reading progress bar as visual separator
- Remove separate border separator from bookmark footer
- Enhance reading progress bar styling as primary separator
- Add subtle separator for cards without reading progress
- Improve visual hierarchy with progress-based separation
- Maintain consistent spacing and visual flow
2025-10-25 01:32:34 +02:00
Gigi
78da0cb3e4 feat: redesign medium-sized bookmark cards with left-side thumbnails
- Replace full-width hero images with compact left-side thumbnails
- Add card-layout flex container for thumbnail + content arrangement
- Implement 80px square thumbnails with hover scale effects
- Update responsive design for smaller screens (60px tablet, 50px mobile)
- Maintain content truncation and reading progress indicators
- Improve space efficiency while preserving visual appeal
2025-10-25 01:31:26 +02:00
Gigi
3d74c25c7d feat: enhance medium-sized bookmark cards with improved styling and layout
- Add card-view class for better visual hierarchy
- Implement hero image display with fallback placeholder
- Add responsive design for mobile and tablet screens
- Improve content truncation with line clamping
- Enhance URL display with better styling
- Add hover effects and smooth transitions
- Optimize card layout for better readability
2025-10-25 01:30:23 +02:00
Gigi
f46f55705b docs: update CHANGELOG for v0.10.24 2025-10-25 01:28:44 +02:00
Gigi
205591602d chore: bump version to 0.10.24 2025-10-25 01:28:22 +02:00
Gigi
c6a42e0304 Merge pull request #27 from dergigi/fixes-after-midnight-as-always
feat: improve OpenGraph extraction with fetch-opengraph library
2025-10-25 01:28:06 +02:00
Gigi
688d4285e3 fix: resolve all linting and TypeScript issues
- Rename fetch import to fetchOpenGraph to avoid global variable conflict
- Replace any types with proper Record<string, unknown> types
- Add proper type guards for OpenGraph data extraction
- Remove unused CACHE_TTL variable
- Fix TypeScript null assignment error in opengraphEnhancer
- All linting rules and type checks now pass
2025-10-25 01:26:54 +02:00
Gigi
9f806afc45 debug: add console logging to debug description extraction in AddBookmarkModal
- Add debug logs to track OpenGraph data fetching
- Add debug logs to track description extraction logic
- Help identify why description field is not being populated
2025-10-25 01:22:05 +02:00
Gigi
1282e778c6 fix: extract description from web bookmark content field
- Web bookmarks (kind:39701) store description in content field, not summary tag
- Update linksFromBookmarks.ts to check content field for web bookmarks
- Maintain backward compatibility with regular bookmarks using summary tag
- Fixes description display for web bookmarks in Links tab
2025-10-25 01:19:07 +02:00
Gigi
6fd40f2ff6 feat: enhance Links type bookmarks with OpenGraph data
- Add opengraphEnhancer service using fetch-opengraph library
- Enhance ReadItems with proper titles, descriptions, and cover images
- Update deriveLinksFromBookmarks to use async OpenGraph enhancement
- Add caching and batching to avoid overwhelming external services
- Improve bookmark card display with rich metadata from OpenGraph tags
2025-10-25 01:15:37 +02:00
Gigi
6ac40c8a17 feat: replace custom OpenGraph extraction with fetch-opengraph library
- Install fetch-opengraph library for robust OpenGraph extraction
- Replace custom regex patterns and proxy logic with specialized library
- Simplify AddBookmarkModal OpenGraph extraction logic
- Remove fetchRawHtml function from readerService
- Improve reliability and maintainability of metadata extraction
2025-10-25 01:14:28 +02:00
Gigi
92145af2bb feat: implement dynamic browser title based on content
- Add useDocumentTitle hook to manage document title dynamically
- Update useArticleLoader to set title when articles load
- Update useExternalUrlLoader to set title for external URLs/videos
- Update useEventLoader to set title for events
- Reset title to default when navigating away from content
- Browser title now shows article/video title instead of always 'Boris'
2025-10-25 01:07:55 +02:00
Gigi
1ebaf7ccd2 docs: update CHANGELOG for v0.10.23 2025-10-25 01:04:29 +02:00
Gigi
5d22819ae3 chore: bump version to 0.10.23 2025-10-25 01:03:57 +02:00
Gigi
6761b1861e Merge pull request #26 from dergigi/tts-fixes-and-stuff
Fix highlight loading issues and improve article performance
2025-10-25 01:03:35 +02:00
Gigi
1d989eae76 fix: improve article loading performance and error handling 2025-10-25 01:02:42 +02:00
Gigi
33d6e5882d refactor: simplify highlight loading code
- Remove redundant fallback mechanisms and backup effects
- Remove unnecessary parameters from useArticleLoader interface
- Keep only essential highlight loading logic
- Maintain DRY principle by eliminating duplicate code
- Simplify the codebase while preserving functionality
2025-10-25 01:00:36 +02:00
Gigi
0a62924b78 feat: implement robust highlight loading with fallback mechanisms
- Add detailed logging to track highlight loading process
- Implement fallback timeout mechanism to retry highlight loading after 2 seconds
- Add backup effect that triggers when article coordinate changes
- Ensure highlights are loaded reliably after article content is fully loaded
- Add console logging to help debug highlight loading issues
2025-10-25 00:59:26 +02:00
Gigi
e2472606dd fix: properly filter Nostr article highlights in sidebar
- Extract article coordinate from nostr: URLs using nip19.decode
- Filter highlights by eventReference matching the article coordinate
- Fix issue where unrelated highlights were showing in sidebar
- Apply same filtering logic to both useFilteredHighlights and filterHighlightsByUrl
2025-10-25 00:56:19 +02:00
Gigi
6f04b8f513 chore: update bookmark components and remove migration docs
- Update BookmarkItem.tsx with latest changes
- Update CardView.tsx and CompactView.tsx bookmark view components
- Update ThreePaneLayout.tsx with latest modifications
- Remove TAILWIND_MIGRATION.md as migration is complete
2025-10-25 00:55:02 +02:00
Gigi
a8ad346c5d feat: implement smart highlight clearing for articles
- Preserve highlights that belong to the current article when switching articles
- Only clear highlights that don't match the current article coordinate or event ID
- Improve user experience by maintaining relevant highlights during navigation
2025-10-25 00:54:02 +02:00
Gigi
465c24ed3a fix: resolve highlight loading issues for articles
- Add missing eventStore parameter to fetchHighlightsForArticle call
- Clear highlights immediately when starting to load new article
- Fix infinite loading spinners when articles have zero highlights
- Ensure highlights are properly stored and persisted
2025-10-25 00:52:49 +02:00
Gigi
04dea350a4 fix: consolidate multiple skeleton loaders in article view
Remove duplicate ContentSkeleton components that were showing simultaneously.
Now uses a single skeleton for both loading and no-content states.

This follows DRY principles and prevents multiple skeletons from appearing
at the same time in the article view.
2025-10-25 00:47:50 +02:00
Gigi
29c4bcb69b fix: replace markdown loading spinner with skeleton
Replace the small spinner used for markdown content loading with a proper
ContentSkeleton for better visual consistency and user experience.

This ensures all content loading states use skeleton loaders instead of
spinners where appropriate.
2025-10-25 00:47:15 +02:00
Gigi
23ea7f352b fix: replace 'No readable content found' with skeleton loader
Replace the confusing 'No readable content found for this URL' message that
appears during loading states with a skeleton loader for better UX.

This prevents users from seeing error messages while content is still loading.
2025-10-25 00:46:27 +02:00
Gigi
36b35367f1 fix: prevent race conditions between content loaders
Add coordination logic to ensure only one content loader (article/external/event)
runs at a time. This prevents state conflicts that caused 'No readable content found'
errors and stale content from previous articles appearing.

The existing instant-load + background-refresh flow is preserved.
2025-10-25 00:45:13 +02:00
Gigi
183463c817 feat: align home button to left next to profile button
- Move home button from right side to left side in sidebar header
- Add sidebar-header-left container for left-aligned elements
- Update CSS to support new layout with flex positioning
- Home button now appears next to profile button when logged in
2025-10-25 00:32:14 +02:00
Gigi
7fb91e71f1 fix: add missing relayPool dependency to useEffect
- Add relayPool to dependency array in VideoView useEffect
- Fixes React hooks exhaustive-deps linting warning
- Ensures effect runs when relayPool changes
2025-10-25 00:31:15 +02:00
Gigi
717f094984 feat: use note content as title for direct video URLs
- Extract first 100 characters of note content as video title
- Truncate with ellipsis if content is longer than 100 characters
- Fallback to YouTube metadata title or original title if no note content
- Improves user experience by showing meaningful titles for direct videos from Nostr notes
2025-10-25 00:30:32 +02:00
Gigi
c69e50d3bb feat: add note content support for direct video URLs
- Add noteContent prop to VideoView component for displaying note text
- Update VideoView to prioritize note content over metadata when available
- Detect direct video URLs from Nostr notes (nostr.build, nostr.video domains)
- Pass bookmark information through URL selection in bookmark components
- Show placeholder message for direct videos from Nostr notes
- Maintains backward compatibility with existing video metadata extraction
2025-10-25 00:30:07 +02:00
Gigi
4e4d719d94 feat: add video thumbnail support for cover images
- Add YouTube thumbnail extraction using existing getYouTubeThumbnail utility
- Add Vimeo thumbnail support using vumbnail.com service
- Update VideoView to use video thumbnails as cover images in ReaderHeader
- Update Vimeo API to include thumbnail_url in response
- Fallback to original image prop if no video thumbnail available
- Supports both YouTube and Vimeo video thumbnails
2025-10-25 00:28:07 +02:00
Gigi
d453a6439c fix: improve video metadata extraction for YouTube and Vimeo
- Add actual YouTube title and description fetching via web scraping
- Fix syntax error in video-meta.ts (missing opening brace)
- Complete Vimeo metadata implementation
- Both APIs now properly extract title and description from video pages
- Caption extraction remains functional for supported videos
2025-10-25 00:24:30 +02:00
Gigi
5dfa6ba3ae feat: extract video functionality into dedicated VideoView component
- Create VideoView component with dedicated video player, metadata, and menu
- Remove video-specific logic from ContentPanel for better separation of concerns
- Update ThreePaneLayout to conditionally render VideoView vs ContentPanel
- Maintain all existing video features: YouTube metadata, transcripts, mark as watched
- Improve code organization and maintainability
2025-10-25 00:19:16 +02:00
Gigi
f2d2883eee docs: update CHANGELOG for v0.10.22
- Added mobile-optimized tab interface improvements
- Documented brand tagline update and UI reordering
- Listed mobile sidebar navigation fixes
- Recorded reading progress versioning removal
2025-10-25 00:11:19 +02:00
Gigi
84001d1b83 chore: bump version to 0.10.22 2025-10-25 00:10:49 +02:00
Gigi
b7a390cf89 Merge pull request #25 from dergigi/trying-to-fix-annoying-bugs
Mobile UX improvements and bug fixes
2025-10-25 00:10:31 +02:00
Gigi
59d9179642 ui: hide tab text labels on mobile for /my and /p/ pages
- Add CSS media query to hide .tab-label on screens ≤768px
- Adjust tab padding and gap for mobile to show only icons
- Saves space on mobile for highlights, bookmarks, reads, links, writings tabs
- Affects Me, Profile, and Explore components
2025-10-25 00:08:40 +02:00
Gigi
68301cd20f brand: update tagline from 'Nostr Bookmarks' to 'Read, Highlight, Explore'
- Update HTML title and meta tags in index.html
- Update PWA manifest in public/manifest.webmanifest
- Update Vite PWA manifest in vite.config.ts
- Update article OG title fallback in api/article-og.ts
- Reflects the core functionality: reading, highlighting, and exploring content
2025-10-25 00:06:19 +02:00
Gigi
4d6b7e1a46 ui: reorder bookmarks bar buttons
- Change button order to: Home, Explore, Settings
- Maintains all existing functionality
2025-10-25 00:05:23 +02:00
Gigi
95fe9b548f fix: close mobile sidebar when clicking navigation items
- Add mobile sidebar close logic to handleMenuItemClick for profile menu items
- Add mobile sidebar close logic to Home, Settings, and Explore buttons
- Fixes issue where mobile bookmarks bar didn't close when navigating to My Reads, My Highlights, etc.
2025-10-25 00:05:04 +02:00
Gigi
e86ae9f05e ui: move highlight button higher up
- Change bottom position from 32px to 80px
- Provides more space from bottom edge for better positioning
2025-10-25 00:04:02 +02:00
Gigi
2124be83c3 refactor: remove versioning from reading progress implementation
- Remove 'ver' field from ReadingProgressContent interface
- Remove ver: '1' from saveReadingPosition function
- Update PROGRESS_CACHE_KEY to remove v1 suffix
2025-10-25 00:02:40 +02:00
Gigi
a8bb17d4cd fix: replace any types with proper type-safe inert attribute handling 2025-10-23 22:06:35 +02:00
Gigi
a886a68822 feat(highlights): increase fetch limits for better explore page coverage
- Increase friends highlights limit from 200 to 1000
- Add 1000 limit to nostrverse highlights on initial load
- Ensures users see more highlights from friends and nostrverse
- Incremental syncs continue to use 'since' filter without limit
2025-10-23 21:59:58 +02:00
Gigi
76bdbc670d fix(nostrverse): merge incremental highlights instead of replacing
- Initialize highlightsMap with existing highlights on incremental sync
- Merge new highlights with existing ones instead of replacing entire list
- Keep existing highlights on error instead of clearing them
- Fixes issue where nostrverse highlights would disappear on page refresh
2025-10-23 21:54:53 +02:00
Gigi
c16ce1fc7e feat(explore): persist scope visibility to localStorage
- Load explore scope visibility from localStorage on mount
- Save user's scope toggles to localStorage when changed
- Only reset to settings defaults if no saved preference exists
- Ensures user's scope selection persists across page refreshes
2025-10-23 21:43:48 +02:00
Gigi
a578d67b1e fix: load reading positions for web bookmarks (kind:39701)
Web bookmarks store their URL in the 'd' tag, not in content.
The getBookmarkReadingProgress function was only extracting URLs from
content, which meant reading progress indicators weren't showing for
web bookmarks. Now it properly extracts URLs from the 'd' tag for
kind:39701 bookmarks.
2025-10-23 21:14:15 +02:00
Gigi
25d1ead9f5 fix: add error handling when closing relay connections
Wrap relay.close() in try-catch to gracefully handle cases where WebSocket
connections are closed before they're fully established. This can occur when
relay sets change rapidly during app initialization (e.g., when loading user
relay lists).
2025-10-23 20:52:42 +02:00
Gigi
ae5ea66dd2 fix(a11y): replace aria-hidden with inert to prevent focus issues
Replaced aria-hidden with inert attribute on mobile sidebar and highlights panes. The inert attribute both hides from assistive technology AND prevents focus, eliminating the accessibility warning about focused elements being hidden.
2025-10-23 20:48:36 +02:00
Gigi
cf5f8fae16 docs: update CHANGELOG for v0.10.21 2025-10-23 20:43:15 +02:00
Gigi
d9c46e602a chore: bump version to 0.10.21 2025-10-23 20:41:32 +02:00
Gigi
4d980bf91c fix: deduplicate bookmarks in Me component
- Add dedupeBookmarksById to flatten operation in Me.tsx
- Same article can appear in multiple bookmark lists/sets
- Use coordinate-based deduplication (kind:pubkey:identifier) for articles
- Prevents duplicate display when article is in multiple bookmark sources
2025-10-23 20:38:06 +02:00
Gigi
cb3b0e38e9 fix: show article title instead of summary in compact view
- Extract article title from tags in BookmarkItem
- Update CompactView to display title as main text for articles
- Remove unused articleSummary prop from CompactView to keep code DRY
- Follows NIP-23 article metadata structure
2025-10-23 20:29:38 +02:00
Gigi
fbf5c455ca fix: prevent tracking reading position for nostr-event sentinel URLs
- Add check to filter out nostr-event: URLs from reading position tracking
- nostr-event: is an internal sentinel, not a valid Nostr URI per NIP-21
- Prevents these sentinel URLs from being saved to reading position data
2025-10-23 20:21:41 +02:00
Gigi
ed5decf3e9 fix: properly route nostr-event URLs to /e/ path instead of /a/
- Add special handling for nostr-event: URLs in getReadItemUrl
- Add special handling for nostr-event: URLs in handleSelectUrl
- Prevent nostr-event URLs from being incorrectly routed to /a/ path (which expects naddr)
- Route nostr-event URLs to /e/ path for proper event loading
- Fixes 'String must be lowercase or uppercase' error when loading base64-encoded nostr-event URLs
2025-10-23 20:18:35 +02:00
Gigi
44a7e6ae2c docs: update CHANGELOG for v0.10.20 2025-10-23 20:08:49 +02:00
Gigi
f52b94d72a chore: bump version to 0.10.20 2025-10-23 20:07:04 +02:00
Gigi
d0833b5ed4 fix(lint): add headings to markdown rule files
Add top-level headings and proper spacing around lists in
markdown documentation files to satisfy markdown linting rules
2025-10-23 20:06:23 +02:00
Gigi
2f20b393bc fix(mobile): preserve scroll position and fix infinite loop
- Fix scroll position reset when toggling highlights panel on mobile
  by using a ref to store the position and requestAnimationFrame
  to restore it after the DOM updates
- Fix infinite loop in useReadingPosition caused by callbacks in
  dependency array by storing them in refs instead
2025-10-23 20:04:18 +02:00
Gigi
13fa6cd485 fix(mobile): preserve scroll position when toggling highlights panel
When opening/closing the highlights sidebar on mobile, the body gets
position:fixed to prevent background scrolling. This was causing the
scroll position to reset to the top.

Now we save the scroll position before locking, apply it as a negative
top value to maintain visual position, and restore it when unlocking.
2025-10-23 20:00:16 +02:00
Gigi
e6e7240cd5 fix: navigate to article route instead of passing empty URL
- Update CompactView to navigate to /a/:naddr for kind:30023 articles
- Update BookmarkItem handleReadNow to navigate to /a/:naddr for articles
- Fixes issue where clicking bookmarked articles showed 'Select a bookmark' message
2025-10-23 19:55:11 +02:00
Gigi
c1ff3b44d1 fix: stop infinite skeleton loading when article has zero highlights 2025-10-23 17:32:54 +02:00
Gigi
0577f862fd feat: show Web Bookmarks first when grouped by source 2025-10-23 17:30:55 +02:00
Gigi
883cb352ff chore: update package-lock version to 0.10.19 2025-10-23 17:26:44 +02:00
Gigi
238cc9bc00 docs: update CHANGELOG for v0.10.19 2025-10-23 17:06:34 +02:00
Gigi
1800ee324e chore: bump version to 0.10.19 2025-10-23 17:01:33 +02:00
Gigi
7d2dac2f1a fix: remove unused props and clean up linting errors
- Remove unused lastFetchTime parameter from BookmarkList
- Remove unused loading and onRefresh parameters from HighlightsPanelHeader
- Update HighlightsPanel to not pass removed props
- All linting and type checking now passing
2025-10-23 17:00:55 +02:00
Gigi
7875f1d0bd refactor: remove refresh button from bookmarks sidebar
Remove the refresh IconButton from bookmarks sidebar and clean up unused imports (faRotate, formatDistanceToNow).
2025-10-23 16:57:56 +02:00
Gigi
d9263e07d1 refactor: remove refresh button from highlights sidebar
Remove the refresh IconButton from highlights panel header as it's no longer needed.
2025-10-23 16:56:51 +02:00
Gigi
9a345a7347 style: match highlights collapse button to bookmarks collapse button
Replace IconButton with native button element and apply same CSS styling as bookmarks collapse button for visual consistency.
2025-10-23 16:56:04 +02:00
Gigi
55d1af3bf9 refactor: move collapse highlights button to left side
Move the collapse highlights panel button from right to left side of the header, making it symmetrical to the bookmarks collapse button. Desktop only (hidden on mobile).
2025-10-23 16:54:08 +02:00
Gigi
feb3134b65 refactor: move grouping toggle to left side next to support button
Move the grouped/chronological toggle button from right/center to the left side, positioned next to the orange heart support button in both BookmarkList and Me components for better UX consistency.
2025-10-23 16:52:24 +02:00
Gigi
7d222e099f refactor: make profile picture trigger dropdown menu
Remove separate three-dot button and make the profile picture itself trigger the dropdown menu. This provides a cleaner, more intuitive UX.
2025-10-23 16:49:54 +02:00
Gigi
59436b5b9e refactor: remove redundant logout button from sidebar header
Remove standalone logout IconButton next to explore since logout is now available in the three-dot profile menu
2025-10-23 16:48:18 +02:00
Gigi
2e08954e83 feat: add three-dot profile menu to sidebar header
Add dropdown menu next to profile picture in bookmarks sidebar with:
- My Highlights
- My Bookmarks
- My Reads
- My Links
- My Writings
- Separator
- Logout

Includes click-outside-to-close functionality and smooth animations.
2025-10-23 16:47:26 +02:00
Gigi
9cb1791a3a docs: update CHANGELOG for v0.10.18 2025-10-23 16:39:07 +02:00
Gigi
28ba620967 chore: bump version to 0.10.18 2025-10-23 16:37:43 +02:00
Gigi
56f2d33e93 fix: fetch all highlights without incremental loading
- Remove incremental loading (since filter) from highlightsController
- Fetch ALL highlights without limits for complete results
- Remove unused timestamp tracking methods and constant
- Ensures /my/highlights shows all highlights consistently
- Matches the fix applied to writingsController
2025-10-23 16:36:02 +02:00
Gigi
312c742969 refactor: centralize writings fetching in controller
- Remove incremental loading (since filter) from writingsController
- Fetch ALL writings without limits for complete results
- Remove duplicate background fetch from Me.tsx and Profile.tsx
- Use writingsController.start() in Profile to populate event store
- Keep code DRY by having single source of truth in controller
- Follows controller pattern: stream, dedupe, store, emit
2025-10-23 16:31:56 +02:00
Gigi
0781c4ebfc fix: fetch all writings in background on /my/writings page
Add background fetch effect to Me component to populate event store with
all writings without limits, matching the behavior of Profile component.
This ensures all writings are displayed on /my/writings page.
2025-10-23 16:28:50 +02:00
Gigi
85f4cd3590 docs: update FEATURES and CHANGELOG from /me to /my 2025-10-23 16:18:32 +02:00
Gigi
89bc6258b1 docs: update CSS comments from Me to My 2025-10-23 16:14:32 +02:00
Gigi
534b628aea refactor: update ShareTargetHandler to navigate to /my/links 2025-10-23 16:13:35 +02:00
Gigi
317d2e0b53 refactor: update Bookmarks component to detect /my routes 2025-10-23 16:13:20 +02:00
Gigi
9ea69589fa refactor: update sidebar avatar navigation to /my 2025-10-23 16:12:52 +02:00
Gigi
89eaa97d30 refactor: update Me component navigation to /my routes 2025-10-23 16:12:33 +02:00
Gigi
0283405fb5 refactor: rename /me routes to /my in App.tsx 2025-10-23 16:12:01 +02:00
Gigi
5eade913d1 docs: update CHANGELOG for v0.10.17 2025-10-23 16:01:31 +02:00
Gigi
15a7129b6d chore: bump version to 0.10.17 2025-10-23 15:59:45 +02:00
Gigi
b9e17e0982 fix: remove unused variable in highlight timestamp handler 2025-10-23 15:59:11 +02:00
Gigi
1be8c62c94 fix: restore edge-to-edge hero image on mobile with adjusted negative margins 2025-10-23 15:56:34 +02:00
Gigi
e2bf243b01 style: increase mobile reader padding to 1rem for better title/body alignment 2025-10-23 15:55:00 +02:00
Gigi
85d816b2a7 style: increase horizontal padding in reader on mobile for better readability 2025-10-23 15:52:36 +02:00
Gigi
623bee4632 fix: timestamp in highlight cards now opens content in app instead of external search 2025-10-23 09:44:20 +02:00
Gigi
e68b97bde8 fix: add equal right padding to blockquotes for better mobile layout 2025-10-23 09:41:25 +02:00
Gigi
ca32dfca51 perf: reduce reading position throttle from 3s to 1s
Reading position now saves every 1 second during continuous scrolling
instead of every 3 seconds, providing more frequent position updates.
2025-10-23 01:04:54 +02:00
Gigi
9de8b00d5d chore: remove remaining console.log statements from reading position code
Removed all debug logs from readingPositionService.ts that were left
from the stabilization collector implementation.
2025-10-23 00:56:53 +02:00
Gigi
033ef5e995 fix: relay article link now opens via /a/ path instead of /r/
Updated handleLinkClick in PWASettings to check if URL is an internal
route (starts with /) and navigate directly, otherwise wrap external
URLs with /r/ path. This fixes the third relay education link to open
the nostr article correctly.
2025-10-23 00:54:29 +02:00
Gigi
c986b0d517 feat: add setting to control auto-scroll to reading position
- Added autoScrollToReadingPosition setting (enabled by default)
- Users can now disable auto-scroll while keeping position sync enabled
- Setting appears in Layout & Behavior section of settings
- Auto-scroll only happens when both syncReadingPosition and
  autoScrollToReadingPosition are enabled
2025-10-23 00:52:51 +02:00
Gigi
1729a5b066 chore: remove debug logs from reading position code 2025-10-23 00:51:01 +02:00
Gigi
c6186ea84e docs: update CHANGELOG for v0.10.16 2025-10-23 00:48:24 +02:00
Gigi
c798376411 chore: bump version to 0.10.16 2025-10-23 00:47:38 +02:00
Gigi
e83c301e6a fix(reading-position): don't clear save timer when tracking toggles
The save timer was being cleared every time the effect unmounted (when
tracking toggled on/off), preventing saves from ever completing.

Now the save timer persists across tracking toggles and will fire even
if tracking is temporarily disabled. This fixes the core issue where
saves were scheduled but never executed.
2025-10-23 00:45:05 +02:00
Gigi
2c0aee3fe4 debug(reading-position): add comprehensive logging to scheduleSave
Adding detailed logs to trace exactly what's happening when saves
are attempted. This will help identify why saves aren't working.
2025-10-23 00:43:42 +02:00
Gigi
d0f043fb5a debug(reading-position): add logging to track isTextContent changes
Added detailed logging to understand why isTextContent is changing
and causing tracking to toggle on/off.
2025-10-23 00:42:29 +02:00
Gigi
039b988869 fix(reading-position): prevent tracking from toggling on/off
Added logic to properly disable tracking when isTextContent becomes false.
This prevents the tracking state from flipping and ensures saves work
consistently.

Now tracking is only enabled once content is stable and stays enabled
until the article changes or content becomes unsuitable.
2025-10-23 00:42:08 +02:00
Gigi
d285003e1d fix(reading-position): fix infinite loop and enable saves
Fixed maximum update depth error by using refs for html/markdown content
instead of including them in useCallback dependencies. This prevents
handleSavePosition from being recreated on every content change, which
was causing scheduleSave to recreate, triggering infinite effect loops.

Now:
- handleSavePosition is stable across renders
- scheduleSave is stable
- Effect doesn't re-run infinitely
- Saves work properly with 3s throttle
2025-10-23 00:40:36 +02:00
Gigi
530abeeb33 fix(reading-position): remove noisy suppression logs and reduce suppression time
Changes:
- Removed log spam during suppression (was logging on every scroll event)
- Reduced suppression time from 2000ms to 1500ms for smooth scroll
  (500ms render delay + 1000ms smooth scroll animation)

The suppression still works but is now silent to avoid console spam.
After smooth scroll completes, saves will resume normally.
2025-10-23 00:38:30 +02:00
Gigi
3ac6954cb7 refactor(reading-position): remove unused complexity
Removed unnecessary refs and logic that are no longer needed with
the simple 3s throttle:

- Removed lastSavedPosition (not used for any logic)
- Removed hasSavedOnce (not used)
- Removed lastSavedAtRef (not used)
- Removed saveNow() function (no longer needed after removing save-on-unmount)
- Simplified to just lastSaved100Ref to prevent duplicate 100% saves

The hook is now much simpler and easier to understand.
2025-10-23 00:36:20 +02:00
Gigi
1c0f619a47 refactor(reading-position): remove 5% delta requirement
Simplified throttle logic to just save every 3 seconds during scrolling,
regardless of how much the position changed. This ensures all position
updates are captured reliably.

The 5% check was causing issues and unnecessary complexity. Now:
- First scroll schedules a save in 3s
- Continued scrolling updates pending position
- Timer fires and saves latest position
- Next scroll schedules another save in 3s

Simple and reliable.
2025-10-23 00:34:47 +02:00
Gigi
0fcfd200a4 fix(reading-position): fix throttle logic to work with slow scrolling
Previous fix didn't work because after a save, the 5% check would
prevent scheduling a new timer during slow scrolling.

Changes:
- Always update pendingPositionRef (line 62)
- Schedule timer if significant change OR 3s has passed since last save
- Check 5% delta again when timer fires before actually saving

This ensures continuous slow scrolling triggers saves every 3s.
2025-10-23 00:33:55 +02:00
Gigi
e01c8d33fc fix(reading-position): use throttle instead of debounce for saves
Changed from debounce (which resets timer on every scroll) to throttle
(which saves at regular 3s intervals). This ensures position is saved
during continuous slow scrolling.

Key changes:
- Don't reset timer if one is already pending
- Track latest position in pendingPositionRef
- Save the latest position when timer fires, not the position from when scheduled

This prevents the issue where slow continuous scrolling would never
trigger a save because the debounce timer kept resetting.
2025-10-23 00:31:29 +02:00
Gigi
51c0f7d923 fix(highlights): scroll to highlight when clicked from /me/highlights
Pass highlightId and openHighlights in navigation state when clicking
highlights from the highlights list. This triggers the scroll behavior
in Bookmarks.tsx that was already implemented but not being used.

The useHighlightInteractions hook automatically scrolls to the selected
highlight once the article loads and the highlight mark is found in the DOM.
2025-10-23 00:27:35 +02:00
Gigi
8c79b5fd75 docs: update CHANGELOG for v0.10.15 2025-10-23 00:26:13 +02:00
Gigi
29746f1042 chore: bump version to 0.10.15 2025-10-23 00:24:49 +02:00
Gigi
829ec4bf6e fix(reading-position): fix infinite loop causing spam saves
The root cause was scheduleSave being in the scroll effect's dependency array.
Even though scheduleSave had an empty dependency array, React still saw it as
a dependency and re-ran the effect constantly, causing unmount/remount loops
and triggering flush-on-unmount repeatedly.

Solution: Store scheduleSave in a ref (scheduleSaveRef) and call it via the ref
in the scroll handler. This removes scheduleSave from the effect dependencies
while still allowing the scroll handler to access the latest version.

This fixes the "Maximum update depth exceeded" error and stops the spam saves.
2025-10-23 00:20:55 +02:00
Gigi
30ae0d9dfb fix(reading-position): prevent spam saves during scroll animation
The issue was that scheduleSave and saveNow had syncEnabled/onSave in their
dependency arrays, causing them to be recreated when those props changed.
This triggered the scroll effect to unmount/remount repeatedly during smooth
scroll animations, flushing saves on each unmount.

Solution: Use refs (syncEnabledRef, onSaveRef) for all callback dependencies,
making scheduleSave and saveNow stable with empty dependency arrays. This
prevents effect re-runs and stops the save spam.

Now the scroll effect only runs once per article load, not on every render.
2025-10-23 00:19:04 +02:00
Gigi
8924f1b307 fix(reading-position): flush pending saves on unmount
Previously, if user navigated away within the 3-second debounce window,
the pending save would be canceled and reading progress would be lost.

Now flushes any pending save on unmount if:
- There's a pending save timer active
- Position has changed by at least 5% since last save
- Not currently in suppression window (e.g., during restore)

This ensures reading progress is always saved even when navigating away
quickly, while still avoiding the 0% save issue from back navigation
(which doesn't trigger scroll events that would set up a pending save).

Uses refs to stabilize cleanup function and avoid effect re-runs.
2025-10-23 00:16:51 +02:00
Gigi
f92fa2cc93 fix(reading-position): prevent 0% saves during back navigation
Removed save-on-unmount behavior that was causing 0% position saves when
using mobile back gesture. The browser scrolls to top during navigation,
triggering a position update to 0% before unmount, which then gets saved.

The auto-save with 3-second debounce already captures position during
normal reading, so saving on unmount is unnecessary and error-prone.

Fixes issue where back gesture on mobile would overwrite reading progress.
2025-10-23 00:15:21 +02:00
Gigi
cc70b533e5 refactor(reading-position): use pre-loaded data from controller
Instead of fetching reading position from scratch using collectReadingPositionsOnce,
now uses the position already loaded by readingProgressController and displayed on cards.

Benefits:
- Faster restore (no network wait)
- Simpler code (no stabilization window needed)
- Data consistency (same data shown on card and used for restore)
- Reduced relay queries
2025-10-23 00:06:35 +02:00
Gigi
003c439658 feat(reading-position): restore smooth animated scroll
Changed scroll behavior from 'auto' to 'smooth' when restoring reading position for better UX.
2025-10-23 00:05:05 +02:00
Gigi
019958073c fix(lint): add missing dependencies to restore effect
Added isTrackingEnabled and restoreKey to dependency array to satisfy react-hooks/exhaustive-deps rule.
2025-10-23 00:04:33 +02:00
Gigi
3d47dddbd2 refactor(reading): simplify back to basics, remove complex timing logic
Removed:
- isTrackingEnabled state and delays
- Complex composite keys
- Verbose debug logging
- isTrackingEnabledRef checks

Back to simple:
- isTextContent = basic check (loading, content exists, not video)
- Restore once per articleIdentifier
- Save on unmount
- Suppression during restore window

Much simpler, closer to original working version.
2025-10-23 00:02:26 +02:00
Gigi
cabf897df8 fix(reading): stabilize tracking enabled state to prevent reset loops
Split tracking enable logic into two effects:
1. Reset to false when article changes (selectedUrl)
2. Enable after 500ms if isTextContent is true AND not already enabled

Prevents isTextContent flipping from resetting the timer repeatedly, which was preventing isTrackingEnabled from ever becoming true.
2025-10-22 23:59:06 +02:00
Gigi
4801c0d621 debug(reading): add detailed logging to restore effect
Add comprehensive debug log showing all dependency states to diagnose why restore never initiates.
2025-10-22 23:57:20 +02:00
Gigi
ae76d6e4ea chore(reading): remove noisy debounce log messages
Removed 'Debouncing save for 3000ms' logs that spam console on every scroll event. Keep only the actual save execution logs.
2025-10-22 23:55:59 +02:00
Gigi
a611e99ff6 fix(reading): only saveNow on unmount if tracking was enabled
Prevents saving 0% position when navigating away before tracking starts. Now checks isTrackingEnabledRef before calling saveNow() in unmount effect.
2025-10-22 23:53:50 +02:00
Gigi
1c039e164f fix(reading): wait for tracking to be enabled before attempting restore
Use composite key (articleIdentifier + isTrackingEnabled) to ensure restore only happens once after:
1. Article loads
2. Content is validated (long enough)
3. 500ms stability delay passes
4. Tracking is enabled

Prevents multiple rapid restore attempts during initial load.
2025-10-22 23:50:50 +02:00
Gigi
ffa4b38106 fix(reading): reset restore attempt tracker when article changes
Previously, if restore was skipped due to missing dependencies (content not loaded), it would never retry even after content loaded. Now resets the attempt tracker whenever articleIdentifier changes, allowing retry when dependencies become available.
2025-10-22 23:49:07 +02:00
Gigi
3b22cb5c5d feat(reading): only track position on loaded, long-enough content
- Check content length before enabling tracking (uses existing 1000 char minimum)
- Wait 500ms after content loads before enabling tracking (ensures stability)
- Prevents tracking on short notes and during page load transitions
- isTextContent now uses useMemo with comprehensive checks
2025-10-22 23:46:19 +02:00
Gigi
7bc4522be4 fix(reading): prevent false 100% detection during page transitions
Add guards to prevent detecting 100% completion when:
- Document height is < 100px (likely during transition)
- scrollTop is < 100px (not actually scrolled)

Prevents accidentally saving 100% when navigating away at 50%.
2025-10-22 23:35:55 +02:00
Gigi
048e0d802b fix(reading): make saveNow respect suppression flag
saveNow() was bypassing suppression, causing 0% to overwrite saved positions during restore. Now checks suppressUntilRef before saving, just like the debounced auto-save.
2025-10-22 23:33:31 +02:00
Gigi
b282bc4972 fix(reading): suppress saves during restore to prevent overwriting
- Suppress saves for 1700ms when restore starts (covers collection + render time)
- If no position found or delta too small, clear suppression immediately
- If restore happens, extend suppression for 1.5s after scroll
- Prevents 0% from overwriting saved 22% position during page load
2025-10-22 23:31:47 +02:00
Gigi
c1a23c1f8f fix(reading): prevent restore effect from restarting during content load
Track whether we've already attempted restore for each article using a ref. Prevents the effect from restarting multiple times as html/markdown/loading state changes during initial page load, which was stopping the stabilization timer before it could complete.
2025-10-22 23:29:29 +02:00
Gigi
8a5aacfe7b feat(reading): allow saving 0% position for open tracking
Removed the check that prevented saving 0% positions. Now tracks when articles are opened, even if not read yet. Useful for engagement metrics and history.
2025-10-22 23:28:28 +02:00
Gigi
9126910de5 fix(reading): stabilize restore effect and prevent 0% saves
- Use ref for suppressSavesFor to prevent restore effect from restarting on every position change
- Skip saving positions at 0% (meaningless start position)
- Restore effect now only restarts when article actually changes, not on every scroll
2025-10-22 23:24:56 +02:00
Gigi
496bbc36f4 fix(reading): prevent saveNow from firing on every position change
The unmount effect had saveNow in its dependency array. Since saveNow is a useCallback that depends on position, it was recreated on every scroll event, triggering the effect cleanup and calling saveNow() repeatedly (every ~14ms).

Now using a ref to store the latest saveNow callback, so the cleanup only runs when selectedUrl changes (i.e., when actually navigating away).
2025-10-22 23:22:16 +02:00
Gigi
90f25420b2 debug(reading): add ISO timestamps to all position logs
Makes it easy to see exact timing of saves and identify if debouncing is working correctly.
2025-10-22 23:21:11 +02:00
Gigi
9167134a89 refactor(reading): increase debounce to 3 seconds 2025-10-22 23:18:40 +02:00
Gigi
b5717f1ebf docs: update CHANGELOG with simplified debouncing 2025-10-22 23:17:53 +02:00
Gigi
0c8eaaf220 refactor(reading): simplify save debouncing to 2s max
Replace complex interval logic with simple 2-second debounce. Every scroll event resets the timer, so saves only happen after 2s of no scrolling (or when reaching 100%). Much less aggressive than the previous 15s minimum interval.
2025-10-22 23:17:34 +02:00
Gigi
80b2720838 docs: update CHANGELOG with debug logging addition 2025-10-22 23:14:47 +02:00
Gigi
ea69740fc8 debug(reading): add comprehensive logging for position restore and save
Add detailed console logs to trace:
- Position collection and stabilization
- Save scheduling, suppression, and execution
- Restore calculations and decisions
- Scroll deltas and thresholds

Logs use [reading-position] prefix with emoji indicators for easy filtering and visual scanning.
2025-10-22 23:14:29 +02:00
Gigi
d650997ff9 docs: update CHANGELOG with reading restore stabilization 2025-10-22 23:06:54 +02:00
Gigi
ba3554b173 feat(reading): one-shot restore with suppression in ContentPanel
Replace continuous restore with stabilized one-shot collector. Suppress saves for 1.5s after restore, skip tiny deltas (<48px or <5%), and use instant scroll (behavior: auto) to eliminate jumpy view behavior from conflicting relay updates.
2025-10-22 23:06:34 +02:00
Gigi
2cc39d0200 feat(reading): add stabilized position collector in readingPositionService
Add collectReadingPositionsOnce() that buffers position updates for ~700ms, then emits the best one (newest timestamp, tie-break by highest progress). Prevents jumpy scrolling from conflicting relay updates.
2025-10-22 23:05:50 +02:00
Gigi
9aa914a704 feat(reading): add save suppression to useReadingPosition
Add suppressSavesFor(ms) API to temporarily block auto-saves after programmatic scrolls, preventing feedback loops where restore triggers save which triggers restore.
2025-10-22 23:05:11 +02:00
Gigi
497b6fa4be docs: update CHANGELOG for v0.10.14 2025-10-22 15:49:23 +02:00
Gigi
4c838b0123 chore: bump version to 0.10.14 2025-10-22 15:48:36 +02:00
Gigi
d551f66ef1 feat: add Relay Setup 101 article link to PWA settings
- Added third relay education article link in PWA settings
- Links to /a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8
- Updated punctuation to use commas for better readability (here, here, and here)
2025-10-22 15:47:26 +02:00
Gigi
34514199ee feat: timestamp in cards now opens content in app instead of external search
- Changed timestamp links in CardView and LargeView to use internal routes
- Articles (kind:30023) open in /a/{naddr}
- Notes (kind:1) open in /e/{eventId}
- External URLs open in /r/{encodedUrl}
- Removed unused eventNevent prop and neventEncode import
- Timestamp now uses Link component for client-side navigation
2025-10-22 15:46:25 +02:00
Gigi
228304f68a fix: prevent duplicate video embeds and stray HTML artifacts
- Refactored VideoEmbedProcessor to process HTML and extract URLs in single pass
- Previously processedHtml and videoUrls were computed separately, causing index mismatches
- Now both are computed together ensuring placeholders match collected URLs
- Added check to skip empty HTML parts to prevent rendering stray characters
2025-10-22 15:42:19 +02:00
Gigi
ba263acdff fix: stop highlights loading spinner when article has no highlights 2025-10-22 15:40:18 +02:00
Gigi
5131cbe12c docs: update CHANGELOG for v0.10.13 2025-10-22 15:38:08 +02:00
Gigi
fa8eed4f4e chore: bump version to 0.10.13 2025-10-22 15:36:42 +02:00
Gigi
3ff57c4b67 fix(lint): add previewData to useArticleLoader effect dependencies 2025-10-22 15:36:03 +02:00
Gigi
51c364ea53 feat(article): instant preview from blog cards - show title, image, summary, date immediately via navigation state while content loads 2025-10-22 15:33:37 +02:00
Gigi
4d032372dc fix(explore): show blog post skeletons instead of spinner when loading writings tab 2025-10-22 15:31:19 +02:00
Gigi
48b5aa3a30 feat(article): instant load from eventStore when clicking bookmark cards - check store by coordinate before relay query 2025-10-22 15:29:08 +02:00
Gigi
d4483a2f91 fix(lint): resolve eslint warnings in useArticleLoader - add comment for empty catch, use settingsRef consistently 2025-10-22 15:26:16 +02:00
Gigi
c62cb21962 fix(article): wire eventStore to useArticleLoader for instant local-first loads; keep SW enabled in prod for PWA 2025-10-22 15:24:24 +02:00
Gigi
3f7d726ae6 feat(article): local-first streaming loader using eventStore + queryEvents in useArticleLoader; emit immediately on first store/relay hit; finalize on EOSE 2025-10-22 15:22:39 +02:00
Gigi
ac0e5eb585 fix(article): add reliable-relay fallback (nostr.band, primal, damus, nos.lol) when first parallel query returns no events 2025-10-22 14:03:47 +02:00
Gigi
5a0dd49e4e fix(sw): disable Service Worker in dev and register non-module SW only in production to avoid stale cached HTML causing mismatched content 2025-10-22 14:01:53 +02:00
Gigi
d067193f21 fix(reader): force re-mount of markdown preview and rendered HTML per-content to eliminate stale display when switching articles 2025-10-22 13:46:57 +02:00
Gigi
774e2ba67c fix(reader): clear markdown render on change and add request guards to external URL loader to prevent stale content 2025-10-22 13:45:41 +02:00
Gigi
6f1c31058f fix(reader): guard against stale article fetches overwriting current content/highlights via requestId in useArticleLoader 2025-10-22 13:41:46 +02:00
Gigi
7551a05aee fix(article): prevent re-fetch on settings change by memoizing via ref in useArticleLoader 2025-10-22 13:38:24 +02:00
Gigi
df485b883d fix(article): query union of naddr relay hints and configured relays to prevent post-load ‘Article not found’ refresh 2025-10-22 13:35:59 +02:00
Gigi
6f428af1bc docs: update CHANGELOG for v0.10.12 2025-10-22 13:32:05 +02:00
Gigi
e821aaf058 chore: bump version to 0.10.12 2025-10-22 13:30:37 +02:00
Gigi
a84d439489 fix: properly deduplicate web bookmarks by d-tag
- Web bookmarks (kind:39701) are replaceable events and should be deduplicated by d-tag
- Update dedupeNip51Events to include kind:39701 in d-tag deduplication logic
- Use coordinate format (kind:pubkey:d-tag) for web bookmark IDs instead of event IDs
- Ensures same URL bookmarked multiple times only appears once
- Keeps newest version when duplicates exist
2025-10-22 13:28:36 +02:00
Gigi
67bf7e017d fix: make profile avatar button same size as other icon buttons on mobile
- Add mobile media query to profile-avatar-button for consistent sizing
- Use --min-touch-target (44px) on mobile to match IconButton components
- Ensures consistent touch target size across all sidebar buttons
2025-10-22 13:26:20 +02:00
Gigi
e47419a0b8 feat: update explore icon to fa-person-hiking and reorder sidebar buttons
- Change explore icon from fa-newspaper to fa-person-hiking in SidebarHeader and Explore components
- Switch positions of settings and explore buttons in sidebar navigation
- Remove all console.log statements from bookmarkController and bookmarkProcessing
- Update CHANGELOG.md with v0.10.11 changes
2025-10-22 13:25:34 +02:00
Gigi
2dda52c30f chore: bump version to 0.10.11 2025-10-22 13:19:36 +02:00
Gigi
2e0a493243 fix(bookmarks): sort by display time (created_at || listUpdatedAt) desc; nulls last 2025-10-22 13:07:37 +02:00
Gigi
2e955e9bed refactor(bookmarks): never default timestamps to now; allow nulls and sort nulls last; render empty when missing 2025-10-22 13:04:24 +02:00
Gigi
538cbd2296 fix(bookmarks): show sane dates using created_at fallback to listUpdatedAt; guard formatters 2025-10-22 12:54:26 +02:00
Gigi
c17eab5a47 fix(router): route /me/reading-list -> /me/bookmarks to render Bookmarks view 2025-10-22 12:49:23 +02:00
Gigi
b3c61ba635 fix: update Me.tsx bookmarks tab to use dynamic filter titles and chronological sorting 2025-10-22 12:46:16 +02:00
Gigi
3bfa750a0c fix: update Me.tsx to use faClock icon instead of faBars 2025-10-22 12:42:42 +02:00
Gigi
d1f7e549c2 fix: change bookmark URL from /me/reading-list to /me/bookmarks 2025-10-22 12:33:05 +02:00
Gigi
0fec120410 debug: add targeted logging to diagnose listUpdatedAt timestamp issue 2025-10-22 12:31:53 +02:00
Gigi
9b21075a9b refactor: remove excessive debug logging 2025-10-22 12:29:09 +02:00
Gigi
4f78ee4794 fix: preserve content created_at, add listUpdatedAt for sorting by when bookmarked 2025-10-22 12:26:01 +02:00
Gigi
8bb871913b refactor: remove synthetic added_at field, use created_at from bookmark list event 2025-10-22 12:18:43 +02:00
Gigi
49eb6855ca debug: add console logging for bookmark timestamp and sorting analysis 2025-10-22 12:14:36 +02:00
Gigi
748b2e1631 fix: correct added_at timestamp to use bookmark list creation time, not content creation time 2025-10-22 12:12:44 +02:00
Gigi
9fa83a2a1c fix: ensure robust sorting of merged bookmarks with fallback timestamps 2025-10-22 12:07:32 +02:00
Gigi
d45705e8e4 feat: use clock icon (regular style) for chronological bookmark view 2025-10-22 12:05:57 +02:00
Gigi
83c170b4e2 fix: ensure bookmarks are consistently sorted chronologically with useMemo 2025-10-22 12:04:41 +02:00
Gigi
8459853c43 refactor: remove bookmark count from section headings 2025-10-22 12:02:24 +02:00
Gigi
f7eeb080e1 feat: update bookmark heading based on selected filter 2025-10-22 12:01:09 +02:00
Gigi
2769b2dba7 fix: remove unused faTimes import 2025-10-22 11:51:17 +02:00
Gigi
46636b8e6a feat: move profile picture to first position (left-aligned) with consistent sizing 2025-10-22 11:50:16 +02:00
Gigi
92a85761ef feat: make highlight count clickable to open highlights sidebar 2025-10-22 11:48:41 +02:00
Gigi
f6a325f7e9 feat: hide close/collapse sidebar buttons on mobile 2025-10-22 11:45:51 +02:00
Gigi
a501fa816f feat: sort bookmarks chronologically by displayed date (newest first) 2025-10-22 11:43:30 +02:00
Gigi
5ece80b8e9 feat: change default bookmark view to flat chronological list 2025-10-22 11:42:12 +02:00
Gigi
87c017b2c2 docs: update CHANGELOG for v0.10.10 2025-10-22 11:38:09 +02:00
Gigi
550ee415f0 chore: bump version to 0.10.10 2025-10-22 11:37:08 +02:00
Gigi
aaaf226623 Merge pull request #24 from dergigi/controllers-and-fetching
Replace timeouts with streaming controllers and fix bookmark hydration
2025-10-22 11:36:35 +02:00
Gigi
23ce0c9d4c chore: remove debug logging from bookmark controller 2025-10-22 11:33:41 +02:00
Gigi
dddf8575c4 fix: resolve TypeScript type errors in bookmark hydration promises
Add .then() handlers to convert Promise<NostrEvent[]> to Promise<void>
for compatibility with Promise<void>[] array type.
2025-10-22 11:30:53 +02:00
Gigi
3ab0610e1e fix: prevent cascading hydration loops in bookmark controller
Run all coordinate queries in parallel with Promise.all instead of
sequential awaits. This prevents each query from triggering a rebuild
that causes another hydration cycle, which was creating infinite loops.

The issue was that awaiting each query sequentially would:
1. Fetch articles for author A
2. Call onProgress, rebuild bookmarks
3. Trigger new hydration because coordinates changed
4. Repeat indefinitely

Now all queries start at once and stream results as they arrive,
matching the original loader behavior.
2025-10-22 11:27:12 +02:00
Gigi
e40f820fdc fix: handle empty d-tags separately in bookmark hydration
Separate fetching of articles with empty vs non-empty d-tags to work
around relay filter handling issues. For empty d-tags, fetch all events
of that kind/author and filter client-side.
2025-10-22 11:25:30 +02:00
Gigi
3f82bc7873 debug: add logging for bookmark coordinate hydration 2025-10-22 11:23:26 +02:00
Gigi
b913cc4d7f fix: hide 'Open Original' button for nostr-native events
Only external URLs (/r/ paths) have original sources.
Nostr-native events don't need this option in the three-dot menu.
2025-10-22 11:21:08 +02:00
Gigi
bc1aed30b4 fix: open nostr events directly on ants.sh instead of as search query
When clicking search in the three-dot menu for a nostr event,
now opens https://ants.sh/e/<eventId> directly instead of
https://ants.sh/?q=nostr-event:<eventId>
2025-10-22 11:20:12 +02:00
Gigi
9a801975aa fix(bookmarks): replace applesauce loaders with local-first queryEvents
Replace EventLoader and AddressLoader with queryEvents for bookmark
hydration to properly prioritize local relays. The applesauce loaders
were not using local-first fetching strategy, causing bookmarked events
to not be hydrated from local relay cache.

- Remove createEventLoader and createAddressLoader usage
- Replace with queryEvents which handles local-first fetching
- Properly streams events from local relays before remote relays
- Follows the controller pattern used by other services (writings, etc)

This fixes the issue where bookmarks would only show event IDs instead
of full content, while blog posts (kind:30023) worked correctly.
2025-10-22 11:16:21 +02:00
Gigi
f3e44edd51 fix: remove unnecessary key prop causing lag on tab switching in Explore 2025-10-22 11:09:05 +02:00
Gigi
0be6aa81ce fix: add comments to empty catch blocks to satisfy linter 2025-10-22 09:00:01 +02:00
Gigi
c7b885cfcd refactor(reader): use startReadingPositionStream in ContentPanel 2025-10-22 08:55:50 +02:00
Gigi
11041df1fb refactor(reading-position): add startReadingPositionStream and remove timeouts 2025-10-22 08:55:18 +02:00
Gigi
89273e2a03 refactor(settings): use startSettingsStream in useSettings hook 2025-10-22 08:54:45 +02:00
Gigi
0610454e74 feat(settings): add startSettingsStream and remove timeout-based blocking 2025-10-22 08:54:17 +02:00
Gigi
a02413a7cb fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard 2025-10-22 02:02:39 +02:00
Gigi
0bc84e7c6c chore: update package-lock.json for v0.10.9 2025-10-22 01:41:46 +02:00
Gigi
a1e28c6bc9 docs: update CHANGELOG for v0.10.9 2025-10-22 01:41:34 +02:00
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
Gigi
9ab6847501 chore: bump version to 0.9.0 2025-10-20 20:13:15 +02:00
Gigi
31afe3792e fix: replace any types with proper NostrEvent types in relayListService
- Import NostrEvent type from nostr-tools
- Replace any[] with NostrEvent[] for events array
- Replace Map<string, any> with Map<string, NostrEvent> for eventsMap
- Resolves ESLint warnings about explicit any usage
2025-10-20 20:13:05 +02:00
Gigi
ebe8ecf63b feat: stream user relay list into pool immediately and finalize after blocked relays
- loadUserRelayList accepts onUpdate callback to stream first user relay list
- App.tsx applies interim relay set on first event, keeps alive, then recomputes with blocked relays
- Keeps startup non-blocking and matches Debug page behavior
2025-10-20 20:10:08 +02:00
Gigi
c418000a0c fix: add streaming callback to relay list service for faster results
- Add onEvent streaming callback to relayListService queryEvents call
- Process events as they arrive instead of waiting for all relays to respond
- Deduplicate events by id and keep most recent version
- Remove artificial delay since streaming provides immediate results
- Should resolve hanging issue where debug works but app query hangs
2025-10-20 20:03:16 +02:00
Gigi
15fd19f6a4 fix: resolve all linting issues
- Remove unused DebugBus import from App.tsx
- Remove unused NostrEvent import from relayListService.ts
- Add comment to empty catch block in ContentPanel.tsx
- Remove unused targetUrlsMap variable from relayManager.ts
- All linting errors resolved, TypeScript type checking passes
2025-10-20 20:01:40 +02:00
Gigi
2a44b4e3c0 cleanup: remove temporary test relays from hardcoded list
- Remove temporary relay additions that were added for debugging
- Restore clean hardcoded relay list now that dynamic relay integration is working
- The non-blocking relay loading implementation handles user relay lists properly
2025-10-20 20:01:02 +02:00
Gigi
aa7807e3d2 fix: make relay list loading non-blocking in App.tsx
- Start with hardcoded relays immediately when user logs in
- Load user relay list and blocked relays in background Promise
- Apply user relay preferences when they become available
- Remove blocking await that was preventing immediate relay setup
- Update keep-alive subscription and address loader when user relays load
- Continue with initial relay set if user relay loading fails
2025-10-20 19:58:55 +02:00
Gigi
359d3d0dd6 feat: add relay list debug section to Debug component
- Add state variables for relay list loading (isLoadingRelayList, relayListEvents, timing)
- Add handleLoadRelayList function to query kind 10002 events
- Add handleClearRelayList function to clear loaded data
- Add UI section with Load/Clear buttons and event display
- Show relay URLs and permissions for each relay list event
- Add loadRelayList to live timing type definition
2025-10-20 19:56:00 +02:00
Gigi
d40b3c0048 debug: add more detailed logging to relay list query including broader query test 2025-10-20 19:54:07 +02:00
Gigi
7b4ca50b16 debug: add timeout to relay list query and temporarily add user's relays to hardcoded set to test relay list loading 2025-10-20 19:52:40 +02:00
Gigi
76e001aba4 debug: add logging to relay list loading to diagnose why user relay list is not found 2025-10-20 19:51:42 +02:00
Gigi
0b42aeb383 refactor: remove non-relay console.log statements
- Remove console.log statements from ContentPanel.tsx (archive/content related)
- Remove console.log statements from readingProgressController.ts (reading progress related)
- Remove console.log statements from reactionService.ts (reaction related)
- Remove debug console.log block from Me.tsx (archive/me related)
- Preserve all relay-related console.log statements in App.tsx and relayManager.ts
2025-10-20 19:46:50 +02:00
Gigi
a4554e5176 chore: remove non-relay debug output
Remove bunker-related debug logs and keep-alive subscription warnings.
Keep only relay-related logs ([relay-init] and [relayManager]) for debugging
relay loading and management.
2025-10-20 19:35:39 +02:00
Gigi
2e844fc26b fix: use user's relay list exclusively when logged in
When logged in:
- If user has relay list (kind:10002): use ONLY user relays + bunker + localhost
- If user has NO relay list: fall back to hardcoded RELAYS

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

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

Now we normalize URLs before comparison to match the pool's format.
2025-10-20 19:26:43 +02:00
Gigi
2e5536c331 debug: add logging to relay initialization to diagnose single relay issue 2025-10-20 19:18:03 +02:00
Gigi
fc025b9579 feat: integrate user relay lists (NIP-65) and blocked relays (NIP-51)
- Add relayListService to load kind:10002 (user relay list) and kind:10006 (blocked relays)
- Add relayManager to compute active relay set and dynamically manage pool membership
- Update App.tsx to fetch and apply user relays on login, reset on logout
- Replace all hardcoded RELAYS usages with dynamic getActiveRelayUrls() across services and components
- Always preserve localhost relays (ws://localhost:10547, ws://localhost:4869) regardless of user blocks
- Merge bunker relays, user relays, and hardcoded relays while excluding blocked relays
- Update keep-alive subscription and address loaders to use dynamic relay set
- Modified files: App.tsx, relayListService.ts (new), relayManager.ts (new), readsService.ts, readingProgressController.ts, archiveController.ts, libraryService.ts, reactionService.ts, writeService.ts, HighlightItem.tsx, ContentPanel.tsx, BookmarkList.tsx, Profile.tsx
2025-10-20 18:40:23 +02:00
Gigi
88db14c352 docs: update CHANGELOG for v0.8.6 2025-10-20 18:07:46 +02:00
106 changed files with 6340 additions and 2122 deletions

View File

@@ -2,4 +2,4 @@
alwaysApply: true
---
Keep files below 210 lines.
Keep files below 420 lines.

View File

@@ -0,0 +1,21 @@
---
description: fetching data from relays
alwaysApply: false
---
# Fetching Data with Controllers
We fetch data from relays using controllers:
- Start controllers immediatly; don't await.
- Stream via onEvent; dedupe replaceables; emit immediately.
- Parallel local/remote queries; complete on EOSE.
- Finalize and persist since after completion.
- Guard with generations to cancel stale runs.
- UI flips off loading on first streamed result.
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate localonly mode for writes (queueing for later).
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.

View File

@@ -3,6 +3,8 @@ description: anything related to UI/UX
alwaysApply: false
---
# Mobile-First UI/UX
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.

File diff suppressed because it is too large Load Diff

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.
@@ -39,7 +40,7 @@
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
## Support

View File

@@ -1,188 +0,0 @@
# Tailwind CSS Migration Status
## ✅ Completed (Core Infrastructure)
### Phase 1: Setup & Foundation
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
- [x] Create `src/styles/tailwind.css` with base/components/utilities
- [x] Import Tailwind before existing CSS in `main.tsx`
- [x] Enable Tailwind preflight (CSS reset)
### Phase 2: Base Styles Reconciliation
- [x] Add CSS variables for user-settable theme colors
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
- `--reading-font`, `--reading-font-size`
- [x] Simplify `global.css` to work with Tailwind preflight
- [x] Remove redundant base styles handled by Tailwind
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
- [x] Switch from pane-scrolling to document-scrolling
- [x] Make sidebars sticky on desktop (`position: sticky`)
- [x] Update `app.css` to remove fixed container heights
- [x] Update `ThreePaneLayout.tsx` to use window scroll
- [x] Fix reading position tracking to work with document scroll
- [x] Maintain mobile overlay behavior
### Phase 4: Component Migrations
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
- Removed 80+ lines of CSS
- Added shimmer animation to Tailwind config
- Z-index layering maintained (1102)
- [x] **Mobile UI Elements**: Tailwind utilities
- Mobile hamburger button
- Mobile highlights button
- Mobile backdrop
- Removed 60+ lines of CSS
- [x] **App Container**: Tailwind utilities
- Responsive padding (p-0 md:p-4)
- Min-height viewport support
## 📊 Impact & Metrics
### Lines of CSS Removed
- `global.css`: ~50 lines removed
- `reader.css`: ~80 lines removed (progress indicator)
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
- `sidebar.css`: ~30 lines removed (mobile hamburger)
- **Total**: ~190 lines removed
### Key Achievements
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
2. **Tailwind Integration**: Fully functional with preflight enabled
3. **No Breaking Changes**: All existing functionality preserved
4. **Type Safety**: TypeScript checks passing
5. **Lint Clean**: ESLint checks passing
6. **Responsive**: Mobile/tablet/desktop layouts working
## 🔄 Remaining Work (Incremental)
The following migrations are **optional enhancements** that can be done as components are touched:
### High-Value Components
- [ ] **ContentPanel** - Large component, high impact
- Reader header, meta info, loading states
- Mark as read button
- Article/video menus
- [ ] **BookmarkList & BookmarkItem** - Core UI
- Card layouts (compact/cards/large views)
- Bookmark metadata display
- Interactive states
- [ ] **HighlightsPanel** - Feature-rich
- Header with toggles
- Highlight items
- Level-based styling
- [ ] **Settings Components** - Forms & controls
- Color pickers
- Font selectors
- Toggle switches
- Sliders
### CSS Files to Prune
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
- `src/styles/components/cards.css` - Bookmark card styles
- `src/styles/components/modals.css` - Modal dialogs
- `src/styles/layout/highlights.css` - Highlight panel layout
## 🎯 Migration Strategy
### For New Components
Use Tailwind utilities from the start. Reference:
```tsx
// Good: Tailwind utilities
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
// Avoid: New CSS classes
<div className="custom-component">
```
### For Existing Components
Migrate incrementally when touching files:
1. Replace layout utilities (flex, grid, spacing, sizing)
2. Replace color/background utilities
3. Replace typography utilities
4. Replace responsive variants
5. Remove old CSS rules
6. Keep file under 210 lines
### CSS Variable Usage
Dynamic values should still use CSS variables or inline styles:
```tsx
// User-settable colors
style={{ backgroundColor: settings.highlightColorMine }}
// Or reference CSS variable
className="bg-[var(--highlight-color-mine)]"
```
## 📝 Technical Notes
### Z-Index Layering
- Mobile sidepanes: `z-[1001]`
- Mobile backdrop: `z-[999]`
- Progress indicator: `z-[1102]`
- Mobile buttons: `z-[900]`
- Relay status: `z-[999]`
- Modals: `z-[10000]`
### Responsive Breakpoints
- Mobile: `< 768px`
- Tablet: `768px - 1024px`
- Desktop: `> 1024px`
Use Tailwind: `md:` (768px), `lg:` (1024px)
### Safe Area Insets
Mobile notch support:
```tsx
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))'
}}
```
### Custom Animations
Add to `tailwind.config.js`:
```js
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}
```
## ✅ Success Criteria Met
- [x] Tailwind CSS fully integrated and functional
- [x] Document scrolling working correctly
- [x] Reading position tracking accurate
- [x] Progress indicator always visible
- [x] No TypeScript errors
- [x] No linting errors
- [x] Mobile responsiveness maintained
- [x] Theme colors (user settings) working
- [x] All existing features functional
## 🚀 Next Steps
1. **Ship It**: Current state is production-ready
2. **Incremental Migration**: Convert components as you touch them
3. **Monitor**: Watch for any CSS conflicts
4. **Cleanup**: Eventually remove unused CSS files
5. **Document**: Update component docs with Tailwind patterns
---
**Status**: ✅ **CORE MIGRATION COMPLETE**
**Date**: 2025-01-14
**Commits**: 8 conventional commits
**Lines Removed**: ~190 lines of CSS
**Breaking Changes**: None

View File

@@ -147,7 +147,7 @@ function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const title = meta?.title || 'Boris Read, Highlight, Explore'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'

View File

@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
return null
}
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
const vimeoUrl = `https://vimeo.com/${videoId}`
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
return {
title: data.title || '',
description: data.description || ''
description: data.description || '',
thumbnail_url: data.thumbnail_url || ''
}
}
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
if (videoInfo.source === 'youtube') {
// YouTube handling
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
const title = ''
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return ok(res, response)
} else if (videoInfo.source === 'vimeo') {
// Vimeo handling
const { title, description } = await getVimeoMetadata(videoInfo.id)
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
const response = {
title,
description,
thumbnail_url,
captions: [], // Vimeo doesn't provide captions through oEmbed API
transcript: '', // No transcript available
lang: 'en', // Default language

View File

@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}
try {
// Since getVideoDetails doesn't exist, we'll use a simple approach
// In a real implementation, you might want to use YouTube's API or other methods
const title = '' // Will be populated from captions or other sources
const description = ''
// Fetch basic metadata from YouTube page
let title = ''
let description = ''
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
if (response.ok) {
const html = await response.text()
// Extract title from HTML
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
if (titleMatch) {
title = titleMatch[1].replace(' - YouTube', '').trim()
}
// Extract description from meta tag
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
if (descMatch) {
description = descMatch[1].trim()
}
}
} catch (error) {
console.warn('Failed to fetch YouTube metadata:', error)
}
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))

View File

@@ -9,14 +9,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Boris - Nostr Bookmarks</title>
<title>Boris - Read, Highlight, Explore</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" />
@@ -24,7 +24,7 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />

77
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.8.4",
"version": "0.10.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.8.4",
"version": "0.10.23",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -35,6 +36,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": {
@@ -4501,6 +4503,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
@@ -6170,6 +6181,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-opengraph": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"html-entities": "^2.3.2"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6263,6 +6284,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6895,6 +6936,22 @@
"he": "bin/he"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -11215,6 +11272,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyld": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
"license": "MIT",
"bin": {
"tinyld": "bin/tinyld.js",
"tinyld-heavy": "bin/tinyld-heavy.js",
"tinyld-light": "bin/tinyld-light.js"
},
"engines": {
"node": ">= 12.10.0",
"npm": ">= 6.12.0",
"yarn": ">= 1.20.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.8.6",
"version": "0.10.26",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -26,6 +26,7 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -38,6 +39,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

@@ -1,5 +1,5 @@
{
"name": "Boris - Nostr Bookmarks",
"name": "Boris - Read, Highlight, Explore",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",
@@ -9,6 +9,16 @@
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "link"
}
},
"icons": [
{
"src": "/icon-192.png",

View File

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

View File

@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { fetchReadableContent } from '../services/readerService'
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
interface AddBookmarkModalProps {
onClose: () => void
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
}
// Helper to extract metadata from HTML
function extractMetaTag(html: string, patterns: string[]): string | null {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'))
if (match) return match[1]
}
return null
}
function extractTags(html: string): string[] {
// Helper to extract tags from OpenGraph data
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
const tags: string[] = []
// Extract keywords meta tag
const keywords = extractMetaTag(html, [
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (keywords) {
keywords.split(/[,;]/)
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0 && k.length < 30)
.forEach(k => tags.push(k))
// Extract keywords from OpenGraph data
if (ogData.keywords && typeof ogData.keywords === 'string') {
ogData.keywords.split(/[,;]/)
.map((k: string) => k.trim().toLowerCase())
.filter((k: string) => k.length > 0 && k.length < 30)
.forEach((k: string) => tags.push(k))
}
// Extract article:tag (multiple possible)
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
let match
while ((match = articleTagRegex.exec(html)) !== null) {
const tag = match[1].trim().toLowerCase()
if (tag && tag.length < 30) tags.push(tag)
// Extract article:tag from OpenGraph data
if (ogData['article:tag']) {
const articleTagValue = ogData['article:tag']
const articleTags = Array.isArray(articleTagValue)
? articleTagValue
: [articleTagValue]
articleTags.forEach((tag: unknown) => {
if (typeof tag === 'string') {
const cleanTag = tag.trim().toLowerCase()
if (cleanTag && cleanTag.length < 30) {
tags.push(cleanTag)
}
}
})
}
return Array.from(new Set(tags)).slice(0, 5)
@@ -83,17 +82,27 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
fetchTimeoutRef.current = window.setTimeout(async () => {
setIsFetchingMetadata(true)
try {
const content = await fetchReadableContent(normalizedUrl)
lastFetchedUrlRef.current = normalizedUrl
// Fetch both readable content and OpenGraph data in parallel
const [content, ogData] = await Promise.all([
fetchReadableContent(normalizedUrl),
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
])
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false
// Extract title: prioritize og:title > twitter:title > <title>
if (!title && content.html) {
const extractedTitle = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
]) || content.title
// Extract title: prioritize og:title > twitter:title > content.title
if (!title) {
let extractedTitle = null
if (ogData) {
extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
}
// Fallback to content.title if no OpenGraph title found
if (!extractedTitle) {
extractedTitle = content.title
}
if (extractedTitle) {
setTitle(extractedTitle)
@@ -102,12 +111,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
// Extract description: prioritize og:description > twitter:description > meta description
if (!description && content.html) {
const extractedDesc = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (!description && ogData) {
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
if (extractedDesc) {
setDescription(extractedDesc)
@@ -116,8 +121,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
if (!tagsInput && content.html) {
const extractedTags = extractTags(content.html)
if (!tagsInput && ogData) {
const extractedTags = extractTagsFromOgData(ogData)
// Only add boris tag if we extracted something
if (extractedAnything || extractedTags.length > 0) {

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(), {
@@ -42,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
return (
<Link
to={href}
state={{
previewData: {
title: post.title,
image: post.image,
summary: post.summary,
published: post.published
}
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>

View File

@@ -1,10 +1,11 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
import { npubEncode, naddrEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
@@ -23,6 +24,7 @@ interface BookmarkItemProps {
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const navigate = useNavigate()
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -40,10 +42,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, extract image and summary tags (per NIP-23)
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
// Note: We extract directly from tags here since we don't have the full event.
// When we have full events, we use getArticleImage() helper (see articleService.ts)
const isArticle = bookmark.kind === 30023
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
@@ -58,8 +61,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
// Get display name for author
const getAuthorDisplayName = () => {
@@ -110,10 +111,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
// For kind:30023 articles, pass the bookmark data instead of URL
// For kind:30023 articles, navigate to /a/:naddr route
if (bookmark.kind === 30023) {
if (onSelectUrl) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
return
}
@@ -135,7 +142,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
@@ -145,8 +151,16 @@ 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,
articleTitle,
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
return <CompactView {...compactProps} />
}
@@ -155,5 +169,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
}

View File

@@ -1,8 +1,8 @@
import React, { useRef, useState } from 'react'
import React, { useRef, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
@@ -13,12 +13,12 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import { getActiveRelayUrls } from '../services/relayManager'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
@@ -58,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
isMobile = false,
@@ -71,7 +70,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
return saved === 'grouped' ? 'grouped' : 'flat'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -106,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
return undefined
}
}
// For web bookmarks and other types, try to use URL if available
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (bookmark.kind === 39701) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
// Ensure URL has protocol
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
return readingProgressMap.get(url)
}
}
// For other bookmark types, try to extract URL from content
const urls = extractUrlsFromContent(bookmark.content)
if (urls.length > 0) {
return readingProgressMap.get(urls[0])
@@ -120,12 +130,24 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
localStorage.setItem('bookmarkGroupingMode', newMode)
}
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
}
// Pull-to-refresh for bookmarks
@@ -140,39 +162,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
const sections = useMemo(() => {
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic }
]
// Add bookmark sets as additional sections (only in grouped mode)
if (groupingMode === 'grouped') {
bookmarkSets.forEach(set => {
sectionsArray.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
}
return sectionsArray
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Get all filtered bookmarks for empty state checks
const allIndividualBookmarks = useMemo(() =>
bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
const filteredBookmarks = useMemo(() =>
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
[allIndividualBookmarks, selectedFilter]
)
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -282,27 +323,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
variant="ghost"
style={{ color: friendsColor }}
/>
</div>
{activeAccount && (
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && (
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
)}
</div>
{activeAccount && (
<div className="view-mode-right">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

@@ -1,15 +1,17 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { faLink } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -18,12 +20,11 @@ interface CardViewProps {
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
articleTitle?: string
readingProgress?: number
}
@@ -32,14 +33,12 @@ export const CardView: React.FC<CardViewProps> = ({
index,
hasUrls,
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon,
articleTitle,
readingProgress
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
@@ -47,21 +46,41 @@ export const CardView: React.FC<CardViewProps> = ({
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
const [ogImage, setOgImage] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isNote = bookmark.kind === 1
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
// Extract title from tags for regular bookmarks (not just articles)
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
// Get content type icon based on bookmark kind and URL classification
const getContentTypeIcon = () => {
if (isArticle) return faNewspaper // Nostr-native article
// For web bookmarks, classify the URL to determine icon
if (isWebBookmark && firstUrlClassificationType) {
switch (firstUrlClassificationType) {
case 'youtube':
case 'video':
return faCirclePlay
case 'image':
return faCamera
case 'article':
return faFileLines
default:
return faGlobe
}
}
// For notes, use sticky note icon
if (isNote) return faStickyNote
// Default fallback
return faLink
}
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined)
@@ -73,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
}
}, [firstUrl, articleImage, instantPreview, ogImage])
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
@@ -82,127 +102,113 @@ export const CardView: React.FC<CardViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
onClick={triggerOpen}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${cachedImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}
<div className="bookmark-header">
<span className="bookmark-type">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{eventNevent ? (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
title="Open event in search"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
)}
</div>
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
return (
<button
key={urlIndex}
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
<div className="card-layout">
<div className="card-content">
<div className="card-content-header">
{(cachedImage || firstUrl) && (
<div
className="card-thumbnail"
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
>
{url}
</button>
)
})}
{extractedUrls.length > 1 && (
<button
className="expand-toggle-urls"
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
{!cachedImage && firstUrl && (
<div className="thumbnail-placeholder">
<FontAwesomeIcon icon={getContentTypeIcon()} />
</div>
)}
</div>
)}
<div className="card-text-content">
<div className="bookmark-header">
</div>
{/* Display title for articles or bookmarks with titles */}
{(articleTitle || bookmarkTitle) && (
<h3 className="bookmark-title">
<RichContent content={articleTitle || bookmarkTitle || ''} className="" />
</h3>
)}
{isArticle && articleSummary ? (
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<RichContent content={bookmark.content} className="bookmark-content" />
)}
</div>
</div>
</div>
{/* Reading progress indicator as separator - always shown for all bookmark types */}
<ReadingProgressBar
readingProgress={readingProgress}
height={1}
marginTop="0.125rem"
marginBottom="0.125rem"
/>
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
</button>
)}
{getAuthorDisplayName()}
</Link>
</div>
<div className="bookmark-footer-right">
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
</div>
</div>
)}
{isArticle && articleSummary ? (
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}`} className="" />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
)}
{contentLength > 210 && (
<button
className="expand-toggle"
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
aria-label={expanded ? 'Collapse' : 'Expand'}
title={expanded ? 'Collapse' : 'Expand'}
>
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
</button>
)}
{/* Reading progress indicator for articles */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</Link>
</div>
{/* CTA removed */}
</div>
</div>
)

View File

@@ -1,9 +1,12 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -11,7 +14,7 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string
articleTitle?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
@@ -22,37 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
articleSummary,
articleTitle,
contentTypeIcon,
readingProgress
}) => {
const navigate = useNavigate()
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
const isNote = bookmark.kind === 1
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
const handleCompactClick = () => {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
navigate(`/a/${naddr}`)
}
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
onSelectUrl?.(extractedUrls[0])
} else if (isNote) {
navigate(`/e/${bookmark.id}`)
}
}
// For articles, prefer summary; for others, use content
const displayText = isArticle && articleSummary
? articleSummary
: bookmark.content
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
@@ -64,36 +67,26 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-type-compact">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
{displayText ? (
<div className="compact-text">
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
) : (
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
{/* CTA removed */}
</div>
{/* Reading progress indicator for all bookmark types with reading data */}
{/* Reading progress indicator - only show when there's actual progress */}
{readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '1px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
margin: '0',
marginLeft: '1.5rem'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
<ReadingProgressBar
readingProgress={readingProgress}
height={1}
marginLeft="1.5rem"
/>
)}
</div>
)

View File

@@ -7,7 +7,8 @@ import { formatDate } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -18,7 +19,6 @@ interface LargeViewProps {
getIconForUrlType: IconGetter
previewImage: string | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
@@ -35,7 +35,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
getIconForUrlType,
previewImage,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary,
@@ -45,15 +44,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023
// Calculate progress display (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
@@ -63,6 +53,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
const firstUrl = hasUrls ? extractedUrls[0] : null
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
@@ -100,27 +114,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
<RichContent content={bookmark.content} className="large-text" />
)}
{/* Reading progress indicator for articles - shown only if there's progress */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
{/* Reading progress indicator for all bookmark types - always shown */}
<ReadingProgressBar
readingProgress={readingProgress}
height={3}
marginTop="0.75rem"
/>
<div className="large-footer">
<span className="bookmark-type-large">
@@ -136,16 +135,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
</Link>
</span>
{eventNevent && (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at)}
</a>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
{/* CTA removed */}

View File

@@ -13,6 +13,8 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import { useEventLoader } from '../hooks/useEventLoader'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
@@ -38,7 +40,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
bookmarksLoading,
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -52,20 +54,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showMe = location.pathname.startsWith('/my')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
const eventId = eventIdParam
// Manage document title based on current route
const isViewingContent = !!(naddr || externalUrl || eventId)
useDocumentTitle({
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
})
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
const meTab = location.pathname === '/my' ? 'highlights' :
location.pathname === '/my/highlights' ? 'highlights' :
location.pathname === '/my/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/my/reads') ? 'reads' :
location.pathname.startsWith('/my/links') ? 'links' :
location.pathname === '/my/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
@@ -85,7 +94,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
}
}
// Track previous location for going back from settings/me/explore/profile
// Track previous location for going back from settings/my/explore/profile
useEffect(() => {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
@@ -220,14 +229,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
onHighlightCreated: (highlight) => setHighlights(prev => {
// Deduplicate by checking if highlight with this ID already exists
const exists = prev.some(h => h.id === highlight.id)
if (exists) {
return prev // Don't add duplicate
}
return [highlight, ...prev]
}),
settings
})
// Determine which loader should be active based on route
// Only one loader should run at a time to prevent state conflicts
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
// Load nostr-native article if naddr is in URL
useArticleLoader({
naddr,
naddr: shouldLoadArticle ? naddr : undefined,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -242,7 +265,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Load external URL if /r/* route is used
useExternalUrlLoader({
url: externalUrl,
url: shouldLoadExternal ? externalUrl : undefined,
relayPool,
eventStore,
setSelectedUrl,
@@ -255,6 +278,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
setCurrentArticleEventId
})
// Load event if /e/:eventId route is used
useEventLoader({
eventId: shouldLoadEvent ? eventId : undefined,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
})
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)

View File

@@ -1,17 +1,17 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import VideoEmbedProcessor from './VideoEmbedProcessor'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { getActiveRelayUrls } from '../services/relayManager'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
@@ -33,18 +33,17 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { shouldTrackReadingProgress } from '../utils/helpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
loadReadingPosition,
saveReadingPosition
saveReadingPosition
} from '../services/readingPositionService'
import { readingProgressController } from '../services/readingProgressController'
import TTSControls from './TTSControls'
interface ContentPanelProps {
loading: boolean
@@ -74,6 +73,7 @@ interface ContentPanelProps {
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
onOpenHighlights?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -101,21 +101,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
isHighlightsCollapsed = false,
onOpenHighlights
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -130,6 +127,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey,
followedPubkeys
})
// Key used to force re-mount of markdown preview/render when content changes
const contentKey = useMemo(() => {
// Prefer selectedUrl as a stable per-article key; fallback to title+length
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
@@ -141,8 +143,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
// Reading position tracking - only for text content that's loaded and long enough
// Wait for content to load, check it's not a video, and verify it's long enough to track
const isTextContent = useMemo(() => {
if (loading) return false
if (!markdown && !html) return false
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
if (selectedUrl?.startsWith('nostr-event:')) return false
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
if (!shouldTrackReadingProgress(html, markdown)) return false
return true
}, [loading, markdown, html, selectedUrl])
// Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => {
@@ -150,6 +162,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return generateArticleIdentifier(selectedUrl)
}, [selectedUrl])
// Use refs for content to avoid recreating callback on every content change
const htmlRef = useRef(html)
const markdownRef = useRef(markdown)
useEffect(() => {
htmlRef.current = html
markdownRef.current = markdown
}, [html, markdown])
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
@@ -160,7 +180,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) {
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
return
}
@@ -180,12 +200,39 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
)
} catch (error) {
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
console.error('[reading-position] Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
const { progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
// Delay enabling position tracking to ensure content is stable
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
// Reset tracking when article changes
useEffect(() => {
setIsTrackingEnabled(false)
}, [selectedUrl])
// Enable/disable tracking based on content state
useEffect(() => {
if (!isTextContent) {
// Disable tracking if content is not suitable
if (isTrackingEnabled) {
setIsTrackingEnabled(false)
}
return
}
if (!isTrackingEnabled) {
// Wait 500ms after content loads before enabling tracking
const timer = setTimeout(() => {
setIsTrackingEnabled(true)
}, 500)
return () => clearTimeout(timer)
}
}, [isTextContent, isTrackingEnabled])
const { progressPercentage, suppressSavesFor } = useReadingPosition({
enabled: isTrackingEnabled,
syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition,
onReadingComplete: () => {
@@ -205,59 +252,82 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
useEffect(() => {
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads
// Load saved reading position when article loads (using pre-loaded data from controller)
const suppressSavesForRef = useRef(suppressSavesFor)
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
suppressSavesForRef.current = suppressSavesFor
}, [suppressSavesFor])
// Track if we've successfully started restore for this article + tracking state
// Use a composite key to ensure we only restore once per article when tracking is enabled
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
const hasAttemptedRestoreRef = useRef<string | null>(null)
useEffect(() => {
if (!isTextContent || !activeAccount || !articleIdentifier) {
return
}
if (settings?.syncReadingPosition === false) {
return
}
if (settings?.autoScrollToReadingPosition === false) {
return
}
if (!isTrackingEnabled) {
return
}
const loadPosition = async () => {
try {
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier
)
// Only attempt restore once per article (after tracking is enabled)
if (hasAttemptedRestoreRef.current === restoreKey) {
return
}
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
// Article was completed, start from top
} else {
// Position was too early, skip restore
}
// Mark as attempted using composite key
hasAttemptedRestoreRef.current = restoreKey
// Get the saved position from the controller (already loaded and displayed on card)
const savedProgress = readingProgressController.getProgress(articleIdentifier)
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
return
}
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
if (suppressSavesForRef.current) {
suppressSavesForRef.current(1500)
}
// Wait for content to be fully rendered
setTimeout(() => {
const docH = document.documentElement.scrollHeight
const winH = window.innerHeight
const maxScroll = Math.max(0, docH - winH)
const currentTop = window.pageYOffset || document.documentElement.scrollTop
const targetTop = savedProgress * maxScroll
// Skip if delta is too small (< 48px or < 5%)
const deltaPx = Math.abs(targetTop - currentTop)
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
if (deltaPx < 48 || deltaPct < 0.05) {
// Allow saves immediately since no scroll happened
if (suppressSavesForRef.current) {
suppressSavesForRef.current(0)
}
} catch (error) {
console.error('❌ [ContentPanel] Failed to load reading position:', error)
return
}
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Perform smooth animated restore
window.scrollTo({
top: targetTop,
behavior: 'smooth'
})
}, 500) // Give content time to render
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
// Save position before unmounting or changing article
useEffect(() => {
return () => {
if (saveNow) {
saveNow()
}
}
}, [saveNow, selectedUrl])
// Note: We intentionally do NOT save on unmount because:
// 1. Browser may scroll to top during back navigation, causing 0% saves
// 2. The auto-save with 1s throttle already captures position during reading
// 3. Position state may not reflect actual reading position during navigation
// Close menu when clicking outside
useEffect(() => {
@@ -266,21 +336,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
setShowArticleMenu(false)
}
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu || showExternalMenu) {
if (showArticleMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
@@ -303,13 +370,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
}, [showArticleMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -320,36 +384,29 @@ 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)
// Track external video duration (in seconds) for display in header
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
// Load YouTube metadata/captions when applicable
useEffect(() => {
(async () => {
try {
if (!selectedUrl) return setYtMeta(null)
const id = extractYouTubeId(selectedUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [selectedUrl])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
// Get article links for menu
@@ -357,7 +414,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
@@ -386,7 +444,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowArticleMenu(!showArticleMenu)
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenPortal = () => {
if (articleLinks) {
@@ -463,52 +520,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
const handleOpenSearch = () => {
if (articleLinks) {
// For regular notes (kind:1), open via /e/ path
if (currentArticle?.kind === 1) {
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
window.open(borisUrl, '_blank', 'noopener,noreferrer')
} else if (articleLinks) {
// For articles, use search portal
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
if (!selectedUrl) return
const native = buildNativeVideoUrl(selectedUrl)
if (native) {
window.location.href = native
} else {
window.location.href = selectedUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
@@ -550,7 +572,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
// If it's a nostr event sentinel, open the event directly on ants.sh
if (selectedUrl.startsWith('nostr-event:')) {
const eventId = selectedUrl.replace('nostr-event:', '')
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
} else {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
}
setShowExternalMenu(false)
}
@@ -579,9 +607,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
// Silently ignore encoding errors
}
}
} else {
@@ -593,7 +620,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -674,7 +700,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
@@ -686,7 +711,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
relayPool
)
archiveController.mark(selectedUrl)
console.log('[archive][content] optimistic mark url', selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -704,13 +728,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
}
if (loading) {
return (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}
const highlightRgb = hexToRgb(highlightColor)
@@ -731,16 +748,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
components={{
img: ({ src, alt, ...props }) => (
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
{...props}
/>
)
}}
@@ -751,127 +767,59 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)}
<ReaderHeader
title={ytMeta?.title || title}
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
onHighlightCountClick={onOpenHighlights}
/>
{isExternalVideo ? (
<>
<div className="reader-video">
<ReactPlayer
url={selectedUrl as string}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{ytMeta?.description && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{ytMeta.description}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
</span>
</button>
</div>
)}
</>
{isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
</div>
)}
{loading || !markdown && !html ? (
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
) : markdown || html ? (
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
<ContentSkeleton />
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-html"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
{!isNostrArticle && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
@@ -898,13 +846,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
{/* Only show "Open Original" for actual external URLs, not nostr events */}
{!selectedUrl?.startsWith('nostr-event:') && (
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}
@@ -1019,11 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>
</div>
)}
) : null}
</div>
</>
)

View File

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

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 { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { nip19, NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
// Contacts are managed via controller subscription
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
@@ -19,20 +19,22 @@ import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
// import { KINDS } from '../config/kinds'
// import { eventToHighlight } from '../services/highlightEventProcessor'
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
import { contactsController } from '../services/contactsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
// Accessors from Helpers (currently unused here)
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps {
relayPool: RelayPool
@@ -55,35 +57,53 @@ 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, [])
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
// Visibility filters - load from localStorage first, fallback to settings
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
// Try to load from localStorage first
try {
const saved = localStorage.getItem('exploreScopeVisibility')
if (saved) {
const parsed = JSON.parse(saved)
// Validate that at least one scope is enabled
if (parsed.nostrverse || parsed.friends || parsed.mine) {
return parsed
}
}
} catch (err) {
console.warn('Failed to load explore scope from localStorage:', err)
}
// Fallback to settings or defaults
return {
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
}
})
// Ensure at least one scope remains active
@@ -93,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
if (!next.nostrverse && !next.friends && !next.mine) {
return prev // ignore toggle that would disable all scopes
}
// Persist to localStorage
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
return next
})
}, [])
@@ -105,6 +131,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}, [])
// Subscribe to contacts stream and mirror into local state
useEffect(() => {
const unsubscribe = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => unsubscribe()
}, [])
// Ensure contacts controller is started for the active account (non-blocking)
useEffect(() => {
if (relayPool && activeAccount?.pubkey) {
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
}
}, [relayPool, activeAccount?.pubkey])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
@@ -206,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Update visibility when settings/login state changes
useEffect(() => {
// Check if user has a saved preference
const hasSavedPreference = (() => {
try {
return localStorage.getItem('exploreScopeVisibility') !== null
} catch {
return false
}
})()
// Only reset to defaults if no saved preference exists
if (hasSavedPreference) {
return
}
if (!activeAccount) {
// When logged out, show nostrverse by default
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
setHasLoadedNostrverseHighlights(true)
} else {
// When logged in, use settings defaults immediately
setVisibility({
const defaultVisibility = {
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
}
setVisibility(defaultVisibility)
try {
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
} catch (err) {
console.warn('Failed to save explore scope to localStorage:', err)
}
setHasLoadedNostrverse(false)
setHasLoadedNostrverseHighlights(false)
}
@@ -230,242 +297,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 +429,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 +511,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 +535,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
@@ -641,8 +572,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
) : (
<div className="explore-grid">
@@ -653,6 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
hideBotByName={settings?.hideBotArticlesByName !== false}
/>
))}
</div>
@@ -701,7 +635,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
<FontAwesomeIcon icon={faPersonHiking} />
Explore
</h1>
@@ -773,7 +707,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
<div key={activeTab}>
<div>
{renderTabContent()}
</div>
</div>

View File

@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
className="highlight-fab"
style={{
position: 'fixed',
bottom: '32px',
bottom: '80px',
right: '32px',
zIndex: 1000,
width: '56px',

View File

@@ -7,9 +7,9 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
@@ -114,7 +114,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -133,12 +132,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
@@ -147,13 +140,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
publishedRelays: getActiveRelayUrls(relayPool),
isLocalOnly: false,
isOfflineCreated: false
}
@@ -164,7 +155,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
})
return unsubscribe
}, [highlight, onHighlightUpdate])
}, [highlight, onHighlightUpdate, relayPool])
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -212,19 +203,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
pubkey,
identifier
})
navigate(`/a/${naddr}`)
// Pass highlight ID in navigation state to trigger scroll
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}
} else if (highlight.urlReference) {
// Navigate to external URL
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
// Navigate to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayHints = activeRelays.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
@@ -260,7 +263,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
const targetRelays = getActiveRelayUrls(relayPool)
await relayPool.publish(targetRelays, event)
@@ -280,9 +283,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
@@ -301,8 +301,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Check if this highlight was only published to local relays
let isLocalOnly = highlight.isLocalOnly
const publishedRelays = highlight.publishedRelays || []
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
if (isLocalOnly === undefined) {
if (isEventOfflineCreated(highlight.id)) {
isLocalOnly = true
}
}
// Fallback 2: If publishedRelays only contains local relays, it's local-only
if (isLocalOnly === undefined && publishedRelays.length > 0) {
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
if (hasOnlyLocalRelays && !hasRemoteRelays) {
isLocalOnly = true
}
}
// If isLocalOnly is true (from any fallback), show airplane icon
if (isLocalOnly === true) {
return {
icon: faPlane,
tooltip: publishedRelays.length > 0
? 'Local relays only - will sync when remote relays available'
: 'Created in flight mode - will sync when remote relays available',
spin: false
}
}
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
@@ -310,7 +339,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faHighlighter,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -328,7 +357,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
const relayNames = activeRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
@@ -420,7 +450,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
// Navigate within app using same logic as handleItemClick
if (highlight.eventReference) {
const parts = highlight.eventReference.split(':')
if (parts.length === 3 && parts[0] === '30023') {
const [, pubkey, identifier] = parts
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey,
identifier
})
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
} else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}}
>
{formatDateCompact(highlight.created_at)}

View File

@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
isMobile?: boolean
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
followedPubkeys = new Set(),
relayPool,
eventStore,
settings
settings,
isMobile = false
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -116,15 +118,14 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
return (
<div className="highlights-container">
<HighlightsPanelHeader
loading={loading}
hasHighlights={filteredHighlights.length > 0}
showHighlights={showHighlights}
highlightVisibility={highlightVisibility}
currentUserPubkey={currentUserPubkey}
onToggleHighlights={handleToggleHighlights}
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
isMobile={isMobile}
/>
{loading && filteredHighlights.length === 0 ? (

View File

@@ -1,35 +1,44 @@
import React from 'react'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
hasHighlights: boolean
showHighlights: boolean
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
onToggleHighlights: () => void
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
isMobile?: boolean
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
loading,
hasHighlights,
showHighlights,
highlightVisibility,
currentUserPubkey,
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange
onHighlightVisibilityChange,
isMobile = false
}) => {
return (
<div className="highlights-header">
<div className="highlights-actions">
<div className="highlights-actions-left">
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
</button>
)}
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<IconButton
@@ -80,17 +89,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
)}
</div>
)}
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
spin={loading}
/>
)}
</div>
<div className="highlights-actions-right">
{hasHighlights && (
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
@@ -101,14 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
/>
)}
</div>
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
</div>
</div>
)

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,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faHeart } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { Hooks } from 'applesauce-react'
import { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
@@ -23,7 +24,8 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { dedupeBookmarksById } from '../services/bookmarkHelpers'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
@@ -42,7 +44,7 @@ interface MeProps {
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
@@ -57,7 +59,7 @@ const Me: React.FC<MeProps> = ({
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const activeTab = propActiveTab || 'highlights'
// Only for own profile
const viewingPubkey = activeAccount?.pubkey
@@ -129,13 +131,6 @@ const Me: React.FC<MeProps> = ({
}
}, [])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
// Sync filter state with URL changes
useEffect(() => {
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
@@ -150,15 +145,15 @@ const Me: React.FC<MeProps> = ({
setReadingProgressFilter(filter)
if (activeTab === 'reads') {
if (filter === 'all') {
navigate('/me/reads', { replace: true })
navigate('/my/reads', { replace: true })
} else {
navigate(`/me/reads/${filter}`, { replace: true })
navigate(`/my/reads/${filter}`, { replace: true })
}
} else if (activeTab === 'links') {
if (filter === 'all') {
navigate('/me/links', { replace: true })
navigate('/my/links', { replace: true })
} else {
navigate(`/me/links/${filter}`, { replace: true })
navigate(`/my/links/${filter}`, { replace: true })
}
}
}
@@ -205,15 +200,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 +225,29 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load writings:', err)
setLoading(false)
}
}
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
const loadReadingListTab = async () => {
const loadReadingListTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
setLoadedTabs(prev => {
const hasBeenLoaded = prev.has('bookmarks')
if (!hasBeenLoaded) setLoading(true)
// Bookmarks come from centralized loading in App.tsx
setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) {
console.error('Failed to load reading list:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
return new Set(prev).add('bookmarks')
})
// Always turn off loading after a tick
setTimeout(() => setLoading(false), 0)
}, [viewingPubkey, activeAccount])
const loadReadsTab = async () => {
const loadReadsTab = useCallback(async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
let hasBeenLoaded = false
setLoadedTabs(prev => {
hasBeenLoaded = prev.has('reads')
return prev
})
try {
if (!hasBeenLoaded) setLoading(true)
@@ -270,18 +266,22 @@ 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)
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
const initialLinks = deriveLinksFromBookmarks(bookmarks)
// Derive links from bookmarks with OpenGraph enhancement
const initialLinks = await deriveLinksFromBookmarks(bookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap)
setLinks(initialLinks)
@@ -310,10 +310,10 @@ const Me: React.FC<MeProps> = ({
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
}, [viewingPubkey, activeAccount, bookmarks, relayPool, readingProgressMap])
// Load active tab data
useEffect(() => {
const loadActiveTab = useCallback(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
@@ -336,7 +336,7 @@ const Me: React.FC<MeProps> = ({
case 'writings':
loadWritingsTab()
break
case 'reading-list':
case 'bookmarks':
loadReadingListTab()
break
case 'reads':
@@ -346,8 +346,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(() => {
@@ -392,8 +395,7 @@ const Me: React.FC<MeProps> = ({
}
const getReadItemUrl = (item: ReadItem) => {
if (item.type === 'article') {
// ID is already in naddr format
if (item.type === 'article' && item.id.startsWith('naddr1')) {
return `/a/${item.id}`
} else if (item.url) {
return `/r/${encodeURIComponent(item.url)}`
@@ -417,7 +419,7 @@ const Me: React.FC<MeProps> = ({
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
created_at: item.readingTimestamp || 0,
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
@@ -436,19 +438,16 @@ const Me: React.FC<MeProps> = ({
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag && bookmark.pubkey) {
const pointer = {
identifier: dTag,
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
identifier: dTag
})
navigate(`/a/${naddr}`)
}
} else if (url) {
// For regular URLs, navigate to the reader route
navigate(`/r/${encodeURIComponent(url)}`)
}
}
@@ -489,8 +488,10 @@ const Me: React.FC<MeProps> = ({
return undefined
}
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
// Merge and flatten all individual bookmarks with deduplication
const allIndividualBookmarks = dedupeBookmarksById(
bookmarks.flatMap(b => b.individualBookmarks || [])
)
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
@@ -564,30 +565,21 @@ const Me: React.FC<MeProps> = ({
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
// Debug logs for archive filter issues
if (readingProgressFilter === 'archive') {
const ids = Array.from(new Set([
...archiveController.getMarkedIds(),
...readingProgressController.getMarkedAsReadIds()
]))
const readIds = new Set(reads.map(i => i.id))
const matches = ids.filter(id => readIds.has(id))
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
console.log('[archive][me] counts', {
reads: reads.length,
filteredReads: filteredReads.length,
links: links.length,
linksWithProgress: linksWithProgress.length,
filteredLinks: filteredLinks.length,
markedIds: ids.length,
sampleMarked: ids.slice(0, 3),
matches: matches.length,
nonMatches
})
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
@@ -629,7 +621,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
case 'reading-list':
case 'bookmarks':
if (showSkeletons) {
return (
<div className="bookmarks-list">
@@ -675,21 +667,26 @@ const Me: React.FC<MeProps> = ({
</div>
</div>
)))}
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
<div className="view-mode-controls">
<div className="view-mode-left">
<IconButton
icon={faHeart}
onClick={() => navigate('/support')}
title="Support Boris"
ariaLabel="Support"
variant="ghost"
style={{ color: 'rgb(251 146 60)' }}
/>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
</div>
<div className="view-mode-right">
</div>
</div>
</div>
)
@@ -850,6 +847,7 @@ const Me: React.FC<MeProps> = ({
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
hideBotByName={settings.hideBotArticlesByName !== false}
/>
))}
</div>
@@ -873,15 +871,15 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/me/highlights')}
onClick={() => navigate('/my/highlights')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
data-tab="bookmarks"
onClick={() => navigate('/my/bookmarks')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
@@ -889,7 +887,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
onClick={() => navigate('/my/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
@@ -897,7 +895,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
onClick={() => navigate('/my/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
@@ -905,7 +903,7 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/me/writings')}
onClick={() => navigate('/my/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>

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'
@@ -6,9 +6,7 @@ import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { BlogPostPreview } from '../services/exploreService'
import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
@@ -20,6 +18,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
import { writingsController } from '../services/writingsController'
import { highlightsController } from '../services/highlightsController'
interface ProfileProps {
relayPool: RelayPool
@@ -57,6 +57,15 @@ const Profile: React.FC<ProfileProps> = ({
[pubkey]
)
// Sort writings by publication date, newest first
const sortedWritings = useMemo(() => {
return cachedWritings.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [cachedWritings])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -94,28 +103,17 @@ const Profile: React.FC<ProfileProps> = ({
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Background fetch to populate event store (non-blocking)
// Background fetch via controllers to populate event store
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
// Start controllers to fetch and populate event store
// Controllers handle streaming, deduplication, and storage
highlightsController.start({ relayPool, eventStore, pubkey })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
// Fetch highlights in background
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
.then(() => {
// Highlights fetched
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
})
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
})
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh
@@ -168,7 +166,7 @@ const Profile: React.FC<ProfileProps> = ({
}
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
const renderTabContent = () => {
switch (activeTab) {
@@ -209,13 +207,13 @@ const Profile: React.FC<ProfileProps> = ({
</div>
)
}
return cachedWritings.length === 0 ? (
return sortedWritings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
{cachedWritings.map((post) => (
{sortedWritings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}

View File

@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
onHighlightCountClick?: () => void
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlightCount,
settings,
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightCountClick
}) => {
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(true)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(false)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -0,0 +1,58 @@
import React from 'react'
interface ReadingProgressBarProps {
readingProgress?: number
height?: number
marginTop?: string
marginBottom?: string
marginLeft?: string
className?: string
}
export const ReadingProgressBar: React.FC<ReadingProgressBarProps> = ({
readingProgress,
height = 1,
marginTop,
marginBottom,
marginLeft,
className
}) => {
// Calculate progress color
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const progressWidth = readingProgress ? `${Math.round(readingProgress * 100)}%` : '0%'
const progressBackground = readingProgress ? progressColor : 'var(--color-border)'
return (
<div
className={className}
style={{
height: `${height}px`,
width: '100%',
background: 'var(--color-border)',
borderRadius: '0.5px',
overflow: 'hidden',
marginTop,
marginBottom,
marginLeft,
position: 'relative',
minHeight: `${height}px`
}}
>
<div
style={{
height: '100%',
width: progressWidth,
background: progressBackground,
transition: 'width 0.3s ease, background 0.3s ease',
minHeight: `${height}px`
}}
/>
</div>
)
}

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,16 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
fullWidthImages: true,
renderVideoLinksAsEmbeds: true,
syncReadingPosition: true,
autoScrollToReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
ttsUseSystemLanguage: false,
ttsDetectContentLanguage: true,
ttsLanguageMode: 'content',
ttsDefaultSpeed: 2.1,
}
interface SettingsProps {
@@ -169,8 +178,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />

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

@@ -118,6 +118,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
<input
id="autoScrollToReadingPosition"
type="checkbox"
checked={settings.autoScrollToReadingPosition !== false}
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to saved reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
<input

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

@@ -33,7 +33,13 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
// If it's an internal route (starts with /), navigate directly
if (url.startsWith('/')) {
navigate(url)
} else {
// External URL: wrap with /r/ path
navigate(`/r/${encodeURIComponent(url)}`)
}
}
const handleClearCache = async () => {
@@ -151,7 +157,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{' and '}
{', '}
<a
onClick={(e) => {
e.preventDefault()
@@ -161,6 +167,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{', and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>

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('/my/links')
} catch (err) {
console.error('Failed to save shared bookmark:', err)
showToast('Failed to save bookmark')
navigate('/')
} finally {
setProcessing(false)
}
}
}
handleSharedContent()
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
// Show waiting for login state
if (waitingForLogin && !activeAccount) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Waiting for login...</p>
</div>
</div>
)
}
// Show processing state
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Saving bookmark...</p>
</div>
</div>
)
}

View File

@@ -1,11 +1,12 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -18,6 +19,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const getProfileImage = () => {
return profile?.picture || null
@@ -33,73 +36,147 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const profileImage = getProfileImage()
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowProfileMenu(false)
}
}
if (showProfileMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showProfileMenu])
const handleMenuItemClick = (action: () => void) => {
setShowProfileMenu(false)
// Close mobile sidebar when navigating on mobile
if (isMobile) {
onToggleCollapse()
}
action()
}
return (
<>
<div className="sidebar-header-bar">
{isMobile ? (
<div className="sidebar-header-left">
{activeAccount && (
<div className="profile-menu-wrapper" ref={menuRef}>
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => setShowProfileMenu(!showProfileMenu)}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</button>
{showProfileMenu && (
<div className="profile-dropdown-menu">
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>My Highlights</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
>
<FontAwesomeIcon icon={faBookmark} />
<span>My Bookmarks</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
>
<FontAwesomeIcon icon={faBooks} />
<span>My Reads</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
>
<FontAwesomeIcon icon={faLink} />
<span>My Links</span>
</button>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span>My Writings</span>
</button>
<div className="profile-menu-separator"></div>
<button
className="profile-menu-item"
onClick={() => handleMenuItemClick(onLogout)}
>
<FontAwesomeIcon icon={faRightFromBracket} />
<span>Logout</span>
</button>
</div>
)}
</div>
)}
<IconButton
icon={faTimes}
onClick={onToggleCollapse}
title="Close sidebar"
ariaLabel="Close sidebar"
icon={faHome}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/')
}}
title="Home"
ariaLabel="Home"
variant="ghost"
className="mobile-close-btn"
/>
) : (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
<div className="sidebar-header-right">
{activeAccount && (
<div
className="profile-avatar"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
icon={faPersonHiking}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
navigate('/explore')
}}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
)}
<IconButton
icon={faGear}
onClick={() => {
if (isMobile) {
onToggleCollapse()
}
onOpenSettings()
}}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
</div>
</>

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

@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import VideoView from './VideoView'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { classifyUrl } from '../utils/helpers'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -134,15 +136,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
// Lock body scroll when mobile sidebar or highlights is open
const savedScrollPosition = useRef<number>(0)
useEffect(() => {
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
// Save current scroll position
savedScrollPosition.current = window.scrollY
document.body.style.top = `-${savedScrollPosition.current}px`
document.body.classList.add('mobile-sidebar-open')
} else {
// Restore scroll position
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
if (savedScrollPosition.current > 0) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo(0, savedScrollPosition.current)
savedScrollPosition.current = 0
})
}
}
return () => {
document.body.classList.remove('mobile-sidebar-open')
document.body.style.top = ''
}
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
@@ -306,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
>
<BookmarkList
bookmarks={props.bookmarks}
@@ -358,42 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
/>
)}
) : (() => {
// Determine if this is a video URL
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
if (isExternalVideo) {
return (
<VideoView
videoUrl={props.selectedUrl!}
title={props.readerContent?.title}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
}
return (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)
})()}
</div>
<div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
>
<HighlightsPanel
highlights={props.highlights}
@@ -413,6 +461,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
isMobile={isMobile}
/>
</div>
</div>

View File

@@ -0,0 +1,164 @@
import React, { useMemo, forwardRef } from 'react'
import ReactPlayer from 'react-player'
import { classifyUrl } from '../utils/helpers'
interface VideoEmbedProcessorProps {
html: string
renderVideoLinksAsEmbeds: boolean
className?: string
onMouseUp?: (e: React.MouseEvent) => void
onTouchEnd?: (e: React.TouchEvent) => void
}
/**
* Component that processes HTML content and optionally embeds video links
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
*/
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
html,
renderVideoLinksAsEmbeds,
className,
onMouseUp,
onTouchEnd
}, ref) => {
// Process HTML and extract video URLs in a single pass to keep them in sync
const { processedHtml, videoUrls } = useMemo(() => {
if (!renderVideoLinksAsEmbeds || !html) {
return { processedHtml: html, videoUrls: [] }
}
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
let result = html
const collectedUrls: string[] = []
let placeholderIndex = 0
// 1) Replace entire <video>...</video> blocks when they reference a video URL
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
const videoBlocks = result.match(videoBlockPattern) || []
videoBlocks.forEach((block) => {
// Try src on <video>
let url: string | null = null
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (videoSrcMatch && videoSrcMatch[1]) {
url = videoSrcMatch[1]
} else {
// Try nested <source>
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (sourceSrcMatch && sourceSrcMatch[1]) {
url = sourceSrcMatch[1]
}
}
if (url) {
collectedUrls.push(url)
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'g'), placeholder)
placeholderIndex++
}
})
// 2) Replace entire <img ...> tags if their src points to a video
const imgTagPattern = /<img[^>]*>/gi
const allImgTags = result.match(imgTagPattern) || []
allImgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
if (srcMatch && srcMatch[1]) {
const videoUrl = srcMatch[1]
collectedUrls.push(videoUrl)
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
placeholderIndex++
}
})
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
const allUrls: string[] = result.match(allUrlPattern) || []
const platformVideoUrls = allUrls.filter(url => {
// include URLs classified as video and not already collected
const classification = classifyUrl(url)
return classification.type === 'video' && !collectedUrls.includes(url)
})
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
let finalHtml = result
remainingUrls.forEach((url) => {
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
collectedUrls.push(url)
placeholderIndex++
})
// Return both processed HTML and collected URLs (in the same order as placeholders)
return {
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
videoUrls: collectedUrls
}
}, [html, renderVideoLinksAsEmbeds])
// If no video embedding is enabled, just render the HTML normally
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
return (
<div
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: processedHtml }}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
/>
)
}
// Split the HTML by video placeholders and render with embedded players
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
return (
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
{parts.map((part, index) => {
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
if (videoMatch) {
const videoIndex = parseInt(videoMatch[1])
const videoUrl = videoUrls[videoIndex]
if (videoUrl) {
return (
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
<ReactPlayer
url={videoUrl}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
/>
</div>
)
}
}
// Regular HTML content - only render if not empty
if (part.trim()) {
return (
<div
key={index}
dangerouslySetInnerHTML={{ __html: part }}
/>
)
}
return null
})}
</div>
)
})
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
export default VideoEmbedProcessor

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactPlayer from 'react-player'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { UserSettings } from '../services/settingsService'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { getYouTubeThumbnail } from '../utils/imagePreview'
// Helper function to get Vimeo thumbnail
const getVimeoThumbnail = (url: string): string | null => {
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (!vimeoMatch) return null
const videoId = vimeoMatch[1]
return `https://vumbnail.com/${videoId}.jpg`
}
import {
createWebsiteReaction,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveWebsite } from '../services/unarchiveService'
import ReaderHeader from './ReaderHeader'
interface VideoViewProps {
videoUrl: string
title?: string
image?: string
summary?: string
published?: number
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
onOpenHighlights?: () => void
}
const VideoView: React.FC<VideoViewProps> = ({
videoUrl,
title,
image,
summary,
published,
settings,
relayPool,
activeAccount,
onOpenHighlights
}) => {
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
// Load YouTube metadata when applicable
useEffect(() => {
(async () => {
try {
if (!videoUrl) return setYtMeta(null)
const id = extractYouTubeId(videoUrl)
if (!id) return setYtMeta(null)
const locale = navigator?.language?.split('-')[0] || 'en'
const data = await getYouTubeMeta(id, locale)
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
} catch {
setYtMeta(null)
}
})()
}, [videoUrl])
// Check if video is marked as watched
useEffect(() => {
const checkWatchedStatus = async () => {
if (!activeAccount || !videoUrl) return
setIsCheckingWatchedStatus(true)
try {
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
setIsMarkedAsWatched(isWatched)
} catch (error) {
console.warn('Failed to check watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
}
}
checkWatchedStatus()
}, [activeAccount, videoUrl, relayPool])
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
}
if (showVideoMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showVideoMenu])
// Check menu position for upward opening
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
if (!menuRef.current) return
const rect = menuRef.current.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// Open upward if there's more space above and less space below
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
}, [showVideoMenu])
const formatDuration = (totalSeconds: number): string => {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = Math.floor(totalSeconds % 60)
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const ss = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
}
const handleMarkAsWatched = async () => {
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
setIsCheckingWatchedStatus(true)
setShowCheckAnimation(true)
try {
if (isMarkedAsWatched) {
// Unmark as watched
if (relayPool) {
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(false)
} else {
// Mark as watched
if (relayPool) {
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
}
setIsMarkedAsWatched(true)
}
} catch (error) {
console.warn('Failed to update watched status:', error)
} finally {
setIsCheckingWatchedStatus(false)
setTimeout(() => setShowCheckAnimation(false), 1000)
}
}
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenVideoExternal = () => {
window.open(videoUrl, '_blank', 'noopener,noreferrer')
setShowVideoMenu(false)
}
const handleOpenVideoNative = () => {
const native = buildNativeVideoUrl(videoUrl)
if (native) {
window.location.href = native
} else {
window.location.href = videoUrl
}
setShowVideoMenu(false)
}
const handleCopyVideoUrl = async () => {
try {
await navigator.clipboard.writeText(videoUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowVideoMenu(false)
}
}
const handleShareVideoUrl = async () => {
try {
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: ytMeta?.title || title || 'Video',
url: videoUrl
})
} else {
await navigator.clipboard.writeText(videoUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowVideoMenu(false)
}
}
const displayTitle = ytMeta?.title || title
const displaySummary = ytMeta?.description || summary
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
// Get video thumbnail for cover image
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
const displayImage = videoThumbnail || image
return (
<>
<ReaderHeader
title={displayTitle}
image={displayImage}
summary={displaySummary}
published={published}
readingTimeText={durationText}
hasHighlights={false}
highlightCount={0}
settings={settings}
highlights={[]}
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
onHighlightCountClick={onOpenHighlights}
/>
<div className="reader-video">
<ReactPlayer
url={videoUrl}
controls
width="100%"
height="auto"
style={{
width: '100%',
height: 'auto',
aspectRatio: '16/9'
}}
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
/>
</div>
{displaySummary && (
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
{displaySummary}
</div>
)}
{ytMeta?.transcript && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
{ytMeta.transcript}
</div>
</div>
)}
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={videoMenuRef}>
<button
className="article-menu-btn"
onClick={toggleVideoMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
</button>
<button className="article-menu-item" onClick={handleOpenVideoNative}>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open in Native App</span>
</button>
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button className="article-menu-item" onClick={handleShareVideoUrl}>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsWatched}
disabled={isCheckingWatchedStatus}
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={faCheckCircle}
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
/>
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
</button>
</div>
)}
</>
)
}
export default VideoView

17
src/config/bots.ts Normal file
View File

@@ -0,0 +1,17 @@
import { nip19 } from 'nostr-tools'
/**
* Hardcoded list of bot pubkeys (hex format) to hide articles from
* These are accounts known to be bots or automated services
*/
export const BOT_PUBKEYS = new Set([
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
])
/**
* Check if a pubkey corresponds to a known bot
*/
export function isKnownBot(pubkey: string): boolean {
return BOT_PUBKEYS.has(pubkey)
}

View File

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

View File

@@ -1,15 +1,30 @@
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay'
import type { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
import { useDocumentTitle } from './useDocumentTitle'
interface PreviewData {
title: string
image?: string
summary?: string
published?: number
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
@@ -25,6 +40,7 @@ interface UseArticleLoaderProps {
export function useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -36,7 +52,22 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const location = useLocation()
const mountedRef = useRef(true)
// Hold latest settings without retriggering effect
const settingsRef = useRef<UserSettings | undefined>(settings)
useEffect(() => {
settingsRef.current = settings
}, [settings])
// Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData
// Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
useEffect(() => {
mountedRef.current = true
@@ -44,67 +75,231 @@ export function useArticleLoader({
if (!relayPool || !naddr) return
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!mountedRef.current) return
// Don't clear highlights yet - let the smart filtering logic handle it
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
let foundInStore = false
if (eventStore) {
try {
// Decode naddr to get the coordinate
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const pointer = decoded.data as AddressPointer
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
foundInStore = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
// If we found the content in EventStore, we can return early
// This prevents unnecessary relay queries when offline
return
}
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// If we have preview data from navigation, show it immediately (no skeleton!)
if (previewData) {
setCurrentTitle(previewData.title)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
title: previewData.title,
markdown: '', // Will be loaded from store or relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
setReaderLoading(false) // Turn off loading immediately - we have the preview!
} else if (!foundInStore) {
// Only show loading if we didn't find content in store and no preview data
setReaderLoading(true)
setReaderContent(undefined)
}
try {
// Decode naddr to filter
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
// Store in event store for future local reads
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Silently ignore store errors
}
// Keep latest by created_at
if (!latestEvent || evt.created_at > latestEvent.created_at) {
latestEvent = evt
}
// Emit immediately on first event
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setReaderContent({
title,
markdown: evt.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setReaderContent({
title,
markdown: finalEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
setCurrentTitle(article.title)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
}
// Fetch highlights after content is shown
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
setHighlights([])
const le = latestEvent as NostrEvent | null
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
const eventId = le ? le.id : undefined
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
if (coord && eventId) {
setHighlightsLoading(true)
// Clear highlights that don't belong to this article coordinate
setHighlights((prev) => {
return prev.filter(h => {
// Keep highlights that match this article coordinate or event ID
return h.eventReference === coord || h.eventReference === eventId
})
},
settings
)
})
await fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settingsRef.current,
false, // force
eventStore || undefined
)
} else {
// No article event to fetch highlights for - clear and don't show loading
setHighlights([])
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
@@ -120,7 +315,11 @@ export function useArticleLoader({
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naddr, relayPool, settings])
}, [
naddr,
previewData
])
}

View File

@@ -158,7 +158,10 @@ export const useBookmarksData = ({
// Fetch article-specific highlights when viewing an article
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!relayPool || !activeAccount) {
setHighlightsLoading(false)
return
}
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (effectiveArticleCoordinate && !externalUrl) {
@@ -167,6 +170,9 @@ export const useBookmarksData = ({
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
} else {
// For external URLs or other cases, loading is not needed
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
interface UseDocumentTitleProps {
title?: string
fallback?: string
}
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
const originalTitleRef = useRef<string>(document.title)
useEffect(() => {
// Store the original title on first mount
if (originalTitleRef.current === DEFAULT_TITLE) {
originalTitleRef.current = document.title
}
// Set the new title if provided, otherwise use fallback or default
const newTitle = title || fallback || DEFAULT_TITLE
document.title = newTitle
// Cleanup: restore original title when component unmounts
return () => {
document.title = originalTitleRef.current
}
}, [title, fallback])
// Return a function to manually reset to default
const resetTitle = () => {
document.title = DEFAULT_TITLE
}
return { resetTitle }
}

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

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

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from 'react'
import { useEffect, useRef, useMemo, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { useDocumentTitle } from './useDocumentTitle'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
@@ -49,6 +50,12 @@ export function useExternalUrlLoader({
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Track in-flight request to prevent stale updates when switching quickly
const currentRequestIdRef = useRef(0)
// Track the current content title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
@@ -70,6 +77,7 @@ export function useExternalUrlLoader({
if (!relayPool || !url) return
const loadExternalUrl = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
@@ -83,7 +91,9 @@ export function useExternalUrlLoader({
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setCurrentTitle(content.title)
setReaderContent(content)
setReaderLoading(false)
@@ -114,6 +124,7 @@ export function useExternalUrlLoader({
url,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
@@ -131,13 +142,13 @@ export function useExternalUrlLoader({
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
@@ -154,9 +165,13 @@ export function useExternalUrlLoader({
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, cachedUrlHighlights])
}, [
url,
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content
useEffect(() => {
@@ -169,8 +184,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

@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { normalizeUrl } from '../utils/urlHelpers'
import { classifyHighlights } from '../utils/highlightClassification'
import { nip19 } from 'nostr-tools'
interface UseFilteredHighlightsParams {
highlights: Highlight[]
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
let urlFiltered = highlights
// For Nostr articles, we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
// Filter highlights based on URL type
if (selectedUrl.startsWith('nostr:')) {
// For Nostr articles, extract the article coordinate and filter by eventReference
try {
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
urlFiltered = highlights.filter(h => {
// Keep highlights that match this article coordinate
return h.eventReference === articleCoordinate
})
} else {
// Not a valid naddr, clear all highlights
urlFiltered = []
}
} catch {
// Invalid naddr, clear all highlights
urlFiltered = []
}
} else {
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {

View File

@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !eventStore) {
console.error('Missing requirements for highlight creation')
return
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
const newHighlight = await createHighlight(
text,
source,
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
)
// Highlight created successfully
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
if (selection) {

View File

@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
setProcessedMarkdown('')
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}

View File

@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
}
@@ -18,63 +17,69 @@ export const useReadingPosition = ({
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const suppressUntilRef = useRef<number>(0)
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
// Store callbacks in refs to avoid them being dependencies
const onPositionChangeRef = useRef(onPositionChange)
const onReadingCompleteRef = useRef(onReadingComplete)
const onSaveRef = useRef(onSave)
useEffect(() => {
onPositionChangeRef.current = onPositionChange
onReadingCompleteRef.current = onReadingComplete
onSaveRef.current = onSave
}, [onPositionChange, onReadingComplete, onSave])
// Debounced save function
// Suppress auto-saves for a given duration (used after programmatic restore)
const suppressSavesFor = useCallback((ms: number) => {
const until = Date.now() + ms
suppressUntilRef.current = until
}, [])
// Throttled save function - saves at 1s intervals during scrolling
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) {
if (!syncEnabled || !onSaveRef.current) {
return
}
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
const isInitialSave = !hasSavedOnce.current
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
// Not significant enough to save
// Always save instantly when we reach completion (1.0)
if (currentPosition === 1 && !lastSaved100Ref.current) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
lastSaved100Ref.current = true
onSaveRef.current(1)
return
}
// Clear existing timer
// Always update the pending position (latest position to save)
pendingPositionRef.current = currentPosition
// Throttle: only schedule a save if one isn't already pending
// This ensures saves happen at regular 1s intervals during continuous scrolling
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
return // Already have a save scheduled, don't reset the timer
}
// Schedule new save
const THROTTLE_MS = 1000
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
onSave(currentPosition)
}, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabled || !onSave) return
// Cancel any pending saves
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
// Save the latest position, not the one from when timer was scheduled
const positionToSave = pendingPositionRef.current
onSaveRef.current?.(positionToSave)
saveTimerRef.current = null
}
// Always allow immediate save (including 0%)
lastSavedPosition.current = position
hasSavedOnce.current = true
onSave(position)
}, [syncEnabled, onSave, position])
}, THROTTLE_MS)
}, [syncEnabled])
useEffect(() => {
if (!enabled) return
@@ -88,28 +93,27 @@ export const useReadingPosition = ({
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Ignore if document is too small (likely during page transition)
if (documentHeight < 100) return
// Calculate position based on how much of the content has been scrolled through
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
const maxScroll = documentHeight - windowHeight
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
// If we're within 5px of the bottom, consider it 100%
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
// This prevents false 100% during page transitions
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
// 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)
onPositionChangeRef.current?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Schedule auto-save if sync is enabled (unless suppressed)
if (Date.now() >= suppressUntilRef.current) {
scheduleSave(clampedProgress)
}
// Note: Suppression is silent to avoid log spam during scrolling
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
@@ -120,7 +124,7 @@ export const useReadingPosition = ({
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
onReadingCompleteRef.current?.()
}
completionTimerRef.current = null
}, completionHoldMs)
@@ -134,7 +138,7 @@ export const useReadingPosition = ({
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
onReadingCompleteRef.current?.()
}
}
}
@@ -152,25 +156,20 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
// Only clear completion timer since that's tied to the current scroll session
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
}
// 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, readingCompleteThreshold, scheduleSave, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
hasSavedOnce.current = false
lastSavedPosition.current = 0
lastSaved100Ref.current = false
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
@@ -182,6 +181,6 @@ export const useReadingPosition = ({
position,
isReadingComplete,
progressPercentage: Math.round(position * 100),
saveNow
suppressSavesFor
}
}

View File

@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
@@ -16,30 +16,28 @@ interface UseSettingsParams {
}
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
const [settings, setSettings] = useState<UserSettings>({})
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [toastType, setToastType] = useState<'success' | 'error'>('success')
// Load settings and set up subscription
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
useEffect(() => {
if (!relayPool || !pubkey || !eventStore) return
const loadAndWatch = async () => {
try {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
if (loadedSettings) setSettings(loadedSettings)
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadAndWatch()
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings(loadedSettings)
// Start settings stream: seed from store, stream updates to store in background
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => subscription.unsubscribe()
// Also watch store reactively for any further updates
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
})
return () => {
subscription.unsubscribe()
stopNetwork()
}
}, [relayPool, pubkey, eventStore])
// Apply settings to document
@@ -73,6 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
// Set image max-width based on full-width setting
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
}
applyStyles()

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

@@ -5,13 +5,12 @@ import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
// Register Service Worker for PWA functionality (production only)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
.register('/sw.js')
.then(registration => {
// Check for updates periodically
setInterval(() => {
registration.update()
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}

View File

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

View File

@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Define relays to query - use union of relay hints from naddr and configured relays
// This avoids failures when naddr contains stale/unreachable relay hints
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
@@ -114,7 +114,28 @@ export async function fetchArticleByNaddr(
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
let events = collected as NostrEvent[]
// Fallback: if nothing found, try a second round against a set of reliable public relays
if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
filter,
1500,
12000
)
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
events = fallbackCollected as NostrEvent[]
}
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -1,12 +1,8 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { merge } from 'rxjs'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { collectBookmarksFromEvents } from './bookmarkProcessing'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import {
@@ -64,11 +60,8 @@ class BookmarkController {
}> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Event loaders for efficient batching
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null
private relayPool: RelayPool | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
@@ -117,15 +110,15 @@ class BookmarkController {
}
/**
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
* Hydrate events by IDs using queryEvents (local-first, streaming)
*/
private hydrateByIds(
private async hydrateByIds(
ids: string[],
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.eventLoader) {
): Promise<void> {
if (!this.relayPool) {
return
}
@@ -135,71 +128,146 @@ class BookmarkController {
return
}
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use EventLoader - it auto-batches and streams results
merge(...pointers.map(this.eventLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
// Fetch events using local-first queryEvents
await queryEvents(
this.relayPool,
{ ids: unique },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
onProgress()
},
error: () => {
// Silent error - EventLoader handles retries
}
})
)
}
/**
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
*/
private hydrateByCoordinates(
private async hydrateByCoordinates(
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
): Promise<void> {
if (!this.relayPool) {
return
}
if (coords.length === 0) return
if (coords.length === 0) {
return
}
// Convert coordinates to AddressPointers
const pointers = coords.map(c => ({
kind: c.kind,
pubkey: c.pubkey,
identifier: c.identifier
}))
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
onProgress()
},
error: () => {
// Silent error - AddressLoader handles retries
// Group by kind and pubkey for efficient batching
const filtersByKind = new Map<number, Map<string, string[]>>()
for (const coord of coords) {
if (!filtersByKind.has(coord.kind)) {
filtersByKind.set(coord.kind, new Map())
}
})
const byPubkey = filtersByKind.get(coord.kind)!
if (!byPubkey.has(coord.pubkey)) {
byPubkey.set(coord.pubkey, [])
}
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
}
// Kick off all queries in parallel (fire-and-forget)
const promises: Promise<void>[] = []
for (const [kind, byPubkey] of filtersByKind) {
for (const [pubkey, identifiers] of byPubkey) {
// Separate empty and non-empty identifiers
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
// Fetch events with non-empty d-tags
if (nonEmptyIdentifiers.length > 0) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
// Fetch events with empty d-tag separately (without '#d' filter)
if (hasEmptyIdentifier) {
promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey] },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
// Only process events with empty d-tag
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
if (dTag !== '') return
const coordinate = `${event.kind}:${event.pubkey}:`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
}
}
// Wait for all queries to complete
await Promise.all(promises)
}
private async buildAndEmitBookmarks(
@@ -244,42 +312,58 @@ class BookmarkController {
})
const allItems = [...publicItemsAll, ...privateItemsAll]
const deduped = dedupeBookmarksById(allItems)
// Separate hex IDs from coordinates
// Separate hex IDs from coordinates for fetching
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
// Request hydration for all items that don't have content yet
deduped.forEach(i => {
// If item has no content, we need to fetch it
if (!i.content || i.content.length === 0) {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
}
}
})
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
const allBookmarks = dedupeBookmarksById([
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
// This preserves the original public/private split while still getting all the content
const allBookmarks = [
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
]
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.map(b => ({
...b,
urlReferences: extractUrlsFromContent(b.content)
}))
.sort((a, b) => {
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
return bTs - aTs
})
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
created_at: newestCreatedAt || 0,
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
@@ -295,7 +379,7 @@ class BookmarkController {
const idToEvent: Map<string, NostrEvent> = new Map()
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
// Now fetch events progressively in background using local-first queries
const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent)
@@ -310,10 +394,14 @@ class BookmarkController {
}
})
// Kick off batched hydration (streaming, non-blocking)
// EventLoader and AddressLoader handle batching and streaming automatically
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
// Kick off hydration (streaming, non-blocking, local-first)
// Fire-and-forget - don't await, let it run in background
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
} catch (error) {
console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
@@ -324,8 +412,13 @@ class BookmarkController {
relayPool: RelayPool
activeAccount: unknown
accountManager: { getActive: () => unknown }
eventStore?: EventStore
}): Promise<void> {
const { relayPool, activeAccount, accountManager } = options
const { relayPool, activeAccount, accountManager, eventStore } = options
// Store references for hydration
this.relayPool = relayPool
this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
return
@@ -336,16 +429,6 @@ class BookmarkController {
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
// Initialize loaders for this session
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {

View File

@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
}
const unique = Array.from(byId.values())
// Separate web bookmarks (kind:39701) from list-based bookmarks
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
const byD = new Map<string, NostrEvent>()
for (const e of unique) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
const prev = byD.get(d)
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
}
}
const setsAndNamedLists = Array.from(byD.values())
// Separate web bookmarks from bookmark sets/lists
const allReplaceable = Array.from(byD.values())
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
const out: NostrEvent[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
// Add web bookmarks as individual events
// Add deduplicated web bookmarks as individual events
out.push(...webBookmarks)
return out
}

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 {
@@ -67,48 +71,44 @@ export const processApplesauceBookmarks = (
): IndividualBookmark[] => {
if (!bookmarks) return []
console.log('[BOOKMARK_TS] processApplesauceBookmarks called with parentCreatedAt:', parentCreatedAt, 'isPrivate:', isPrivate)
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
const allItems: IndividualBookmark[] = []
// Process notes (EventPointer[])
if (applesauceBookmarks.notes) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.notes.length, 'notes with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.notes.forEach((note: EventPointer) => {
allItems.push({
id: note.id,
content: '',
created_at: parentCreatedAt || 0,
created_at: note.created_at ?? null,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
// Process articles (AddressPointer[])
if (applesauceBookmarks.articles) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.articles.length, 'articles with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
allItems.push({
id: coordinate,
content: '',
created_at: parentCreatedAt || 0,
created_at: article.created_at ?? null,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
@@ -119,33 +119,32 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: parentCreatedAt || 0,
created_at: 0, // Hashtags don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
// Process URLs (string[])
if (applesauceBookmarks.urls) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.urls.length, 'URLs with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.urls.forEach((url: string) => {
allItems.push({
id: `url-${url}`,
content: url,
created_at: parentCreatedAt || 0,
created_at: 0, // URLs don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
@@ -154,20 +153,24 @@ export const processApplesauceBookmarks = (
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray
const processed = bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || parentCreatedAt || 0
}))
.map((bookmark: BookmarkData) => {
return {
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at ?? null,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
listUpdatedAt: parentCreatedAt ?? null
}
})
return processed
}
// Types and guards around signer/decryption APIs
@@ -189,6 +192,9 @@ export function hydrateItems(
}
}
// Ensure all events with content get parsed content for proper rendering
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
return {
...item,
pubkey: ev.pubkey || item.pubkey,
@@ -196,13 +202,12 @@ export function hydrateItems(
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
parsedContent: parsedContent || item.parsedContent
}
})
.filter(item => {
// Filter out bookmark list events (they're containers, not content)
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
console.log('[BOOKMARK_TS] After hydration - id:', item.id, 'kind:', item.kind, 'isBookmarkListEvent:', isBookmarkListEvent, 'content:', item.content?.substring(0, 50))
return !isBookmarkListEvent
})
}

View File

@@ -121,7 +121,6 @@ export async function collectBookmarksFromEvents(
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
for (const evt of bookmarkListEvents) {
console.log('[BOOKMARK_TS] Processing bookmark event', evt.id, 'kind:', evt.kind, 'created_at:', evt.created_at)
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
@@ -134,29 +133,36 @@ export async function collectBookmarksFromEvents(
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
// Use coordinate format for web bookmarks to enable proper deduplication
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
publicItemsAll.push({
id: evt.id,
id: webBookmarkId,
content: evt.content || '',
created_at: evt.created_at || Math.floor(Date.now() / 1000),
created_at: evt.created_at ?? null,
pubkey: evt.pubkey,
kind: evt.kind,
tags: evt.tags || [],
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage
setImage,
listUpdatedAt: evt.created_at ?? null
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...processedPub.map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

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

@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { publishEvent } from './writeService'
import { isLocalRelay } from '../utils/helpers'
import { setHighlightMetadata } from './highlightEventProcessor'
// Boris pubkey for zap splits
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
const {
getHighlightText,
getHighlightContext,
@@ -118,25 +127,111 @@ export async function createHighlight(
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent)
// Initialize custom properties on the event (will be updated after publishing)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: false,
isSyncing: false
}
// Check current connection status for UI feedback
// Get only connected relays to avoid long timeouts
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
let isLocalOnly = false
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
// Convert to Highlight with relay tracking info and return IMMEDIATELY
try {
// Publish only to connected relays to avoid long timeouts
if (connectedRelays.length === 0) {
isLocalOnly = true
} else {
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
}
// Determine which relays successfully accepted the event
const successfulRelays = publishResponses
.filter(response => response.ok)
.map(response => response.from)
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
// isLocalOnly is true if only local relays accepted the event
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
// Handle case when no relays were connected
const successfulRelaysList = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
})
// Also update the event with the actual properties (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: successfulRelaysList,
isLocalOnly,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
// Mark for offline sync if we're in local-only mode
if (isLocalOnly) {
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
} catch (error) {
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
// If publishing fails completely, assume local-only mode
isLocalOnly = true
// Store metadata in cache (persists across EventStore serialization)
setHighlightMetadata(signedEvent.id, {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
})
// Also update the event with the error state (for backwards compatibility)
;(signedEvent as HighlightEvent).__highlightProps = {
publishedRelays: [],
isLocalOnly: true,
isSyncing: false
}
// Store the event in EventStore AFTER updating with final properties
eventStore.add(signedEvent)
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info
const highlight = eventToHighlight(signedEvent)
highlight.publishedRelays = expectedSuccessRelays
// Manually set the properties since __highlightProps might not be working
const finalPublishedRelays = publishResponses.length > 0
? publishResponses
.filter(response => response.ok)
.map(response => response.from)
: []
highlight.publishedRelays = finalPublishedRelays
highlight.isLocalOnly = isLocalOnly
highlight.isOfflineCreated = isLocalOnly
highlight.isSyncing = false
return highlight
}

View File

@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Highlight } from '../types/highlights'
// Extended event type with highlight metadata
interface HighlightEvent extends NostrEvent {
__highlightProps?: {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
}
const {
getHighlightText,
getHighlightContext,
@@ -12,6 +21,66 @@ const {
getHighlightAttributions
} = Helpers
const METADATA_CACHE_KEY = 'highlightMetadataCache'
type HighlightMetadata = {
publishedRelays?: string[]
isLocalOnly?: boolean
isSyncing?: boolean
}
/**
* Load highlight metadata from localStorage
*/
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
try {
const raw = localStorage.getItem(METADATA_CACHE_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
return new Map(Object.entries(parsed))
} catch {
// Silently fail on parse errors or if storage is unavailable
return new Map()
}
}
/**
* Save highlight metadata to localStorage
*/
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
try {
const record = Object.fromEntries(cache.entries())
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
} catch {
// Silently fail if storage is full or unavailable
}
}
/**
* Cache for highlight metadata that persists across EventStore serialization
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
*/
const highlightMetadataCache = loadHighlightMetadataFromStorage()
/**
* Store highlight metadata for an event ID
*/
export function setHighlightMetadata(
eventId: string,
metadata: HighlightMetadata
): void {
highlightMetadataCache.set(eventId, metadata)
saveHighlightMetadataToStorage(highlightMetadataCache)
}
/**
* Get highlight metadata for an event ID
*/
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
return highlightMetadataCache.get(eventId)
}
/**
* Convert a NostrEvent to a Highlight object
*/
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
// Check cache first (persists across EventStore serialization)
const cachedMetadata = getHighlightMetadata(event.id)
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
return {
id: event.id,
pubkey: event.pubkey,
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
urlReference: sourceUrl,
author,
context,
comment
comment,
// Preserve custom properties if they exist
publishedRelays: customProps.publishedRelays,
isLocalOnly: customProps.isLocalOnly,
isSyncing: customProps.isSyncing
}
}

View File

@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], authors: pubkeys, limit: 200 },
{ kinds: [9802], authors: pubkeys, limit: 1000 },
{
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {

View File

@@ -8,8 +8,6 @@ import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
type HighlightsCallback = (highlights: Highlight[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'highlights_last_synced'
/**
* Shared highlights controller
* Manages the user's highlights centrally, similar to bookmarkController
@@ -68,37 +66,10 @@ class HighlightsController {
this.emitHighlights(this.currentHighlights)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[highlights] Failed to save last synced timestamp:', err)
}
}
/**
* Load highlights for a user
* Streams results and stores in event store
* Always fetches ALL highlights to ensure completeness
*/
async start(options: {
relayPool: RelayPool
@@ -124,15 +95,12 @@ class HighlightsController {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
// Fetch ALL highlights without limits (no since filter)
// This ensures we get complete results for profile/my pages
const filter = {
kinds: [KINDS.Highlights],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
}
const events = await queryEvents(
relayPool,
@@ -179,12 +147,6 @@ class HighlightsController {
this.lastLoadedPubkey = pubkey
this.emitHighlights(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []

View File

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

View File

@@ -81,13 +81,21 @@ class NostrverseHighlightsController {
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
try {
const seenIds = new Set<string>()
// Start with existing highlights when doing incremental sync
const highlightsMap = new Map<string, Highlight>(
this.currentHighlights.map(h => [h.id, h])
)
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) filter.since = lastSyncedAt
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number; limit?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) {
filter.since = lastSyncedAt
} else {
// On initial load, fetch more highlights
filter.limit = 1000
}
const events = await queryEvents(
relayPool,
@@ -111,22 +119,24 @@ class NostrverseHighlightsController {
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
events.forEach(evt => eventStore.add(evt))
const highlights = events.map(eventToHighlight)
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
const sorted = sortHighlights(unique)
const highlights = events.map(eventToHighlight)
// Merge new highlights with existing ones
highlights.forEach(h => highlightsMap.set(h.id, h))
const sorted = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(newest)
}
} catch (err) {
this.currentHighlights = []
// On error, keep existing highlights instead of clearing them
console.error('[nostrverse-highlights] Failed to sync:', err)
this.emitHighlights(this.currentHighlights)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)

View File

@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
let isSyncing = false
/**
* Load offline events from localStorage
*/
function loadOfflineEventsFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(OFFLINE_EVENTS_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw) as string[]
return new Set(parsed)
} catch {
// Silently fail on parse errors or if storage is unavailable
return new Set()
}
}
/**
* Save offline events to localStorage
*/
function saveOfflineEventsToStorage(events: Set<string>): void {
try {
const array = Array.from(events)
localStorage.setItem(OFFLINE_EVENTS_KEY, JSON.stringify(array))
} catch {
// Silently fail if storage is full or unavailable
}
}
// Track events created during offline period
const offlineCreatedEvents = new Set<string>()
const offlineCreatedEvents = loadOfflineEventsFromStorage()
// Track events currently being synced
const syncingEvents = new Set<string>()
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
*/
export function markEventAsOfflineCreated(eventId: string): void {
offlineCreatedEvents.add(eventId)
saveOfflineEventsToStorage(offlineCreatedEvents)
}
/**
* Check if an event was created during offline period (flight mode)
*/
export function isEventOfflineCreated(eventId: string): boolean {
return offlineCreatedEvents.has(eventId)
}
/**
@@ -87,6 +126,7 @@ export async function syncLocalEventsToRemote(
if (eventsToSync.length === 0) {
isSyncing = false
offlineCreatedEvents.clear()
saveOfflineEventsToStorage(offlineCreatedEvents)
return
}
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
// Mark all events as syncing
// Mark all events as syncing and update metadata
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
notifySyncStateChange(event.id, true)
// Update metadata cache to reflect syncing state
const existingMetadata = getHighlightMetadata(event.id)
setHighlightMetadata(event.id, {
...existingMetadata,
isSyncing: true
})
})
// Publish to remote relays
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
syncingEvents.delete(eventId)
offlineCreatedEvents.delete(eventId)
notifySyncStateChange(eventId, false)
// Update metadata cache: sync complete, no longer local-only
const existingMetadata = getHighlightMetadata(eventId)
setHighlightMetadata(eventId, {
...existingMetadata,
isSyncing: false,
isLocalOnly: false
})
})
// Save updated offline events set to localStorage
saveOfflineEventsToStorage(offlineCreatedEvents)
// Clear syncing state for failed events
uniqueEvents.forEach(event => {
if (!successfulIds.includes(event.id)) {
syncingEvents.delete(event.id)
notifySyncStateChange(event.id, false)
// Update metadata cache: sync failed, still local-only
const existingMetadata = getHighlightMetadata(event.id)
setHighlightMetadata(event.id, {
...existingMetadata,
isSyncing: false
// Keep isLocalOnly as true (sync failed)
})
}
})
} catch (error) {

View File

@@ -0,0 +1,114 @@
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
import { ReadItem } from './readsService'
// Cache for OpenGraph data to avoid repeated requests
const ogCache = new Map<string, Record<string, unknown>>()
function getCachedOgData(url: string): Record<string, unknown> | null {
const cached = ogCache.get(url)
if (!cached) return null
return cached
}
function setCachedOgData(url: string, data: Record<string, unknown>): void {
ogCache.set(url, data)
}
/**
* Enhances a ReadItem with OpenGraph data
* Only fetches if the item doesn't already have good metadata
*/
export async function enhanceReadItemWithOpenGraph(item: ReadItem): Promise<ReadItem> {
// Skip if we already have good metadata
if (item.title && item.title !== fallbackTitleFromUrl(item.url || '') && item.image) {
return item
}
if (!item.url) return item
try {
// Check cache first
let ogData = getCachedOgData(item.url)
if (!ogData) {
// Fetch OpenGraph data
const fetchedOgData = await fetchOpenGraph(item.url)
if (fetchedOgData) {
ogData = fetchedOgData
setCachedOgData(item.url, fetchedOgData)
}
}
if (!ogData) return item
// Enhance the item with OpenGraph data
const enhanced: ReadItem = { ...item }
// Use OpenGraph title if we don't have a good title
if (!enhanced.title || enhanced.title === fallbackTitleFromUrl(item.url)) {
const ogTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
if (typeof ogTitle === 'string') {
enhanced.title = ogTitle
}
}
// Use OpenGraph description if we don't have a summary
if (!enhanced.summary) {
const ogDescription = ogData['og:description'] || ogData['twitter:description'] || ogData.description
if (typeof ogDescription === 'string') {
enhanced.summary = ogDescription
}
}
// Use OpenGraph image if we don't have an image
if (!enhanced.image) {
const ogImage = ogData['og:image'] || ogData['twitter:image'] || ogData.image
if (typeof ogImage === 'string') {
enhanced.image = ogImage
}
}
return enhanced
} catch (error) {
console.warn('Failed to enhance ReadItem with OpenGraph data:', error)
return item
}
}
/**
* Enhances multiple ReadItems with OpenGraph data in parallel
* Uses batching to avoid overwhelming the service
*/
export async function enhanceReadItemsWithOpenGraph(items: ReadItem[]): Promise<ReadItem[]> {
const BATCH_SIZE = 5
const BATCH_DELAY = 1000 // 1 second between batches
const enhancedItems: ReadItem[] = []
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE)
// Process batch in parallel
const batchPromises = batch.map(item => enhanceReadItemWithOpenGraph(item))
const batchResults = await Promise.all(batchPromises)
enhancedItems.push(...batchResults)
// Add delay between batches to be respectful to the service
if (i + BATCH_SIZE < items.length) {
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY))
}
}
return enhancedItems
}
// Helper function to generate fallback title from URL
function fallbackTitleFromUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace('www.', '')
} catch {
return url
}
}

View File

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

View File

@@ -110,3 +110,4 @@ export async function fetchReadableContent(
}

View File

@@ -75,10 +75,17 @@ export function processReadingProgress(
continue
}
} else if (dTag.startsWith('url:')) {
// It's a URL with base64url encoding
const encoded = dTag.replace('url:', '')
// It's a URL. We support both raw URLs and base64url-encoded URLs.
const value = dTag.slice(4)
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
try {
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
if (looksBase64Url) {
// Decode base64url to raw URL
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
} else {
// Treat as raw URL (already decoded)
itemUrl = value
}
itemId = itemUrl
itemType = 'external'
} catch (e) {

View File

@@ -19,7 +19,6 @@ export interface ReadingProgressContent {
progress: number // 0-1 scroll progress
ts?: number // Unix timestamp (optional, for display)
loc?: number // Optional: pixel position
ver?: string // Schema version
}
// Helper to extract and parse reading progress from event (kind 39802)
@@ -98,11 +97,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
if (naddrOrUrl.startsWith('nostr:')) {
return naddrOrUrl.replace('nostr:', '')
}
// For URLs, use base64url encoding (URL-safe)
return btoa(naddrOrUrl)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
return naddrOrUrl
}
/**
@@ -120,8 +116,7 @@ export async function saveReadingPosition(
const progressContent: ReadingProgressContent = {
progress: position.position,
ts: position.timestamp,
loc: position.scrollTop,
ver: '1'
loc: position.scrollTop
}
const tags = generateProgressTags(articleIdentifier)
@@ -138,8 +133,136 @@ export async function saveReadingPosition(
await publishEvent(relayPool, eventStore, signed)
}
/**
* Streaming reading position loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startReadingPositionStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
articleIdentifier: string,
onPosition: (pos: ReadingPosition | null) => void
): () => void {
const dTag = generateDTag(articleIdentifier)
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onPosition(null)
return
}
const parsed = getReadingProgressContent(event)
onPosition(parsed || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* Stabilized reading position collector
* Collects position updates for a brief window, then emits the best one (newest, then highest progress)
* @returns Object with stop() to cancel and onStable(cb) to register callback
*/
export function collectReadingPositionsOnce(params: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
articleIdentifier: string
windowMs?: number
}): { stop: () => void; onStable: (cb: (pos: ReadingPosition | null) => void) => void } {
const { relayPool, eventStore, pubkey, articleIdentifier, windowMs = 700 } = params
const candidates: ReadingPosition[] = []
let stableCallback: ((pos: ReadingPosition | null) => void) | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let streamStop: (() => void) | null = null
let hasEmitted = false
const emitStable = () => {
if (hasEmitted || !stableCallback) return
hasEmitted = true
if (candidates.length === 0) {
stableCallback(null)
return
}
// Sort: newest first, then highest progress
candidates.sort((a, b) => {
const timeDiff = b.timestamp - a.timestamp
if (timeDiff !== 0) return timeDiff
return b.position - a.position
})
stableCallback(candidates[0])
}
// Start streaming and collecting
streamStop = startReadingPositionStream(
relayPool,
eventStore,
pubkey,
articleIdentifier,
(pos) => {
if (hasEmitted) return
if (!pos) {
return
}
if (pos.position <= 0.05 || pos.position >= 1) {
return
}
candidates.push(pos)
// Schedule one-shot emission if not already scheduled
if (!timer) {
timer = setTimeout(() => {
emitStable()
if (streamStop) streamStop()
}, windowMs)
}
}
)
return {
stop: () => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (streamStop) {
streamStop()
streamStop = null
}
},
onStable: (cb) => {
stableCallback = cb
}
}
}
/**
* Load reading position from Nostr (kind 39802)
* @deprecated Use startReadingPositionStream for non-blocking behavior
* Returns current local position immediately (or null) and starts background sync
*/
export async function loadReadingPosition(
relayPool: RelayPool,
@@ -149,101 +272,29 @@ export async function loadReadingPosition(
): Promise<ReadingPosition | null> {
const dTag = generateDTag(articleIdentifier)
// Check local event store first
let initial: ReadingPosition | null = null
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
)
if (localEvent) {
const content = getReadingProgressContent(localEvent)
if (content) {
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content
}
if (content) initial = content
}
} catch (err) {
// Ignore errors and fetch from relays
} catch {
// ignore
}
// Fetch from relays
const result = await fetchFromRelays(
relayPool,
eventStore,
pubkey,
READING_PROGRESS_KIND,
dTag,
getReadingProgressContent
)
return result || null
}
// Helper function to fetch from relays with timeout
async function fetchFromRelays(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
kind: number,
dTag: string,
parser: (event: NostrEvent) => ReadingPosition | undefined
): Promise<ReadingPosition | null> {
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}, 3000)
const sub = relayPool
.subscription(RELAYS, {
kinds: [kind],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(kind, pubkey, dTag)
)
if (event) {
const content = parser(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
resolve(null)
}
}
},
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => {
sub.unsubscribe()
}, 3000)
})
// Start background sync (fire-and-forget; no timeout)
relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return initial
}

View File

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

View File

@@ -3,7 +3,6 @@ import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
@@ -44,7 +43,7 @@ export async function fetchAllReads(
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }),
fetchReadArticles(relayPool, userPubkey)
])
@@ -77,7 +76,7 @@ export async function fetchAllReads(
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
readingTimestamp: bookmark.created_at ?? undefined
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)
@@ -130,8 +129,7 @@ export async function fetchAllReads(
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers }
)
// Merge event data into ReadItems and emit

View File

@@ -0,0 +1,180 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
export interface UserRelayInfo {
url: string
mode?: 'read' | 'write' | 'both'
}
/**
* Loads user's relay list from kind 10002 (NIP-65)
*/
export async function loadUserRelayList(
relayPool: RelayPool,
pubkey: string,
options?: {
onUpdate?: (relays: UserRelayInfo[]) => void
}
): Promise<UserRelayInfo[]> {
try {
// Try querying with streaming callback for faster results
const events: NostrEvent[] = []
const eventsMap = new Map<string, NostrEvent>()
const result = await queryEvents(relayPool, {
kinds: [10002],
authors: [pubkey],
limit: 10
}, {
onEvent: (evt) => {
// Deduplicate by id and keep most recent
const existing = eventsMap.get(evt.id)
if (!existing || evt.created_at > existing.created_at) {
eventsMap.set(evt.id, evt)
// Update events array with deduplicated events
events.length = 0
events.push(...Array.from(eventsMap.values()))
// Stream immediate updates to caller using the newest event
if (options?.onUpdate) {
const tags = evt.tags || []
const relays: UserRelayInfo[] = []
for (const tag of tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const mode = (tag[2] as 'read' | 'write' | undefined) || 'both'
relays.push({ url, mode })
}
}
if (relays.length > 0) {
options.onUpdate(relays)
}
}
}
}
})
// Use the streaming results if we got any, otherwise fall back to the full result
const finalEvents = events.length > 0 ? events : result
// Also try a broader query to see if we get any events at all
await queryEvents(relayPool, {
kinds: [10002],
limit: 5
})
if (finalEvents.length === 0) return []
// Get most recent event
const sortedEvents = finalEvents.sort((a, b) => b.created_at - a.created_at)
const relayListEvent = sortedEvents[0]
const relays: UserRelayInfo[] = []
for (const tag of relayListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const mode = tag[2] as 'read' | 'write' | undefined
relays.push({
url,
mode: mode || 'both'
})
}
}
return relays
} catch (error) {
console.error('Failed to load user relay list:', error)
return []
}
}
/**
* Loads blocked relays from kind 10006 (NIP-51 mute list)
*/
export async function loadBlockedRelays(
relayPool: RelayPool,
pubkey: string
): Promise<string[]> {
try {
const events = await queryEvents(relayPool, {
kinds: [10006],
authors: [pubkey]
})
if (events.length === 0) return []
// Get most recent event
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const muteListEvent = sortedEvents[0]
const blocked: string[] = []
for (const tag of muteListEvent.tags) {
if (tag[0] === 'r' && tag[1]) {
blocked.push(tag[1])
}
}
return blocked
} catch (error) {
console.error('Failed to load blocked relays:', error)
return []
}
}
/**
* Computes final relay set by merging inputs and removing blocked relays
*/
export function computeRelaySet(params: {
hardcoded: string[]
bunker?: string[]
userList?: UserRelayInfo[]
blocked?: string[]
alwaysIncludeLocal: string[]
}): string[] {
const {
hardcoded,
bunker = [],
userList = [],
blocked = [],
alwaysIncludeLocal
} = params
const relaySet = new Set<string>()
const blockedSet = new Set(blocked)
// Helper to check if relay should be included
const shouldInclude = (url: string): boolean => {
// Always include local relays
if (alwaysIncludeLocal.includes(url)) return true
// Otherwise check if blocked
return !blockedSet.has(url)
}
// Add hardcoded relays
for (const url of hardcoded) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add bunker relays
for (const url of bunker) {
if (shouldInclude(url)) relaySet.add(url)
}
// Add user relays (treating 'both' and 'read' as applicable for queries)
for (const relay of userList) {
if (shouldInclude(relay.url)) relaySet.add(relay.url)
}
// Always ensure local relays are present
for (const url of alwaysIncludeLocal) {
relaySet.add(url)
}
return Array.from(relaySet)
}

View File

@@ -0,0 +1,99 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
/**
* Local relays that are always included
*/
export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869'
]
/**
* Hardcoded relays that are always included
*/
export const HARDCODED_RELAYS = [
'wss://relay.nostr.band'
]
/**
* Gets active relay URLs from the relay pool
*/
export function getActiveRelayUrls(relayPool: RelayPool): string[] {
const urls = Array.from(relayPool.relays.keys())
return prioritizeLocalRelays(urls)
}
/**
* Normalizes a relay URL to match what applesauce-relay stores internally
* Adds trailing slash for URLs without a path
*/
function normalizeRelayUrl(url: string): string {
try {
const parsed = new URL(url)
// If the pathname is empty or just "/", ensure it ends with "/"
if (parsed.pathname === '' || parsed.pathname === '/') {
return url.endsWith('/') ? url : url + '/'
}
return url
} catch {
// If URL parsing fails, return as-is
return url
}
}
/**
* Applies a new relay set to the pool: adds missing relays, removes extras
*/
export function applyRelaySetToPool(
relayPool: RelayPool,
finalUrls: string[]
): void {
// Normalize all URLs to match pool's internal format
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
// Add new relays (use original URLs for adding, not normalized)
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
if (toAdd.length > 0) {
relayPool.group(toAdd)
}
// Remove relays not in target (but always keep local relays)
const toRemove: string[] = []
for (const url of currentUrls) {
// Check if this normalized URL is in the target set
if (!normalizedTargetUrls.has(url)) {
// Also check if it's a local relay (check both normalized and original forms)
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
normalizeRelayUrl(localUrl) === url || localUrl === url
)
if (!isLocal) {
toRemove.push(url)
}
}
}
for (const url of toRemove) {
const relay = relayPool.relays.get(url)
if (relay) {
try {
// Only close if relay is actually connected or attempting to connect
// This helps avoid WebSocket warnings for connections that never started
relay.close()
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
// Silently ignore
}
relayPool.relays.delete(url)
}
}
}

View File

@@ -58,97 +58,100 @@ export interface UserSettings {
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify
fullWidthImages?: boolean // default: false
renderVideoLinksAsEmbeds?: boolean // default: false
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoScrollToReadingPosition?: boolean // default: true - automatically scroll to saved position when opening article
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false
// Content filtering
hideBotArticlesByName?: boolean // default: true - hide authors whose profile name includes "bot"
// TTS language selection
ttsUseSystemLanguage?: boolean // default: false
ttsDetectContentLanguage?: boolean // default: true
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
// Text-to-Speech settings
ttsDefaultSpeed?: number // default: 2.1
}
/**
* Streaming settings loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startSettingsStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
relays: string[],
onSettings: (settings: UserSettings | null) => void
): () => void {
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onSettings(null)
return
}
const content = getAppDataContent<UserSettings>(event)
onSettings(content || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
* Returns current local settings immediately (or null if not present) and starts background sync.
*/
export async function loadSettings(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
// First, check if we already have settings in the local event store
let initial: UserSettings | null = null
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent)
// Still fetch from relays in the background to get any updates
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content || null
initial = content || null
}
} catch (_err) {
// Ignore local store errors
} catch {
// ignore
}
// If not in local store, fetch from relays
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.warn('⚠️ Settings load timeout - no settings event found')
hasResolved = true
resolve(null)
}
}, 5000)
const sub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
console.error('❌ Error loading settings:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Settings subscription error:', err)
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
// Start background sync (fire-and-forget; no timeout)
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
setTimeout(() => {
sub.unsubscribe()
}, 5000)
})
return initial
}
export async function saveSettings(

View File

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

View File

@@ -10,8 +10,6 @@ const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'writings_last_synced'
/**
* Shared writings controller
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
@@ -71,34 +69,6 @@ class WritingsController {
this.emitWritings(this.currentPosts)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[writings] Failed to save last synced timestamp:', err)
}
}
/**
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
*/
@@ -127,6 +97,7 @@ class WritingsController {
/**
* Load writings for a user (kind:30023)
* Streams results and stores in event store
* Always fetches ALL writings to ensure completeness
*/
async start(options: {
relayPool: RelayPool
@@ -152,15 +123,12 @@ class WritingsController {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
// Fetch ALL writings without limits (no since filter)
// This ensures we get complete results for profile/my pages
const filter = {
kinds: [KINDS.BlogPost],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
}
const events = await queryEvents(
relayPool,
@@ -221,12 +189,6 @@ class WritingsController {
this.lastLoadedPubkey = pubkey
this.emitWritings(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[writings] ❌ Failed to load writings:', error)
this.currentPosts = []

View File

@@ -59,7 +59,7 @@
.compact-read-btn:active { transform: translateY(1px); }
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-type { font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
@@ -76,6 +76,179 @@
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
/* Medium-sized card view */
.individual-bookmark.card-view {
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--color-bg-elevated);
border-radius: 12px;
transition: all 0.3s ease;
background: var(--color-bg);
}
.individual-bookmark.card-view:hover {
border-color: var(--color-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-layout {
display: flex;
flex-direction: column;
padding: 1.25rem;
gap: 0.75rem;
}
.card-content-header {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.card-text-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.card-thumbnail:hover {
opacity: 0.9;
transform: scale(1.05);
}
.card-thumbnail::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.1) 100%);
pointer-events: none;
}
.card-thumbnail .thumbnail-placeholder {
font-size: 1.5rem;
color: var(--color-border-subtle);
opacity: 0.6;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.card-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.card-content .bookmark-header {
display: none;
}
.card-content .bookmark-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.25rem 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.card-content .bookmark-content {
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-text);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-content .bookmark-content.article-summary {
-webkit-line-clamp: 2;
font-style: italic;
color: var(--color-text-secondary);
}
.card-content .bookmark-footer {
margin-top: auto;
padding-top: 0.125rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content .bookmark-footer-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
}
.card-content .bookmark-footer .bookmark-type {
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-secondary) !important;
}
.card-content .bookmark-footer .bookmark-type .content-type-icon {
color: var(--color-text-secondary) !important;
}
.card-content .bookmark-footer .bookmark-date,
.card-content .bookmark-footer .bookmark-date-link {
display: inline;
white-space: nowrap;
}
/* Reading progress as separator - always shown, full width */
.reading-progress-separator {
height: 1px;
width: 100%;
background: var(--color-border);
border-radius: 0.5px;
overflow: hidden;
margin: 0.125rem 0;
position: relative;
}
.reading-progress-separator .progress-fill {
height: 100%;
border-radius: 0.5px;
transition: width 0.3s ease, background 0.3s ease;
}
/* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
@@ -115,6 +288,74 @@
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
/* Responsive design for medium-sized cards */
@media (max-width: 768px) {
.individual-bookmark.card-view {
border-radius: 8px;
}
.card-layout {
padding: 1rem;
gap: 0.5rem;
}
.card-content-header {
gap: 0.75rem;
}
.card-thumbnail {
width: 60px;
height: 60px;
}
.card-text-content {
gap: 0.5rem;
}
.card-content .bookmark-title {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.card-content .bookmark-content {
font-size: 0.85rem;
line-height: 1.5;
}
.card-content .bookmark-footer {
padding-top: 0.125rem;
}
}
@media (max-width: 480px) {
.card-layout {
padding: 0.75rem;
gap: 0.5rem;
}
.card-content-header {
gap: 0.5rem;
}
.card-thumbnail {
width: 50px;
height: 50px;
}
.card-thumbnail .thumbnail-placeholder {
font-size: 1.2rem;
}
.card-content .bookmark-title {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.card-content .bookmark-content {
font-size: 0.8rem;
}
}
@media (max-width: 768px) {
.explore-container { padding: 1rem; }
.explore-header h1 { font-size: 2rem; }

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

@@ -1,4 +1,4 @@
/* Me page tabs */
/* My page tabs */
.me-tabs {
display: flex;
gap: 0.5rem;
@@ -71,10 +71,22 @@
padding-top: 0.25rem;
}
/* Align highlight list width with profile card width on /me */
/* Align highlight list width with profile card width on /my */
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
/* Hide tab labels on mobile to save space */
@media (max-width: 768px) {
.me-tab .tab-label {
display: none;
}
.me-tab {
padding: 0.75rem;
gap: 0;
}
}
/* Bookmarks list */
.bookmarks-list {
display: flex;
@@ -83,7 +95,7 @@
text-align: left; /* Override center alignment from .app */
}
/* Bookmark filters in Me page */
/* Bookmark filters in My page */
.me-tab-content .bookmark-filters {
background: transparent;
border: none;

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); }
@@ -35,6 +35,8 @@
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
.highlight-indicator svg { font-size: 0.875rem; }
.highlight-indicator.clickable { cursor: pointer; transition: all 0.2s ease; }
.highlight-indicator.clickable:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure font inheritance */
@@ -54,7 +56,15 @@
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
.reader .reader-html img, .reader .reader-markdown img {
max-width: var(--image-max-width, 100%);
max-height: 70vh;
height: auto;
width: auto;
display: block;
margin: 0.75rem auto;
border-radius: 6px;
}
/* Headlines with Tailwind typography */
.reader-markdown h1, .reader-html h1 {
font-size: 2.25rem; /* text-4xl */
@@ -135,7 +145,7 @@
}
.reader-markdown blockquote, .reader-html blockquote {
margin: 1.5rem 0;
padding: 1rem 0 1rem 2rem;
padding: 1rem 2rem;
font-style: italic;
}
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
@@ -182,7 +192,8 @@
}
.reader-markdown img, .reader-html img {
max-width: 100%;
max-width: 100% !important;
width: auto !important;
height: auto;
}
}
@@ -222,7 +233,7 @@
max-width: 100%;
width: 100%;
margin: 0;
padding: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0;
border-left: none;
border-right: none;
@@ -251,7 +262,7 @@
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
.reader-summary-below-image .reader-summary { color: var(--color-text-secondary); font-size: 1rem; line-height: 1.6; margin: 0; }
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image { width: calc(100% + 2rem); margin: -0.5rem -1rem 2rem -1rem; min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
.reader-header-overlay .reader-title { font-size: 2rem; line-height: 1.3; }

View File

@@ -62,6 +62,28 @@
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
.highlights-actions-right { display: flex; align-items: center; gap: 0.5rem; }
/* Collapse button in highlights header */
.highlights-header .toggle-highlights-btn {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border-subtle);
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 33px;
height: 33px;
flex-shrink: 0;
box-sizing: border-box;
}
.highlights-header .toggle-highlights-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
.highlights-header .toggle-highlights-btn:active { transform: translateY(1px); }
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }

View File

@@ -54,11 +54,20 @@
}
}
.sidebar-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.sidebar-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex: 1;
justify-content: flex-end;
}
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
@@ -92,7 +101,7 @@
gap: 0.5rem;
}
.profile-avatar {
.profile-avatar-button {
min-width: 33px;
min-height: 33px;
width: 33px;
@@ -108,10 +117,91 @@
color: var(--color-text);
box-sizing: border-box;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar svg { font-size: 1rem; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.profile-avatar-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
width: var(--min-touch-target);
height: var(--min-touch-target);
}
}
.profile-avatar-button:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar-button svg { font-size: 1rem; }
/* Profile menu wrapper */
.profile-menu-wrapper {
position: relative;
}
/* Dropdown menu */
.profile-dropdown-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
padding: 0.25rem;
z-index: 1000;
animation: profileMenuSlideIn 0.15s ease-out;
}
@keyframes profileMenuSlideIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.profile-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.875rem;
background: transparent;
border: none;
border-radius: 4px;
color: var(--color-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.875rem;
text-align: left;
white-space: nowrap;
}
.profile-menu-item:hover {
background: var(--color-bg-hover);
}
.profile-menu-item svg {
width: 1em;
font-size: 1rem;
color: var(--color-text-secondary);
}
.profile-menu-separator {
height: 1px;
background: var(--color-border);
margin: 0.25rem 0;
}
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;

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

@@ -31,7 +31,8 @@ export interface Bookmark {
export interface IndividualBookmark {
id: string
content: string
created_at: number
// Timestamp when the content was created (from the content event itself)
created_at: number | null
pubkey: string
kind: number
tags: string[][]
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
type: 'event' | 'article' | 'web'
isPrivate?: boolean
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)
added_at?: number
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
sourceKind?: number
// The 'd' tag value from kind 30003 bookmark sets
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
setTitle?: string
setDescription?: string
setImage?: string
// Timestamp of the bookmark list event (best proxy for "when bookmarked")
// Note: This is imperfect - it's when the list was last updated, not necessarily when this item was added
listUpdatedAt?: number | null
}
export interface ActiveAccount {

Some files were not shown because too many files have changed in this diff Show More