mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e1bc3419 | ||
|
|
4ec34a0379 | ||
|
|
aec2dcb75c | ||
|
|
5bdc435f5d | ||
|
|
db46edd39e | ||
|
|
c9739f804d | ||
|
|
eeb44e344f | ||
|
|
a6674610b8 | ||
|
|
6ae3decafb | ||
|
|
00da638e81 | ||
|
|
f04c0a401e | ||
|
|
f5e9f164f5 | ||
|
|
589ac17114 | ||
|
|
8d3510947c | ||
|
|
08a8f5623a | ||
|
|
e85ccdc7da | ||
|
|
d0e7f146fb | ||
|
|
efdb33eb31 | ||
|
|
0abbe62515 | ||
|
|
ab0972dd29 | ||
|
|
83fbb26e03 | ||
|
|
e8ce928ec6 | ||
|
|
1a01e14702 | ||
|
|
aab8176987 | ||
|
|
5a8b885d25 | ||
|
|
c129b24352 | ||
|
|
d98d750268 | ||
|
|
8262b2bf24 | ||
|
|
b99f36c0c5 | ||
|
|
dfe37a260e | ||
|
|
2a42f1de53 | ||
|
|
cea2d0eda2 | ||
|
|
ef05974a72 | ||
|
|
5a6ac628d2 | ||
|
|
826f07544e | ||
|
|
911215c0fb | ||
|
|
43ed41bfae | ||
|
|
81597fbb6d | ||
|
|
cc722c2599 | ||
|
|
c20682fbe8 | ||
|
|
cfa6dc4400 | ||
|
|
851cecf18c | ||
|
|
d4c67485a2 | ||
|
|
381fd05c90 | ||
|
|
60c4ef55c0 | ||
|
|
0b7891419b | ||
|
|
aeedc622b1 | ||
|
|
6f5b87136b | ||
|
|
1ac0c719a2 | ||
|
|
c9ce5442e0 | ||
|
|
c28052720e | ||
|
|
d0f942c495 | ||
|
|
907ef82efb | ||
|
|
415ff04345 | ||
|
|
683ea27526 | ||
|
|
fa052483b2 | ||
|
|
0ae9e6321e | ||
|
|
5623f2e595 | ||
|
|
2c94c1e3f0 | ||
|
|
19dc2f70f2 | ||
|
|
5013ccc552 | ||
|
|
29eed3395f | ||
|
|
d6da27c634 | ||
|
|
5551b52bce | ||
|
|
af7eb48893 | ||
|
|
51ce79f13a | ||
|
|
bcfc04c35c | ||
|
|
d6911b2acb | ||
|
|
2a869f11e0 | ||
|
|
deab9974fa | ||
|
|
49872337f3 | ||
|
|
389b4de0eb | ||
|
|
959ccac857 | ||
|
|
78c58693a5 | ||
|
|
ab81fe5030 | ||
|
|
6bae30070e | ||
|
|
1f6a904717 | ||
|
|
9379475d1c | ||
|
|
77a5f4bd2a | ||
|
|
4fa01231cd | ||
|
|
1cd85507a7 | ||
|
|
b6f151c711 | ||
|
|
e3d924f3fc | ||
|
|
5914df23d3 | ||
|
|
2083c2b8c6 | ||
|
|
35a8411d9b | ||
|
|
15b3b5b990 | ||
|
|
ad56acb712 | ||
|
|
2f5fe87fc8 | ||
|
|
d313c71e24 | ||
|
|
903b4a4ec1 | ||
|
|
a511b25b87 | ||
|
|
e920cf9477 | ||
|
|
708a1bfd54 | ||
|
|
51842f55bf | ||
|
|
52991f8e20 | ||
|
|
e3cd4454b4 | ||
|
|
78bc1f46dd | ||
|
|
c8cd1e6e66 | ||
|
|
5254697fe2 | ||
|
|
13462efaed | ||
|
|
1df00fbfda | ||
|
|
c2e220a1f2 | ||
|
|
00740aab6d | ||
|
|
e12d67cc5f | ||
|
|
e12aaa2b6c | ||
|
|
9880a9ae34 | ||
|
|
603db680f2 | ||
|
|
ae0471946e | ||
|
|
a48308d57d | ||
|
|
f67b358148 | ||
|
|
46a0a3da1f | ||
|
|
c92a620ea8 | ||
|
|
34de372509 | ||
|
|
a422084949 | ||
|
|
bd0e075984 | ||
|
|
38f4b69d48 | ||
|
|
9d1d944daf | ||
|
|
e56461cb12 | ||
|
|
f6b6747f09 | ||
|
|
180c26c47a | ||
|
|
78da0cb3e4 | ||
|
|
3d74c25c7d | ||
|
|
f46f55705b | ||
|
|
205591602d | ||
|
|
c6a42e0304 | ||
|
|
688d4285e3 | ||
|
|
9f806afc45 | ||
|
|
1282e778c6 | ||
|
|
6fd40f2ff6 | ||
|
|
6ac40c8a17 | ||
|
|
92145af2bb | ||
|
|
1ebaf7ccd2 | ||
|
|
5d22819ae3 | ||
|
|
6761b1861e | ||
|
|
1d989eae76 | ||
|
|
33d6e5882d | ||
|
|
0a62924b78 | ||
|
|
e2472606dd | ||
|
|
6f04b8f513 | ||
|
|
a8ad346c5d | ||
|
|
465c24ed3a | ||
|
|
04dea350a4 | ||
|
|
29c4bcb69b | ||
|
|
23ea7f352b | ||
|
|
36b35367f1 | ||
|
|
183463c817 | ||
|
|
7fb91e71f1 | ||
|
|
717f094984 | ||
|
|
c69e50d3bb | ||
|
|
4e4d719d94 | ||
|
|
d453a6439c | ||
|
|
5dfa6ba3ae | ||
|
|
f2d2883eee | ||
|
|
84001d1b83 | ||
|
|
b7a390cf89 | ||
|
|
59d9179642 | ||
|
|
68301cd20f | ||
|
|
4d6b7e1a46 | ||
|
|
95fe9b548f | ||
|
|
e86ae9f05e | ||
|
|
2124be83c3 | ||
|
|
a8bb17d4cd | ||
|
|
a886a68822 | ||
|
|
76bdbc670d | ||
|
|
c16ce1fc7e | ||
|
|
a578d67b1e | ||
|
|
25d1ead9f5 | ||
|
|
ae5ea66dd2 | ||
|
|
cf5f8fae16 |
336
CHANGELOG.md
336
CHANGELOG.md
@@ -7,6 +7,342 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.29] - 2025-11-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Full-width images setting now uses width instead of max-width
|
||||
- Change from --image-max-width CSS variable to --image-width
|
||||
- When enabled, sets images to width: 100% (enlarging small images)
|
||||
- Always constrains with max-width: 100% to prevent overflow
|
||||
- Update mobile responsive styles to respect the setting
|
||||
|
||||
## [0.10.28] - 2025-11-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nostr URI processing in markdown links
|
||||
- Prevent double-processing of markdown to avoid nested links
|
||||
- Add HTTP URL detection to prevent processing nostr URIs in URLs
|
||||
- Use parser-based approach to detect markdown link URLs
|
||||
- Prevent nostr URI replacement inside markdown link URLs
|
||||
- Remove unused variables in nostrUriResolver
|
||||
- Improved blog post rendering with proper link handling
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements from nostrUriResolver
|
||||
- Cleaned up debug logging used during troubleshooting
|
||||
- Cleaner console output in production
|
||||
|
||||
## [0.10.27] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh button to highlights sidebar header
|
||||
- Users can manually refresh highlights panel
|
||||
- Better control over highlights data updates
|
||||
- Image preloading in BlogPostCard for better caching
|
||||
- Images are preloaded when blog posts are displayed
|
||||
- Improved offline access to article images
|
||||
- Preload logged-in user profile image for offline access
|
||||
- User profile picture is cached for offline viewing
|
||||
- Better user experience when network is unavailable
|
||||
- Development Service Worker for testing image caching
|
||||
- Service Worker enabled in development mode
|
||||
- Improved testing capabilities for offline functionality
|
||||
|
||||
### Fixed
|
||||
|
||||
- Service Worker registration error handling
|
||||
- Better error handling for Service Worker registration failures
|
||||
- More robust development mode Service Worker support
|
||||
- Proper error handling for fetch requests in Service Worker
|
||||
- Article loading race conditions
|
||||
- Resolved race condition when loading articles from cache
|
||||
- Cache is checked synchronously before setting loading state
|
||||
- Articles are populated in cache from explore view
|
||||
- Image caching issues
|
||||
- Images are properly preloaded when loading articles from cache
|
||||
- Removed bulk image preloading to prevent resource exhaustion errors
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Scroll position management
|
||||
- Scroll position is reset when switching articles
|
||||
- Save suppression added when resetting scroll position
|
||||
- Reader content is cleared immediately when article changes
|
||||
- React hook ordering issues
|
||||
- useEffect moved before early return in BlogPostCard
|
||||
- Prevents React hooks dependency violations
|
||||
- TypeScript and linting issues
|
||||
- Cache save logic simplified to avoid TypeScript errors
|
||||
- Unused settings parameter marked as intentionally unused
|
||||
- Articles are saved to localStorage cache after loading from relays
|
||||
- Cache is saved immediately when first event is received
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid redundant image preload when using preview data
|
||||
- Prevents unnecessary image loading operations
|
||||
- Improved resource utilization
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Removed all debug console output from article cache and service worker
|
||||
- Removed debug logging from useImageCache hook
|
||||
- Cleaner console output in production
|
||||
- Unused refresh button from highlights panel header
|
||||
- Cleaned up unused UI component
|
||||
|
||||
## [0.10.26] - 2025-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Persist highlight metadata and offline events to localStorage
|
||||
- Highlights created offline are now preserved across sessions
|
||||
- Better data persistence for flight mode highlights
|
||||
- Proper relay response tracking for flight mode
|
||||
- Accurate detection of which relays have received highlight events
|
||||
- Improved flight mode indicator accuracy
|
||||
|
||||
### Changed
|
||||
|
||||
- Implemented proper flight mode detection for highlights
|
||||
- More reliable identification of highlights created offline
|
||||
- Better tracking of highlight publication status
|
||||
- Refactored to use isLocalOnly flag instead of isOfflineCreated
|
||||
- More consistent naming and clearer intent
|
||||
- Improved code maintainability
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show airplane icon for flight mode highlights
|
||||
- Visual indicator now correctly displays for offline-created highlights
|
||||
- Better user feedback for highlights pending publication
|
||||
- Prioritize isLocalOnly check to show airplane icon
|
||||
- Icon display logic now correctly identifies flight mode highlights
|
||||
- Preserve isLocalOnly and publishedRelays in eventToHighlight conversion
|
||||
- Highlight metadata is maintained during event transformations
|
||||
- Flight mode status preserved across data conversions
|
||||
- Use metadata cache to preserve highlight properties across EventStore
|
||||
- Highlights maintain their properties when stored in EventStore
|
||||
- Prevents loss of flight mode metadata
|
||||
- Add fallback logic for detecting flight mode highlights
|
||||
- More robust detection when primary methods fail
|
||||
- Better handling of edge cases
|
||||
- Determine isLocalOnly before publishing, not after
|
||||
- Correct timing for flight mode detection
|
||||
- Prevents incorrect status during highlight creation
|
||||
- Store event in EventStore after updating properties
|
||||
- Ensures all highlight properties are saved correctly
|
||||
- Prevents data loss during highlight creation
|
||||
- Manually set highlight properties after eventToHighlight conversion
|
||||
- Ensures all metadata is properly assigned
|
||||
- Correct property mapping during conversions
|
||||
- Prevent duplicate highlights
|
||||
- Eliminates duplicate highlight entries
|
||||
- Cleaner highlight list display
|
||||
- Publish only to connected relays to avoid long timeouts
|
||||
- Faster highlight publishing
|
||||
- Better user experience when some relays are unavailable
|
||||
- Prevent unnecessary relay queries when article content is cached
|
||||
- Improved performance by avoiding redundant network requests
|
||||
- Better resource utilization
|
||||
- Remove relayPool dependency from content loaders
|
||||
- Cleaner architecture and reduced coupling
|
||||
- Better separation of concerns
|
||||
- Check EventStore before setting loading state
|
||||
- Prevents unnecessary loading states
|
||||
- Better state management
|
||||
- Remove eventStore and setter functions from useEffect dependencies
|
||||
- Fixes React hook dependency issues
|
||||
- Prevents unnecessary re-renders
|
||||
- Replace require() with ES module imports
|
||||
- Modern JavaScript module system
|
||||
- Better compatibility with build tools
|
||||
- Resolve all linting errors and type issues
|
||||
- Improved code quality
|
||||
- Better type safety
|
||||
- Remove unused variables to resolve lint errors
|
||||
- Cleaner codebase
|
||||
- Eliminates lint warnings
|
||||
|
||||
### Performance
|
||||
|
||||
- Remove excessive debug logging for better performance
|
||||
- Reduced overhead from logging operations
|
||||
- Improved application performance
|
||||
|
||||
### Removed
|
||||
|
||||
- Debug console.log statements
|
||||
- Cleaner console output
|
||||
- Production-ready code
|
||||
|
||||
## [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
|
||||
|
||||
- Mobile-optimized tab interface for `/my` and `/p/` pages
|
||||
- Tab text labels hidden on mobile screens (≤768px) to save space
|
||||
- Shows only icons for highlights, bookmarks, reads, links, and writings tabs
|
||||
- Maintains full functionality while improving mobile UX
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated brand tagline from "Nostr Bookmarks" to "Read, Highlight, Explore"
|
||||
- Updated across HTML meta tags, PWA manifest, and Open Graph metadata
|
||||
- Better reflects core functionality: reading, highlighting, and exploring content
|
||||
- Reordered bookmarks bar navigation buttons
|
||||
- New order: Home, Explore, Settings (previously: Home, Settings, Explore)
|
||||
- Moved highlight button higher up on screen
|
||||
- Changed position from `bottom: 32px` to `bottom: 80px` for better accessibility
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile sidebar not closing when navigating to profile sections
|
||||
- Fixed issue where clicking "My Reads", "My Highlights", etc. didn't close mobile sidebar
|
||||
- Added mobile sidebar close logic to all navigation buttons
|
||||
- Improved mobile navigation experience
|
||||
- Removed unnecessary versioning from reading progress implementation
|
||||
- Simplified reading progress events by removing `ver` field
|
||||
- Updated cache key to remove version suffix
|
||||
- Cleaner, more maintainable code
|
||||
|
||||
## [0.10.21] - 2025-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position tracking for internal event URLs
|
||||
- Prevents tracking for `nostr-event:` sentinel URLs (internal convention, not a valid Nostr URI per NIP-21)
|
||||
- Fixes "String must be lowercase or uppercase" error when loading base64-encoded event URLs
|
||||
- These internal sentinels are no longer saved to reading position data
|
||||
- Compact bookmark view display
|
||||
- Articles now show title instead of summary in compact view
|
||||
- Extracts article title from NIP-23 metadata tags
|
||||
- Removed unused `articleSummary` prop to keep code DRY
|
||||
- Bookmark deduplication in profile view
|
||||
- Same article appearing in multiple bookmark lists/sets now displays only once
|
||||
- Uses coordinate-based deduplication (`kind:pubkey:identifier`) for articles
|
||||
- Applies `dedupeBookmarksById` when flattening bookmarks from different sources
|
||||
|
||||
## [0.10.20] - 2025-10-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# Tailwind CSS Migration Status
|
||||
|
||||
## ✅ Completed (Core Infrastructure)
|
||||
|
||||
### Phase 1: Setup & Foundation
|
||||
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||
- [x] Enable Tailwind preflight (CSS reset)
|
||||
|
||||
### Phase 2: Base Styles Reconciliation
|
||||
- [x] Add CSS variables for user-settable theme colors
|
||||
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||
- `--reading-font`, `--reading-font-size`
|
||||
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||
- [x] Remove redundant base styles handled by Tailwind
|
||||
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||
|
||||
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||
- [x] Switch from pane-scrolling to document-scrolling
|
||||
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||
- [x] Update `app.css` to remove fixed container heights
|
||||
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||
- [x] Fix reading position tracking to work with document scroll
|
||||
- [x] Maintain mobile overlay behavior
|
||||
|
||||
### Phase 4: Component Migrations
|
||||
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||
- Removed 80+ lines of CSS
|
||||
- Added shimmer animation to Tailwind config
|
||||
- Z-index layering maintained (1102)
|
||||
|
||||
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||
- Mobile hamburger button
|
||||
- Mobile highlights button
|
||||
- Mobile backdrop
|
||||
- Removed 60+ lines of CSS
|
||||
|
||||
- [x] **App Container**: Tailwind utilities
|
||||
- Responsive padding (p-0 md:p-4)
|
||||
- Min-height viewport support
|
||||
|
||||
## 📊 Impact & Metrics
|
||||
|
||||
### Lines of CSS Removed
|
||||
- `global.css`: ~50 lines removed
|
||||
- `reader.css`: ~80 lines removed (progress indicator)
|
||||
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||
- **Total**: ~190 lines removed
|
||||
|
||||
### Key Achievements
|
||||
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||
3. **No Breaking Changes**: All existing functionality preserved
|
||||
4. **Type Safety**: TypeScript checks passing
|
||||
5. **Lint Clean**: ESLint checks passing
|
||||
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||
|
||||
## 🔄 Remaining Work (Incremental)
|
||||
|
||||
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||
|
||||
### High-Value Components
|
||||
- [ ] **ContentPanel** - Large component, high impact
|
||||
- Reader header, meta info, loading states
|
||||
- Mark as read button
|
||||
- Article/video menus
|
||||
|
||||
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||
- Card layouts (compact/cards/large views)
|
||||
- Bookmark metadata display
|
||||
- Interactive states
|
||||
|
||||
- [ ] **HighlightsPanel** - Feature-rich
|
||||
- Header with toggles
|
||||
- Highlight items
|
||||
- Level-based styling
|
||||
|
||||
- [ ] **Settings Components** - Forms & controls
|
||||
- Color pickers
|
||||
- Font selectors
|
||||
- Toggle switches
|
||||
- Sliders
|
||||
|
||||
### CSS Files to Prune
|
||||
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||
- `src/styles/components/cards.css` - Bookmark card styles
|
||||
- `src/styles/components/modals.css` - Modal dialogs
|
||||
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||
|
||||
## 🎯 Migration Strategy
|
||||
|
||||
### For New Components
|
||||
Use Tailwind utilities from the start. Reference:
|
||||
```tsx
|
||||
// Good: Tailwind utilities
|
||||
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||
|
||||
// Avoid: New CSS classes
|
||||
<div className="custom-component">
|
||||
```
|
||||
|
||||
### For Existing Components
|
||||
Migrate incrementally when touching files:
|
||||
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||
2. Replace color/background utilities
|
||||
3. Replace typography utilities
|
||||
4. Replace responsive variants
|
||||
5. Remove old CSS rules
|
||||
6. Keep file under 210 lines
|
||||
|
||||
### CSS Variable Usage
|
||||
Dynamic values should still use CSS variables or inline styles:
|
||||
```tsx
|
||||
// User-settable colors
|
||||
style={{ backgroundColor: settings.highlightColorMine }}
|
||||
|
||||
// Or reference CSS variable
|
||||
className="bg-[var(--highlight-color-mine)]"
|
||||
```
|
||||
|
||||
## 📝 Technical Notes
|
||||
|
||||
### Z-Index Layering
|
||||
- Mobile sidepanes: `z-[1001]`
|
||||
- Mobile backdrop: `z-[999]`
|
||||
- Progress indicator: `z-[1102]`
|
||||
- Mobile buttons: `z-[900]`
|
||||
- Relay status: `z-[999]`
|
||||
- Modals: `z-[10000]`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Mobile: `< 768px`
|
||||
- Tablet: `768px - 1024px`
|
||||
- Desktop: `> 1024px`
|
||||
|
||||
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||
|
||||
### Safe Area Insets
|
||||
Mobile notch support:
|
||||
```tsx
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||
}}
|
||||
```
|
||||
|
||||
### Custom Animations
|
||||
Add to `tailwind.config.js`:
|
||||
```js
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
- [x] Tailwind CSS fully integrated and functional
|
||||
- [x] Document scrolling working correctly
|
||||
- [x] Reading position tracking accurate
|
||||
- [x] Progress indicator always visible
|
||||
- [x] No TypeScript errors
|
||||
- [x] No linting errors
|
||||
- [x] Mobile responsiveness maintained
|
||||
- [x] Theme colors (user settings) working
|
||||
- [x] All existing features functional
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Ship It**: Current state is production-ready
|
||||
2. **Incremental Migration**: Convert components as you touch them
|
||||
3. **Monitor**: Watch for any CSS conflicts
|
||||
4. **Cleanup**: Eventually remove unused CSS files
|
||||
5. **Document**: Update component docs with Tailwind patterns
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||
**Date**: 2025-01-14
|
||||
**Commits**: 8 conventional commits
|
||||
**Lines Removed**: ~190 lines of CSS
|
||||
**Breaking Changes**: None
|
||||
|
||||
@@ -147,7 +147,7 @@ function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||
const baseUrl = 'https://read.withboris.com'
|
||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||
|
||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||
const author = meta?.author || 'Boris'
|
||||
|
||||
@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
description: data.description || '',
|
||||
thumbnail_url: data.thumbnail_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
try {
|
||||
if (videoInfo.source === 'youtube') {
|
||||
// YouTube handling
|
||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||
const title = ''
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
thumbnail_url,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
|
||||
@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||
// In a real implementation, you might want to use YouTube's API or other methods
|
||||
const title = '' // Will be populated from captions or other sources
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<title>Boris - Read, Highlight, Explore</title>
|
||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.21",
|
||||
"version": "0.10.30",
|
||||
"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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"name": "Boris - Read, Highlight, Explore",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
|
||||
47
public/sw-dev.js
Normal file
47
public/sw-dev.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Development Service Worker - simplified version for testing image caching
|
||||
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim())
|
||||
})
|
||||
|
||||
// Image caching - simple version for dev testing
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
const isImage = event.request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
|
||||
if (isImage) {
|
||||
event.respondWith(
|
||||
caches.open('boris-images-dev').then((cache) => {
|
||||
return cache.match(event.request).then((cachedResponse) => {
|
||||
// Try to fetch from network
|
||||
return fetch(event.request).then((response) => {
|
||||
// If fetch succeeds, cache it and return
|
||||
if (response.ok) {
|
||||
cache.put(event.request, response.clone()).catch(() => {
|
||||
// Ignore cache put errors
|
||||
})
|
||||
}
|
||||
return response
|
||||
}).catch((error) => {
|
||||
// If fetch fails (network error, CORS, etc.), return cached response if available
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
// No cache available, reject the promise so browser handles it
|
||||
return Promise.reject(error)
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
// If cache operations fail, try to fetch directly without caching
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
const npub = nip19.npubEncode(authorPubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
navigate(`/p/${npub}/writings`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ interface BlogPostCardProps {
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
|
||||
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
|
||||
// when they come into view. The Service Worker will cache them automatically.
|
||||
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
|
||||
// when there are many blog posts.
|
||||
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||
@@ -41,7 +47,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||
}
|
||||
|
||||
|
||||
// Debug log - reading progress shown as visual indicator
|
||||
if (readingProgress !== undefined) {
|
||||
// Reading progress display
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -105,7 +105,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
// For web bookmarks and other types, try to use URL if available
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (bookmark.kind === 39701) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
// Ensure URL has protocol
|
||||
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||
return readingProgressMap.get(url)
|
||||
}
|
||||
}
|
||||
|
||||
// For other bookmark types, try to extract URL from content
|
||||
const urls = extractUrlsFromContent(bookmark.content)
|
||||
if (urls.length > 0) {
|
||||
return readingProgressMap.get(urls[0])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -93,6 +100,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||
|
||||
// Reset scroll to top when navigating to profile routes
|
||||
useEffect(() => {
|
||||
if (showProfile) {
|
||||
// Reset scroll position when navigating to profile pages
|
||||
// Use requestAnimationFrame to ensure it happens after DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
}
|
||||
}, [location.pathname, showProfile])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -222,13 +240,26 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => {
|
||||
// Deduplicate by checking if highlight with this ID already exists
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) {
|
||||
return prev // Don't add duplicate
|
||||
}
|
||||
return [highlight, ...prev]
|
||||
}),
|
||||
settings
|
||||
})
|
||||
|
||||
// Determine which loader should be active based on route
|
||||
// Only one loader should run at a time to prevent state conflicts
|
||||
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
|
||||
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
|
||||
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
naddr: shouldLoadArticle ? naddr : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
@@ -245,7 +276,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
url: shouldLoadExternal ? externalUrl : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
@@ -260,7 +291,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
|
||||
// Load event if /e/:eventId route is used
|
||||
useEventLoader({
|
||||
eventId,
|
||||
eventId: shouldLoadEvent ? eventId : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
@@ -34,9 +33,7 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||
import { archiveController } from '../services/archiveController'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { shouldTrackReadingProgress } from '../utils/helpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
@@ -111,15 +108,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
@@ -270,6 +263,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||
|
||||
// Reset scroll position and restore ref when article changes
|
||||
useEffect(() => {
|
||||
if (!articleIdentifier) return
|
||||
|
||||
// Suppress saves during navigation to prevent saving 0% position
|
||||
// The 500ms suppression covers the scroll reset and initial render
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(500)
|
||||
}
|
||||
|
||||
// Reset scroll to top when article identifier changes
|
||||
// This prevents showing wrong scroll position from previous article
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
// Reset restore attempt tracking for new article
|
||||
hasAttemptedRestoreRef.current = null
|
||||
}, [articleIdentifier])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
return
|
||||
@@ -343,21 +353,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
if (showArticleMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
// Check available space and position menu upward if needed
|
||||
useEffect(() => {
|
||||
@@ -380,13 +387,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (showArticleMenu) {
|
||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||
}
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
if (showExternalMenu) {
|
||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
@@ -418,34 +422,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
// Load YouTube metadata/captions when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(selectedUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [selectedUrl])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
|
||||
// Get article links for menu
|
||||
@@ -483,7 +461,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
@@ -571,46 +548,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
if (!selectedUrl) return
|
||||
const native = buildNativeVideoUrl(selectedUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = selectedUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
@@ -808,13 +745,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
@@ -854,11 +784,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={ytMeta?.title || title}
|
||||
title={title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
@@ -871,86 +801,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||
</div>
|
||||
)}
|
||||
{isExternalVideo ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={selectedUrl as string}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
{ytMeta?.description && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{ytMeta.description}
|
||||
</div>
|
||||
)}
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{loading || !markdown && !html ? (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
@@ -959,16 +813,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -976,7 +828,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
@@ -984,7 +836,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
{!isNostrArticle && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
@@ -1135,11 +987,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -82,11 +82,28 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
|
||||
|
||||
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
// Visibility filters - load from localStorage first, fallback to settings
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||
// Try to load from localStorage first
|
||||
try {
|
||||
const saved = localStorage.getItem('exploreScopeVisibility')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
// Validate that at least one scope is enabled
|
||||
if (parsed.nostrverse || parsed.friends || parsed.mine) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load explore scope from localStorage:', err)
|
||||
}
|
||||
|
||||
// Fallback to settings or defaults
|
||||
return {
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure at least one scope remains active
|
||||
@@ -96,6 +113,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||
return prev // ignore toggle that would disable all scopes
|
||||
}
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
@@ -224,18 +247,44 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
// Update visibility when settings/login state changes
|
||||
useEffect(() => {
|
||||
// Check if user has a saved preference
|
||||
const hasSavedPreference = (() => {
|
||||
try {
|
||||
return localStorage.getItem('exploreScopeVisibility') !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
// Only reset to defaults if no saved preference exists
|
||||
if (hasSavedPreference) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeAccount) {
|
||||
// When logged out, show nostrverse by default
|
||||
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
} else {
|
||||
// When logged in, use settings defaults immediately
|
||||
setVisibility({
|
||||
const defaultVisibility = {
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
}
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(false)
|
||||
setHasLoadedNostrverseHighlights(false)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
bottom: '80px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -80,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
<img
|
||||
src={cachedImage}
|
||||
alt={title || 'Article image'}
|
||||
onError={(e) => {
|
||||
console.error('[reader-header] Image failed to load:', cachedImage, e)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
|
||||
58
src/components/ReadingProgressBar.tsx
Normal file
58
src/components/ReadingProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import IconButton from './IconButton'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { preloadImage } from '../hooks/useImageCache'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -36,6 +37,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
// Preload profile image for offline access
|
||||
useEffect(() => {
|
||||
if (profileImage) {
|
||||
preloadImage(profileImage)
|
||||
}
|
||||
}, [profileImage])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -55,95 +63,116 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
// Close mobile sidebar when navigating on mobile
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
{activeAccount && (
|
||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
<div className="sidebar-header-left">
|
||||
{activeAccount && (
|
||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</button>
|
||||
{showProfileMenu && (
|
||||
<div className="profile-dropdown-menu">
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>My Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span>My Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span>My Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span>My Links</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span>My Writings</span>
|
||||
</button>
|
||||
<div className="profile-menu-separator"></div>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(onLogout)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{showProfileMenu && (
|
||||
<div className="profile-dropdown-menu">
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>My Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span>My Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span>My Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span>My Links</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span>My Writings</span>
|
||||
</button>
|
||||
<div className="profile-menu-separator"></div>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(onLogout)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/')
|
||||
}}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
icon={faPersonHiking}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/explore')
|
||||
}}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faPersonHiking}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
icon={faGear}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
onOpenSettings()
|
||||
}}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{!isMobile && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import VideoView from './VideoView'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -321,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
@@ -373,47 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.support}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : (() => {
|
||||
// Determine if this is a video URL
|
||||
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
|
||||
|
||||
if (isExternalVideo) {
|
||||
return (
|
||||
<VideoView
|
||||
videoUrl={props.selectedUrl!}
|
||||
title={props.readerContent?.title}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
|
||||
320
src/components/VideoView.tsx
Normal file
320
src/components/VideoView.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { getYouTubeThumbnail } from '../utils/imagePreview'
|
||||
|
||||
// Helper function to get Vimeo thumbnail
|
||||
const getVimeoThumbnail = (url: string): string | null => {
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (!vimeoMatch) return null
|
||||
|
||||
const videoId = vimeoMatch[1]
|
||||
return `https://vumbnail.com/${videoId}.jpg`
|
||||
}
|
||||
import {
|
||||
createWebsiteReaction,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import { unarchiveWebsite } from '../services/unarchiveService'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
|
||||
interface VideoViewProps {
|
||||
videoUrl: string
|
||||
title?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const VideoView: React.FC<VideoViewProps> = ({
|
||||
videoUrl,
|
||||
title,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
|
||||
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load YouTube metadata when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!videoUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(videoUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [videoUrl])
|
||||
|
||||
// Check if video is marked as watched
|
||||
useEffect(() => {
|
||||
const checkWatchedStatus = async () => {
|
||||
if (!activeAccount || !videoUrl) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
try {
|
||||
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
|
||||
setIsMarkedAsWatched(isWatched)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkWatchedStatus()
|
||||
}, [activeAccount, videoUrl, relayPool])
|
||||
|
||||
// Handle click outside to close menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
// Check menu position for upward opening
|
||||
useEffect(() => {
|
||||
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
|
||||
if (!menuRef.current) return
|
||||
|
||||
const rect = menuRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// Open upward if there's more space above and less space below
|
||||
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
const handleMarkAsWatched = async () => {
|
||||
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
try {
|
||||
if (isMarkedAsWatched) {
|
||||
// Unmark as watched
|
||||
if (relayPool) {
|
||||
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(false)
|
||||
} else {
|
||||
// Mark as watched
|
||||
if (relayPool) {
|
||||
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
setTimeout(() => setShowCheckAnimation(false), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenVideoExternal = () => {
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
const native = buildNativeVideoUrl(videoUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = videoUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: ytMeta?.title || title || 'Video',
|
||||
url: videoUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const displayTitle = ytMeta?.title || title
|
||||
const displaySummary = ytMeta?.description || summary
|
||||
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
|
||||
|
||||
// Get video thumbnail for cover image
|
||||
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
|
||||
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
|
||||
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
|
||||
const displayImage = videoThumbnail || image
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReaderHeader
|
||||
title={displayTitle}
|
||||
image={displayImage}
|
||||
summary={displaySummary}
|
||||
published={published}
|
||||
readingTimeText={durationText}
|
||||
hasHighlights={false}
|
||||
highlightCount={0}
|
||||
settings={settings}
|
||||
highlights={[]}
|
||||
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displaySummary && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{displaySummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsWatched}
|
||||
disabled={isCheckingWatchedStatus}
|
||||
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
|
||||
/>
|
||||
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoView
|
||||
@@ -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'
|
||||
@@ -6,12 +6,14 @@ import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from '../services/dataFetch'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { preloadImage } from './useImageCache'
|
||||
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,30 +66,238 @@ 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
|
||||
|
||||
if (!relayPool || !naddr) return
|
||||
// First check: naddr is required
|
||||
if (!naddr) {
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear readerContent immediately to prevent showing stale content from previous article
|
||||
// This ensures images from previous articles don't flash briefly
|
||||
setReaderContent(undefined)
|
||||
|
||||
// Synchronously check cache sources BEFORE checking relayPool
|
||||
// This prevents showing loading skeletons when content is immediately available
|
||||
// and fixes the race condition where relayPool isn't ready yet
|
||||
let foundInCache = false
|
||||
try {
|
||||
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||
const cachedArticle = getFromCache(naddr)
|
||||
if (cachedArticle) {
|
||||
foundInCache = true
|
||||
const title = cachedArticle.title || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: cachedArticle.markdown,
|
||||
image: cachedArticle.image,
|
||||
summary: cachedArticle.summary,
|
||||
published: cachedArticle.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(cachedArticle.event.id)
|
||||
setCurrentArticle?.(cachedArticle.event)
|
||||
setReaderLoading(false)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Preload image if available to ensure it's cached by Service Worker
|
||||
// This ensures images are available when offline
|
||||
if (cachedArticle.image) {
|
||||
preloadImage(cachedArticle.image)
|
||||
}
|
||||
|
||||
// Store in EventStore for future lookups
|
||||
if (eventStore) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventStore.add?.(cachedArticle.event as unknown as any)
|
||||
} catch {
|
||||
// Silently ignore store errors
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights in background (don't block UI)
|
||||
// Only fetch highlights if relayPool is available
|
||||
if (mountedRef.current && relayPool) {
|
||||
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||
const eventId = cachedArticle.event.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have cached content, no need to query relays
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If cache check fails, fall through to async loading
|
||||
console.warn('[article-loader] Cache check failed:', err)
|
||||
}
|
||||
|
||||
// Check EventStore synchronously (also doesn't need relayPool)
|
||||
let foundInEventStore = false
|
||||
if (eventStore && !foundInCache) {
|
||||
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) {
|
||||
foundInEventStore = 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)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Fetch highlights in background if relayPool is available
|
||||
if (relayPool) {
|
||||
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||
const eventId = storedEvent.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have EventStore content, no need to query relays yet
|
||||
// But we might want to fetch from relays in background if relayPool becomes available
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
console.warn('[article-loader] EventStore check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only return early if we have no content AND no relayPool to fetch from
|
||||
if (!relayPool && !foundInCache && !foundInEventStore) {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have relayPool, proceed with async loading
|
||||
if (!relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
// Don't clear highlights yet - let the smart filtering logic handle it
|
||||
// when we know the article coordinate
|
||||
setHighlightsLoading(false) // Don't show loading yet
|
||||
|
||||
// Note: Cache and EventStore were already checked synchronously above
|
||||
// This async function only runs if we need to fetch from relays
|
||||
|
||||
// At this point, we've checked EventStore and cache - neither had content
|
||||
// Only show loading skeleton if we also don't have preview data
|
||||
if (previewData) {
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
setCurrentTitle(previewData.title)
|
||||
setReaderContent({
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from store or relay
|
||||
markdown: '', // Will be loaded from relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
|
||||
// Don't preload image here - it should already be cached from BlogPostCard
|
||||
// Preloading again would be redundant and could cause unnecessary network requests
|
||||
} else {
|
||||
// No cache, no EventStore, no preview data - need to load from relays
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
}
|
||||
@@ -108,43 +318,15 @@ 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) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (currentRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store in event store for future local reads
|
||||
try {
|
||||
@@ -166,6 +348,8 @@ export function useArticleLoader({
|
||||
const image = Helpers.getArticleImage(evt)
|
||||
const summary = Helpers.getArticleSummary(evt)
|
||||
const published = Helpers.getArticlePublished(evt)
|
||||
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: evt.content,
|
||||
@@ -180,11 +364,31 @@ export function useArticleLoader({
|
||||
setCurrentArticleEventId(evt.id)
|
||||
setCurrentArticle?.(evt)
|
||||
setReaderLoading(false)
|
||||
|
||||
// Save to cache immediately when we get the first event
|
||||
// Don't wait for queryEvents to complete in case it hangs
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: evt.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: evt.pubkey,
|
||||
event: evt
|
||||
}
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
|
||||
// Preload image to ensure it's cached by Service Worker
|
||||
if (image) {
|
||||
preloadImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize with newest version if it's newer than what we first rendered
|
||||
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||
@@ -193,6 +397,8 @@ export function useArticleLoader({
|
||||
const image = Helpers.getArticleImage(finalEvent)
|
||||
const summary = Helpers.getArticleSummary(finalEvent)
|
||||
const published = Helpers.getArticlePublished(finalEvent)
|
||||
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
@@ -207,10 +413,28 @@ export function useArticleLoader({
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(finalEvent.id)
|
||||
setCurrentArticle?.(finalEvent)
|
||||
|
||||
// Save to cache for future loads (if we haven't already saved from first event)
|
||||
// Only save if this is a different/newer event than what we first rendered
|
||||
// Note: We already saved from first event, so only save if this is different
|
||||
if (!firstEmitted) {
|
||||
// First event wasn't emitted, so save now
|
||||
const articleContent = {
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
author: finalEvent.pubkey,
|
||||
event: finalEvent
|
||||
}
|
||||
saveToCache(naddr, articleContent)
|
||||
}
|
||||
} else {
|
||||
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
setCurrentTitle(article.title)
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
@@ -237,7 +461,13 @@ export function useArticleLoader({
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
// Clear highlights that don't belong to this article coordinate
|
||||
setHighlights((prev) => {
|
||||
return prev.filter(h => {
|
||||
// Keep highlights that match this article coordinate or event ID
|
||||
return h.eventReference === coord || h.eventReference === eventId
|
||||
})
|
||||
})
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
@@ -251,7 +481,9 @@ export function useArticleLoader({
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settingsRef.current
|
||||
settingsRef.current,
|
||||
false, // force
|
||||
eventStore || undefined
|
||||
)
|
||||
} else {
|
||||
// No article event to fetch highlights for - clear and don't show loading
|
||||
@@ -283,19 +515,13 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||
// This fixes the race condition where articles don't load on direct navigation
|
||||
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
previewData,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
relayPool
|
||||
])
|
||||
}
|
||||
|
||||
35
src/hooks/useDocumentTitle.ts
Normal file
35
src/hooks/useDocumentTitle.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { normalizeUrl } from '../utils/urlHelpers'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface UseFilteredHighlightsParams {
|
||||
highlights: Highlight[]
|
||||
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
|
||||
|
||||
let urlFiltered = highlights
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
if (!selectedUrl.startsWith('nostr:')) {
|
||||
// Filter highlights based on URL type
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
// For Nostr articles, extract the article coordinate and filter by eventReference
|
||||
try {
|
||||
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
// Keep highlights that match this article coordinate
|
||||
return h.eventReference === articleCoordinate
|
||||
})
|
||||
} else {
|
||||
// Not a valid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} catch {
|
||||
// Invalid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} else {
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useImageCache(
|
||||
imageUrl: string | undefined
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
// The Service Worker will intercept fetch requests and cache them
|
||||
// Make sure images use standard <img src> tags for SW interception
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
|
||||
void imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an image URL to ensure it's cached by the Service Worker
|
||||
* This is useful when loading content from cache - we want to ensure
|
||||
* images are cached before going offline
|
||||
*/
|
||||
export function preloadImage(imageUrl: string | undefined): void {
|
||||
if (!imageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a link element with rel=prefetch or use Image object to trigger fetch
|
||||
// Service Worker will intercept and cache the request
|
||||
const img = new Image()
|
||||
img.src = imageUrl
|
||||
|
||||
// Also try using fetch to explicitly trigger Service Worker
|
||||
// This ensures the image is cached even if <img> tag hasn't rendered yet
|
||||
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
|
||||
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
|
||||
// The Image() approach above will work for most cases
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
// Set image max-width based on full-width setting
|
||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||
// Set image width and max-height based on full-width setting
|
||||
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
|
||||
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
|
||||
|
||||
}
|
||||
|
||||
|
||||
75
src/main.tsx
75
src/main.tsx
@@ -5,16 +5,60 @@ import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality (production only)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
// Register Service Worker for PWA functionality
|
||||
// With injectRegister: null, we need to register manually
|
||||
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
const swPath = '/sw.js'
|
||||
|
||||
// Check if already registered/active first
|
||||
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
|
||||
if (registrations.length > 0) {
|
||||
return registrations[0]
|
||||
}
|
||||
|
||||
// Not registered yet, try to register
|
||||
// In dev mode, use the dev Service Worker for testing
|
||||
if (import.meta.env.DEV) {
|
||||
const devSwPath = '/sw-dev.js'
|
||||
try {
|
||||
// Check if dev SW exists
|
||||
const response = await fetch(devSwPath)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
|
||||
|
||||
if (response.ok && isJavaScript) {
|
||||
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
|
||||
} else {
|
||||
console.warn('[sw-registration] Development Service Worker not available')
|
||||
return null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sw-registration] Could not load development Service Worker:', err)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// In production, just register directly
|
||||
return await navigator.serviceWorker.register(swPath)
|
||||
}
|
||||
})
|
||||
.then(registration => {
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
if (!registration) return
|
||||
|
||||
// Wait for Service Worker to activate
|
||||
if (registration.installing) {
|
||||
registration.installing.addEventListener('statechange', () => {
|
||||
// Service Worker state changed
|
||||
})
|
||||
}
|
||||
|
||||
// Check for updates periodically (production only)
|
||||
if (import.meta.env.PROD) {
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
}
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
@@ -31,9 +75,22 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
|
||||
console.error('[sw-registration] Error details:', {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
|
||||
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
|
||||
return `${CACHE_PREFIX}${naddr}`
|
||||
}
|
||||
|
||||
function getFromCache(naddr: string): ArticleContent | null {
|
||||
export function getFromCache(naddr: string): ArticleContent | null {
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
if (!cached) return null
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||
const age = Date.now() - timestamp
|
||||
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
|
||||
}
|
||||
|
||||
return content
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Silently handle cache read errors
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
/**
|
||||
* Caches an article event to localStorage for offline access
|
||||
* @param event - The Nostr event to cache
|
||||
* @param settings - Optional user settings
|
||||
*/
|
||||
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||
try {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (!dTag || event.kind !== 30023) return
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
const articleContent: ArticleContent = {
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
markdown: event.content,
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
summary: getArticleSummary(event),
|
||||
author: event.pubkey,
|
||||
event
|
||||
}
|
||||
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
} catch (err) {
|
||||
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||
// Future: could add explicit enableArticleCache setting
|
||||
// For now, we cache aggressively but handle errors gracefully
|
||||
// Note: settings parameter reserved for future use
|
||||
void settings // Mark as intentionally unused for now
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached: CachedArticle = {
|
||||
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
// Silently fail - don't block the UI if caching fails
|
||||
// Handles quota exceeded, invalid data, and other errors gracefully
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +205,7 @@ export async function fetchArticleByNaddr(
|
||||
}
|
||||
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
saveToCache(naddr, content, settings)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const fetchHighlightsFromAuthors = async (
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
||||
{ kinds: [9802], authors: pubkeys, limit: 1000 },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Service Worker automatically caches images on fetch
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
// Must match the cache name in src/sw.ts
|
||||
const CACHE_NAME = 'boris-images'
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
|
||||
@@ -81,13 +81,21 @@ class NostrverseHighlightsController {
|
||||
const currentGeneration = this.generation
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
// Start with existing highlights when doing incremental sync
|
||||
const highlightsMap = new Map<string, Highlight>(
|
||||
this.currentHighlights.map(h => [h.id, h])
|
||||
)
|
||||
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||
const filter: { kinds: number[]; since?: number; limit?: number } = { kinds: [KINDS.Highlights] }
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
} else {
|
||||
// On initial load, fetch more highlights
|
||||
filter.limit = 1000
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
@@ -111,22 +119,24 @@ class NostrverseHighlightsController {
|
||||
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
const highlights = events.map(eventToHighlight)
|
||||
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
|
||||
const sorted = sortHighlights(unique)
|
||||
const highlights = events.map(eventToHighlight)
|
||||
// Merge new highlights with existing ones
|
||||
highlights.forEach(h => highlightsMap.set(h.id, h))
|
||||
const sorted = sortHighlights(Array.from(highlightsMap.values()))
|
||||
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
this.currentHighlights = sorted
|
||||
this.loaded = true
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const newest = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(newest)
|
||||
}
|
||||
} catch (err) {
|
||||
this.currentHighlights = []
|
||||
// On error, keep existing highlights instead of clearing them
|
||||
console.error('[nostrverse-highlights] Failed to sync:', err)
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
} finally {
|
||||
if (currentGeneration === this.generation) this.setLoading(false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
114
src/services/opengraphEnhancer.ts
Normal file
114
src/services/opengraphEnhancer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,10 @@ export const fetchProfiles = async (
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
|
||||
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
|
||||
// Profile images will be cached by Service Worker when they're actually displayed.
|
||||
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
await rebroadcastEvents(profiles, relayPool, settings)
|
||||
|
||||
@@ -110,3 +110,4 @@ export async function fetchReadableContent(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface ReadingProgressContent {
|
||||
progress: number // 0-1 scroll progress
|
||||
ts?: number // Unix timestamp (optional, for display)
|
||||
loc?: number // Optional: pixel position
|
||||
ver?: string // Schema version
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading progress from event (kind 39802)
|
||||
@@ -117,8 +116,7 @@ export async function saveReadingPosition(
|
||||
const progressContent: ReadingProgressContent = {
|
||||
progress: position.position,
|
||||
ts: position.timestamp,
|
||||
loc: position.scrollTop,
|
||||
ver: '1'
|
||||
loc: position.scrollTop
|
||||
}
|
||||
|
||||
const tags = generateProgressTags(articleIdentifier)
|
||||
|
||||
@@ -13,7 +13,7 @@ type ProgressMapCallback = (progressMap: Map<string, number>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
|
||||
const PROGRESS_CACHE_KEY = 'reading_progress_cache'
|
||||
|
||||
/**
|
||||
* Shared reading progress controller
|
||||
|
||||
@@ -81,7 +81,15 @@ export function applyRelaySetToPool(
|
||||
for (const url of toRemove) {
|
||||
const relay = relayPool.relays.get(url)
|
||||
if (relay) {
|
||||
relay.close()
|
||||
try {
|
||||
// Only close if relay is actually connected or attempting to connect
|
||||
// This helps avoid WebSocket warnings for connections that never started
|
||||
relay.close()
|
||||
} catch (error) {
|
||||
// Suppress errors when closing relays that haven't fully connected yet
|
||||
// This can happen when switching relay sets before connections establish
|
||||
// Silently ignore
|
||||
}
|
||||
relayPool.relays.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* Hide tab labels on mobile to save space */
|
||||
@media (max-width: 768px) {
|
||||
.me-tab .tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
padding: 0.75rem;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks list */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
|
||||
@@ -57,13 +57,14 @@
|
||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img {
|
||||
max-width: var(--image-max-width, 100%);
|
||||
max-height: 70vh;
|
||||
width: var(--image-width, auto);
|
||||
max-width: 100%;
|
||||
max-height: var(--image-max-height, 70vh);
|
||||
height: auto;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
/* Headlines with Tailwind typography */
|
||||
.reader-markdown h1, .reader-html h1 {
|
||||
@@ -192,9 +193,11 @@
|
||||
}
|
||||
|
||||
.reader-markdown img, .reader-html img {
|
||||
width: var(--image-width, auto) !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
max-height: var(--image-max-height, 70vh) !important;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
13
src/sw.ts
13
src/sw.ts
@@ -23,13 +23,15 @@ sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
// Runtime cache: All images (cross-origin and same-origin)
|
||||
// Cache both external images and any internal image assets
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isImage = request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
return isImage && url.origin !== sw.location.origin
|
||||
// Cache all images, not just cross-origin ones
|
||||
// This ensures article images from any source get cached
|
||||
return isImage
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-images',
|
||||
@@ -41,6 +43,11 @@ registerRoute(
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
{
|
||||
cacheWillUpdate: async ({ response }) => {
|
||||
return response.ok ? response : null
|
||||
}
|
||||
}
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,17 +107,123 @@ export function getNostrUriLabel(encoded: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process markdown to replace nostr URIs while skipping those inside markdown links
|
||||
* This prevents nested markdown link issues when nostr identifiers appear in URLs
|
||||
*/
|
||||
function replaceNostrUrisSafely(
|
||||
markdown: string,
|
||||
getReplacement: (encoded: string) => string
|
||||
): string {
|
||||
// Track positions where we're inside a markdown link URL
|
||||
// Use a parser approach to correctly handle URLs with brackets/parentheses
|
||||
const linkRanges: Array<{ start: number, end: number }> = []
|
||||
|
||||
// Find all markdown link URLs by looking for ]( pattern and tracking to matching )
|
||||
let i = 0
|
||||
while (i < markdown.length) {
|
||||
// Look for ]( pattern that starts a markdown link URL
|
||||
const urlStartMatch = markdown.indexOf('](', i)
|
||||
if (urlStartMatch === -1) break
|
||||
|
||||
const urlStart = urlStartMatch + 2 // Position after "]("
|
||||
|
||||
// Now find the matching closing parenthesis
|
||||
// We need to account for nested parentheses and escaped characters
|
||||
let pos = urlStart
|
||||
let depth = 1 // We're inside one set of parentheses
|
||||
let urlEnd = -1
|
||||
|
||||
while (pos < markdown.length && depth > 0) {
|
||||
const char = markdown[pos]
|
||||
const nextChar = pos + 1 < markdown.length ? markdown[pos + 1] : ''
|
||||
|
||||
// Check for escaped characters
|
||||
if (char === '\\' && nextChar) {
|
||||
pos += 2 // Skip escaped character
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
depth++
|
||||
} else if (char === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
urlEnd = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pos++
|
||||
}
|
||||
|
||||
if (urlEnd !== -1) {
|
||||
linkRanges.push({
|
||||
start: urlStart,
|
||||
end: urlEnd
|
||||
})
|
||||
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
// No matching closing paren found, skip this one
|
||||
i = urlStart + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a position is inside any markdown link URL
|
||||
const isInsideLinkUrl = (pos: number): boolean => {
|
||||
return linkRanges.some(range => pos >= range.start && pos < range.end)
|
||||
}
|
||||
|
||||
// Replace nostr URIs, but skip those inside link URLs
|
||||
// Also check if nostr URI is part of any URL pattern (http/https URLs)
|
||||
// Callback params: (match, encoded, type, offset, string)
|
||||
const result = markdown.replace(NOSTR_URI_REGEX, (match, encoded, _type, offset, fullString) => {
|
||||
const matchEnd = offset + match.length
|
||||
|
||||
// Check if this match is inside a markdown link URL
|
||||
// Check both start and end positions to ensure we catch the whole match
|
||||
const startInside = isInsideLinkUrl(offset)
|
||||
const endInside = isInsideLinkUrl(matchEnd - 1) // Check end position
|
||||
|
||||
if (startInside || endInside) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// Also check if the nostr URI is part of an HTTP/HTTPS URL pattern
|
||||
// This catches cases where the source markdown has URLs like https://example.com/naddr1...
|
||||
// before they're formatted as markdown links
|
||||
const contextBefore = fullString.slice(Math.max(0, offset - 200), offset)
|
||||
const contextAfter = fullString.slice(matchEnd, Math.min(fullString.length, matchEnd + 10))
|
||||
|
||||
// Check if we're inside an http/https URL (looking for https?:// pattern before the match)
|
||||
// and the match is followed by valid URL characters (not whitespace or closing paren)
|
||||
const urlPatternBefore = /https?:\/\/[^\s)]*$/i
|
||||
const isInHttpUrl = urlPatternBefore.test(contextBefore)
|
||||
const isValidUrlContinuation = !contextAfter.match(/^[\s)]/) // Not followed by space or closing paren
|
||||
|
||||
if (isInHttpUrl && isValidUrlContinuation) {
|
||||
// Don't replace - return original match
|
||||
return match
|
||||
}
|
||||
|
||||
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||
const replacement = getReplacement(encoded)
|
||||
return replacement
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nostr: URIs in markdown with proper markdown links
|
||||
* This converts: nostr:npub1... to [label](link)
|
||||
*/
|
||||
export function replaceNostrUrisInMarkdown(markdown: string): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
const label = getNostrUriLabel(encoded)
|
||||
|
||||
return `[${label}](${link})`
|
||||
})
|
||||
}
|
||||
@@ -132,9 +238,7 @@ export function replaceNostrUrisInMarkdownWithTitles(
|
||||
markdown: string,
|
||||
articleTitles: Map<string, string>
|
||||
): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||
const link = createNostrLink(encoded)
|
||||
|
||||
// For articles, use the resolved title if available
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
try {
|
||||
@@ -15,10 +16,26 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
|
||||
}
|
||||
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
// So we don't need to filter them - they're all relevant
|
||||
// For Nostr articles, filter by article coordinate
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
return highlights
|
||||
try {
|
||||
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
|
||||
return highlights.filter(h => {
|
||||
// Keep highlights that match this article coordinate
|
||||
return h.eventReference === articleCoordinate
|
||||
})
|
||||
} else {
|
||||
// Not a valid naddr, return empty array
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
// Invalid naddr, return empty array
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
|
||||
@@ -104,7 +104,7 @@ export default defineConfig({
|
||||
filename: 'sw.ts',
|
||||
injectRegister: null,
|
||||
manifest: {
|
||||
name: 'Boris - Nostr Bookmarks',
|
||||
name: 'Boris - Read, Highlight, Explore',
|
||||
short_name: 'Boris',
|
||||
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
|
||||
start_url: '/',
|
||||
@@ -139,7 +139,10 @@ export default defineConfig({
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
type: 'module',
|
||||
// Use generateSW strategy for dev mode to enable SW testing
|
||||
// This creates a working SW in dev mode, while injectManifest is used in production
|
||||
navigateFallback: 'index.html'
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user