Compare commits

...

78 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
26 changed files with 1194 additions and 330 deletions

View File

@@ -7,6 +7,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.25] - 2025-01-27
### Added
- Reading progress bar for all bookmark types across all view modes
- Consistent progress tracking for articles, videos, and other content types
- Visual progress indicator in compact, medium, and large card views
- Title display for regular bookmarks/links
- Better content identification for web bookmarks
- Improved visual hierarchy in bookmark cards
### Changed
- Redesigned medium-sized bookmark cards with left-side thumbnails
- More compact and visually appealing card layout
- Better use of space with thumbnail positioning
- Made bookmark cards significantly more compact
- Reduced vertical spacing and improved density
- Better information display in limited space
- Moved bookmark type icon to bottom right footer
- Cleaner card layout with consistent icon positioning
- Better visual organization of card elements
- Enhanced card layout with author positioning in bottom-left corner
- Improved visual hierarchy and content organization
- Better balance between content and metadata
### Fixed
- Corrected TypeScript error in content type icon logic
- Resolved linting issues in CardView component
- Eliminated 0 artefacts in compact view conditional rendering
- Ensured consistent reading progress bar thickness in large cards
- Fixed reading progress bar display logic for compact view
- Ensured empty reading progress bar is always visible for articles
- Resolved timestamp and icon display alignment issues
- Removed unused variables to resolve linting errors
### Removed
- Type icon from medium-sized bookmark cards
- Cleaner visual design with reduced clutter
- Focus on content rather than type indicators
- Text expansion mechanic from medium-sized cards
- Simplified interaction model
- More consistent card behavior across view modes
- URL display from medium-sized bookmark cards
- Cleaner card appearance
- Better focus on title and content
## [0.10.24] - 2025-01-27
### Added
- Dynamic browser title based on content
- Browser title now updates to reflect the current content being viewed
- Improves user experience and bookmark identification
- Enhanced Links type bookmarks with OpenGraph data
- Web bookmarks now include rich OpenGraph metadata
- Better visual representation and content preview for web bookmarks
### Changed
- Replaced custom OpenGraph extraction with fetch-opengraph library
- More reliable and standardized OpenGraph data extraction
- Better performance and maintainability for web bookmark processing
### Fixed
- Description extraction from web bookmark content field
- Improved extraction of descriptions from web bookmark content
- Better content identification and display for web bookmarks
- Resolved all linting and TypeScript issues
- Code quality improvements and type safety enhancements
- Cleaner codebase with better maintainability
## [0.10.23] - 2025-01-27
### Added
- Video thumbnail support for cover images
- Extracts and displays video thumbnails for YouTube and Vimeo content
- Improves visual representation of video content in bookmark views
- Note content support for direct video URLs
- Uses note content as title for direct video URLs when available
- Better content identification for video bookmarks
- Smart highlight clearing for articles
- Automatically clears highlights when switching between articles
- Prevents highlight confusion between different articles
- Robust highlight loading with fallback mechanisms
- Implements multiple fallback strategies for highlight loading
- Ensures highlights are loaded even when primary methods fail
### Changed
- Home button alignment moved to left next to profile button
- Improved navigation layout consistency
- Video functionality extracted into dedicated VideoView component
- Better code organization and maintainability
- Cleaner separation of concerns for video handling
### Fixed
- Article loading performance and error handling improvements
- Better error handling for failed article loads
- Improved loading performance with optimized data fetching
- Highlight loading issues for articles
- Resolved race conditions between content loaders
- Fixed missing relayPool dependency in useEffect
- Proper filtering of Nostr article highlights in sidebar
- Skeleton loader improvements
- Consolidated multiple skeleton loaders in article view
- Replaced markdown loading spinner with skeleton
- Replaced 'No readable content found' with skeleton loader
- Video metadata extraction improvements
- Enhanced YouTube and Vimeo metadata extraction
- Better handling of video content identification
## [0.10.22] - 2025-01-27
### Added

60
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.10.19",
"version": "0.10.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.10.19",
"version": "0.10.23",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
@@ -4502,6 +4503,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
@@ -6171,6 +6181,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-opengraph": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"html-entities": "^2.3.2"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6264,6 +6284,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6896,6 +6936,22 @@
"he": "bin/he"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.23",
"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",

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

@@ -169,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,9 @@
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'
@@ -10,6 +11,7 @@ import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -22,7 +24,7 @@ interface CardViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
articleTitle?: string
readingProgress?: number
}
@@ -31,13 +33,12 @@ export const CardView: React.FC<CardViewProps> = ({
index,
hasUrls,
extractedUrls,
onSelectUrl,
authorNpub,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon,
articleTitle,
readingProgress
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
@@ -45,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)
@@ -71,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) => {
@@ -106,122 +128,87 @@ export const CardView: React.FC<CardViewProps> = ({
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>
{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>
{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

@@ -6,6 +6,7 @@ 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
@@ -36,13 +37,6 @@ export const CompactView: React.FC<CompactViewProps> = ({
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
// 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 handleCompactClick = () => {
if (isArticle) {
@@ -86,27 +80,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
{/* 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

@@ -8,6 +8,7 @@ import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { naddrEncode } from 'nostr-tools/nip19'
import { ReadingProgressBar } from '../ReadingProgressBar'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -43,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) => {
@@ -122,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">

View File

@@ -14,6 +14,7 @@ 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'
@@ -58,6 +59,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
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'
@@ -222,7 +229,14 @@ 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
})

View File

@@ -7,8 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { areAllRelaysLocal } from '../utils/helpers'
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
import { getActiveRelayUrls } from '../services/relayManager'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
@@ -114,7 +114,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -133,12 +132,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
@@ -147,8 +140,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
const updatedHighlight = {
@@ -292,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 {
@@ -313,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) {
@@ -322,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
}

View File

@@ -280,8 +280,8 @@ const Me: React.FC<MeProps> = ({
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)

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

@@ -1,4 +1,4 @@
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'
@@ -12,6 +12,7 @@ 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
@@ -64,6 +65,10 @@ export function useArticleLoader({
// 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
@@ -80,8 +85,51 @@ export function useArticleLoader({
// 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: previewData.title,
markdown: '', // Will be loaded from store or relay
@@ -91,7 +139,8 @@ export function useArticleLoader({
url: `nostr:${naddr}`
})
setReaderLoading(false) // Turn off loading immediately - we have the preview!
} else {
} else if (!foundInStore) {
// Only show loading if we didn't find content in store and no preview data
setReaderLoading(true)
setReaderContent(undefined)
}
@@ -112,38 +161,6 @@ export function useArticleLoader({
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
if (eventStore) {
try {
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
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)
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
@@ -167,6 +184,7 @@ export function useArticleLoader({
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)
@@ -194,6 +212,7 @@ export function useArticleLoader({
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)
@@ -215,6 +234,7 @@ export function useArticleLoader({
// 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,
@@ -295,19 +315,11 @@ export function useArticleLoader({
return () => {
mountedRef.current = false
}
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
naddr,
relayPool,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
previewData
])
}

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

View File

@@ -1,10 +1,11 @@
import { useEffect, useCallback } from 'react'
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
@@ -25,6 +26,9 @@ export function useEventLoader({
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
@@ -46,6 +50,7 @@ export function useEventLoader({
title,
published: event.created_at
}
setCurrentTitle(title)
setReaderContent(baseContent)
// Background: resolve author profile for kind:1 and update title
@@ -80,7 +85,9 @@ export function useEventLoader({
}
if (resolved) {
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
const updatedTitle = `Note by @${resolved}`
setCurrentTitle(updatedTitle)
setReaderContent({ ...baseContent, title: updatedTitle })
}
} catch {
// ignore profile failures; keep fallback title
@@ -119,6 +126,7 @@ export function useEventLoader({
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)
}

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 {
@@ -52,6 +53,10 @@ export function useExternalUrlLoader({
// 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(() => {
if (!url) return null
@@ -88,6 +93,7 @@ export function useExternalUrlLoader({
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setCurrentTitle(content.title)
setReaderContent(content)
setReaderLoading(false)
@@ -159,19 +165,12 @@ export function useExternalUrlLoader({
return () => {
mountedRef.current = false
}
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
// This fixes the loading skeleton appearing when going offline (flight mode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
url,
relayPool,
eventStore,
cachedUrlHighlights,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setSelectedUrl,
setHighlights,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setHighlightsLoading
cachedUrlHighlights
])
// Keep UI highlights synced with cached store updates without reloading content

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

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

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

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

View File

@@ -88,7 +88,7 @@ export function applyRelaySetToPool(
} catch (error) {
// Suppress errors when closing relays that haven't fully connected yet
// This can happen when switching relay sets before connections establish
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
// Silently ignore
}
relayPool.relays.delete(url)
}

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

@@ -15,11 +15,10 @@ export interface Highlight {
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
// Relay tracking for offline/local-only highlights
// Relay tracking for local-only highlights
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
seenOnRelays?: string[] // URLs of relays where this event was fetched from
isLocalOnly?: boolean // true if only published to local relays
isOfflineCreated?: boolean // true if created while in flight mode (offline)
isSyncing?: boolean // true if currently being synced to remote relays
}

View File

@@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService'
import { KINDS } from '../config/kinds'
import { fallbackTitleFromUrl } from './readItemMerge'
import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer'
/**
* Derives ReadItems from bookmarks for external URLs:
* - Web bookmarks (kind:39701)
* - Any bookmark with http(s) URLs in content or urlReferences
*/
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
export async function deriveLinksFromBookmarks(bookmarks: Bookmark[]): Promise<ReadItem[]> {
const linksMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
@@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
}
}
// Sort by most recent bookmark activity
return Array.from(linksMap.values()).sort((a, b) => {
// Get initial items sorted by most recent bookmark activity
const initialItems = Array.from(linksMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0
return timeB - timeA
})
// Enhance with OpenGraph data
return await enhanceReadItemsWithOpenGraph(initialItems)
}