Compare commits

...

88 Commits

Author SHA1 Message Date
Gigi
5a67be8096 docs: update CHANGELOG for v0.4.1 release 2025-10-10 21:46:30 +01:00
Gigi
9a929a6be4 chore: bump version to 0.4.1 2025-10-10 21:45:41 +01:00
Gigi
e0ca010026 feat: improve hero image rendering with zoom-to-fit on mobile 2025-10-10 21:44:51 +01:00
Gigi
8bd5d7aadf fix: move long article summaries below image on mobile to prevent overlay issues 2025-10-10 21:43:55 +01:00
Gigi
9115c38cde fix: improve article summary display on mobile devices 2025-10-10 21:40:15 +01:00
Gigi
0c7c1d54d9 feat: add nstart.me onboarding link for new users 2025-10-10 21:26:06 +01:00
Gigi
d529d83eb8 fix: add touch event support for highlight creation on mobile 2025-10-10 21:24:46 +01:00
Gigi
a3127c7836 docs: update CHANGELOG for v0.4.0 release 2025-10-10 18:07:57 +01:00
Gigi
4d5fe1f425 chore: bump version to 0.4.0 2025-10-10 18:07:06 +01:00
Gigi
c7a4de9786 Merge pull request #1 from dergigi/mobile
Add mobile responsive design
2025-10-10 18:04:54 +01:00
Gigi
d873718e88 fix: replace any type with proper bookmark interface for linter compliance 2025-10-10 18:03:48 +01:00
Gigi
706276839a fix: reduce mobile backdrop opacity and ensure sidepanes appear above it 2025-10-10 18:01:39 +01:00
Gigi
d281ca5f87 fix: force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop 2025-10-10 17:54:32 +01:00
Gigi
6a9036bfef fix: add flex properties to mobile bookmark containers for proper filling 2025-10-10 17:25:40 +01:00
Gigi
1b242f75c6 fix: restore desktop grid layout for highlights panel 2025-10-10 17:24:26 +01:00
Gigi
7ffd37289d fix: improve empty state and loading visibility in mobile sidepanes 2025-10-10 17:23:12 +01:00
Gigi
cb859ae599 fix: restore flex layout to highlights pane for desktop view 2025-10-10 17:22:14 +01:00
Gigi
a17346c9c2 fix: ensure bookmarks container fills mobile sidepane properly 2025-10-10 17:21:06 +01:00
Gigi
c17a39588d refactor: DRY mobile sidepane styles - unified overlay behavior 2025-10-10 17:19:14 +01:00
Gigi
33cee9c0c2 feat: hide main content when sidepanes open on mobile for single-pane view 2025-10-10 17:11:26 +01:00
Gigi
e6d2920c27 feat: add mobile highlights panel as overlay with toggle button 2025-10-10 17:10:48 +01:00
Gigi
d8195dbe2a refactor: replace hamburger icon with bookmark icon on mobile 2025-10-10 17:08:36 +01:00
Gigi
4843f129c4 docs: update CHANGELOG with mobile implementation 2025-10-10 17:03:07 +01:00
Gigi
fcd1218dc4 docs: add comprehensive mobile implementation documentation 2025-10-10 17:02:46 +01:00
Gigi
eef0f971d7 fix: resolve TypeScript errors for mobile implementation 2025-10-10 17:01:57 +01:00
Gigi
ff09a8aba0 feat: add mobile auto-collapse setting 2025-10-10 17:00:52 +01:00
Gigi
0c4b523d05 feat: implement mobile overlay sidebar with focus trap and ESC handling 2025-10-10 17:00:03 +01:00
Gigi
de7a435a01 feat: add mobile-responsive CSS with breakpoints and safe areas 2025-10-10 16:57:56 +01:00
Gigi
124d399d1f feat: add mobile sidebar state management to useBookmarksUI 2025-10-10 16:56:19 +01:00
Gigi
e22cf71b15 feat: add media query hooks for responsive design 2025-10-10 16:55:53 +01:00
Gigi
670997ed36 feat: update viewport meta for mobile support 2025-10-10 16:55:39 +01:00
Gigi
1ccb6388e3 docs: update CHANGELOG for v0.3.8 2025-10-10 16:30:57 +01:00
Gigi
7d5be8d6aa chore: bump version to 0.3.8 2025-10-10 16:30:21 +01:00
Gigi
133e4756b2 fix: add vercel.json to handle SPA routing on Vercel
Without this configuration, page refreshes result in 404 errors because
Vercel tries to serve non-existent files instead of routing through
index.html for client-side routing.
2025-10-10 16:22:33 +01:00
Gigi
39ada734d5 docs: update CHANGELOG for v0.3.7 2025-10-10 13:25:18 +01:00
Gigi
19d88c5fba chore: bump version to 0.3.7 2025-10-10 13:24:31 +01:00
Gigi
461b0936e2 fix: use clearActive() method for logout instead of setActive(null)
Changed logout to use the proper clearActive() method from AccountManager instead of setActive(null), which was causing TypeScript type errors. This is the correct way to clear the active account according to the applesauce-accounts API.
2025-10-10 13:22:50 +01:00
Gigi
e9ee5e87be chore: add applesauce reference directory to gitignore
Added the applesauce directory to .gitignore to exclude the local reference copy of the applesauce monorepo from being committed to the project repository.
2025-10-10 13:21:25 +01:00
Gigi
5e66c5ef76 fix: correct logout functionality by using null instead of undefined
The logout button wasn't working because setActive was being called with 'undefined as never', which is an incorrect type hack. Changed to use null instead, which properly clears the active account. Also removed redundant localStorage.removeItem('active') call since the active$ subscription already handles localStorage cleanup.
2025-10-10 13:19:34 +01:00
Gigi
307dc3d726 docs: update CHANGELOG for v0.3.6 2025-10-10 13:16:05 +01:00
Gigi
e514a5f063 chore: bump version to 0.3.6 2025-10-10 13:14:41 +01:00
Gigi
880b7974f4 style: make connecting notification more subtle with muted blue background 2025-10-10 13:12:03 +01:00
Gigi
47048f435f Revert "fix(ui): prevent highlight panel UI breaks with long content or formatting"
This reverts commit a31f05d498.
2025-10-10 06:04:57 +01:00
Gigi
53ad492729 fix(ui): remove incorrect padding-right from highlights container 2025-10-09 21:31:17 +01:00
Gigi
eb4da419ae chore: update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x 2025-10-09 21:30:43 +01:00
Gigi
c66dfc9e2e feat(ui): use compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:28:01 +01:00
Gigi
a31f05d498 fix(ui): prevent highlight panel UI breaks with long content or formatting 2025-10-09 21:27:08 +01:00
Gigi
6548e89c54 fix(ui): reduce font size of highlight metadata for cleaner look 2025-10-09 21:25:54 +01:00
Gigi
8a21b46ebd fix(ui): position highlight FAB button relative to article pane, not viewport 2025-10-09 21:23:21 +01:00
Gigi
bc5fe1ae30 fix(ui): adjust relay indicator position for better visual alignment 2025-10-09 21:22:02 +01:00
Gigi
b57ea3f640 fix(ui): ensure highlight metadata elements align on single visual line with consistent line-height 2025-10-09 21:18:14 +01:00
Gigi
3b55d64468 feat(ui): ultra-compact date format for bookmarks sidebar (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:17:14 +01:00
Gigi
4caf1f0b22 fix(ui): prevent bookmark icons from being cut off in compact view 2025-10-09 21:16:20 +01:00
Gigi
1eb9911645 feat(highlights): encode event links as nevent/naddr per NIP-19 2025-10-09 21:15:03 +01:00
Gigi
38268c453c fix(ui): clean up nested borders in bookmark items for cleaner look 2025-10-09 21:13:47 +01:00
Gigi
9686b80b09 fix(ui): clean up nested borders in bookmarks sidebar view mode controls 2025-10-09 21:12:50 +01:00
Gigi
f32dec16fb fix(ui): align highlight metadata elements on single line in sidebar 2025-10-09 21:12:06 +01:00
Gigi
cb444b532f fix(explore): change header icon from compass to newspaper 2025-10-09 21:11:17 +01:00
Gigi
962062130a feat(routing): render /explore via Bookmarks to keep side panels 2025-10-09 21:10:51 +01:00
Gigi
e429931139 feat(layout): render Explore within ThreePaneLayout so side panels remain 2025-10-09 21:10:33 +01:00
Gigi
e56d28f82a chore: update highlight alt tag domain to read.withboris.com 2025-10-09 21:08:45 +01:00
Gigi
13a30d35c4 docs: update README app link to https://read.withboris.com/ 2025-10-09 21:08:31 +01:00
Gigi
e3174d8777 chore(seo): update robots.txt sitemap to https://read.withboris.com/ 2025-10-09 21:08:22 +01:00
Gigi
829a8d5dca chore(seo): update canonical and social URLs to https://read.withboris.com/ 2025-10-09 21:08:13 +01:00
Gigi
00978e2e64 chore: commit pending RelayStatusIndicator changes before URL update 2025-10-09 21:08:00 +01:00
Gigi
a5fcf36e83 docs: update CHANGELOG for v0.3.5 2025-10-09 20:28:50 +01:00
Gigi
a92a9ee3a3 chore: bump version to 0.3.5 2025-10-09 20:27:59 +01:00
Gigi
f39e34c699 fix: ensure connecting state shows for minimum 15s to prevent premature offline display 2025-10-09 20:27:20 +01:00
Gigi
b58f34d587 fix: add Cloudflare Pages routing config for SPA paths
Add _routes.json configuration to properly handle direct /r/ and /a/ paths
on Cloudflare Pages deployments. This ensures that client-side routes are
served correctly instead of returning 404 errors.
2025-10-09 20:22:08 +01:00
Gigi
76d1d4544e feat: extend connecting state to 8 seconds and remove subtitle text
- Increase 'Connecting' timeout from 4 to 8 seconds
- Remove explanatory subtitle 'Establishing connections...'
- Cleaner, simpler connecting state display
2025-10-09 20:17:29 +01:00
Gigi
5e56176e2d docs: update CHANGELOG for v0.3.3 and v0.3.4 2025-10-09 18:39:30 +01:00
Gigi
a2a4e7e454 chore: bump version to 0.3.4 2025-10-09 18:38:32 +01:00
Gigi
b266288b0f fix: add p tag (author tag) to highlights of nostr-native content
- Highlights now include p tag referencing original article author
- Allows authors to discover highlights of their work
- Follows NIP-84 best practices for highlight attribution
2025-10-09 18:36:20 +01:00
Gigi
1619e328da chore: bump version to 0.3.3 2025-10-09 18:34:12 +01:00
Gigi
b852dad243 fix: resolve linter errors for unused parameters
- Add eslint-disable comments for intentionally unused _settings parameters
- Parameters kept for API compatibility with existing code
- All linter and type checks now pass
2025-10-09 18:31:08 +01:00
Gigi
1552a5f106 feat: reorganize bookmarks UI - add explore button and move refresh
- Move refresh button from top bar to end of bookmarks list
- Show relative time of last fetch next to refresh button
- Add 'Explore' button (fa-newspaper icon) to top bar that links to /explore
- Track lastFetchTime in useBookmarksData hook
- Better UX with explore more prominent and refresh less intrusive
2025-10-09 18:29:41 +01:00
Gigi
0feaffb21b feat: make explore page article cards proper links
- Replace div+onClick with Link components
- Enable CMD+click to open articles in new tabs
- Preserve SPA navigation for normal clicks
- Better UX with standard browser link behavior
2025-10-09 18:27:18 +01:00
Gigi
9b3a4e20de feat: show 'Connecting' instead of 'Offline' on page load
- Display 'Connecting' with spinner for first 4 seconds after page load
- Give relays time to establish connections before showing 'Offline'
- Immediately switch to normal state once any relay connects
- Better UX - most refreshes aren't actually offline, just connecting
2025-10-09 18:26:01 +01:00
Gigi
c83b972a68 fix: correct TypeScript types for cache stats state 2025-10-09 18:24:49 +01:00
Gigi
2e96f93d81 refactor: simplify image caching to use Service Worker only
- Remove complex Cache API management with blob URLs and metadata
- useImageCache now simply returns the URL (Service Worker handles caching)
- imageCacheService reduced to just stats and clear functions
- Service Worker automatically caches all images on fetch
- Much simpler, DRY code that 'just works' for offline mode
- Stats now read directly from Cache API instead of localStorage metadata
2025-10-09 18:24:22 +01:00
Gigi
1e8182d984 feat: add Service Worker for robust offline image caching
- Implement Service Worker to intercept and cache image requests
- Service Worker persists across hard reloads unlike Cache API alone
- Simplify useImageCache hook to work with Service Worker
- Images now work offline even after hard reload
- Service Worker handles transparent cache-first serving for images
2025-10-09 18:17:27 +01:00
Gigi
b20a67d4d0 fix: improve image cache resilience for offline viewing
- Clean up stale metadata when Cache API doesn't have cached data
- Handle online/offline state properly in image loading
- Show original URL when online, blob URL from cache when offline
- Prevent cache misses when browser clears Cache API on hard reload
2025-10-09 18:15:30 +01:00
Gigi
60975b449d fix: import useEventModel from applesauce-react/hooks for proper type safety 2025-10-09 18:10:00 +01:00
Gigi
704fce4d80 fix: import Models from applesauce-core instead of applesauce-react 2025-10-09 18:07:45 +01:00
Gigi
4d1eb0f9fd fix: use correct useEventModel hook for profile loading in BlogPostCard 2025-10-09 18:03:32 +01:00
Gigi
ceafe277d3 feat: add /explore route to discover blog posts from friends
- Create exploreService to fetch kind:30023 events from followed users
- Add BlogPostCard component for displaying blog post previews
- Add Explore page component with grid layout
- Add /explore route to App.tsx (not linked in navigation yet)
- Add responsive CSS styles for explore page and blog post cards
- Clicking blog post cards navigates to article view
2025-10-09 18:02:07 +01:00
Gigi
8f2ecd5fe1 chore: bump version to 0.3.2 2025-10-09 17:49:08 +01:00
Gigi
d6be6f364b refactor: migrate image cache from localStorage to Cache API
BREAKING CHANGE: Image cache now uses Cache API instead of localStorage

Benefits:
- Support for actual 210MB cache size (localStorage limited to 5-10MB)
- Store native Response objects (no base64 overhead)
- Asynchronous, non-blocking operations
- Better suited for large binary blobs like images
- Can handle hundreds of MB to several GB

Changes:
- Rewrite imageCacheService to use Cache API for image storage
- Keep metadata in localStorage for LRU tracking (small footprint)
- Update useImageCache hook to handle async Cache API
- Add blob URL cleanup to prevent memory leaks
- Update clearImageCache to async function

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00
38 changed files with 1884 additions and 478 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ dist
# Misc
*.log
.DS_Store
# Applesauce Reference
applesauce

View File

@@ -5,6 +5,124 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.1] - 2025-10-10
### Fixed
- Long article summaries overlapping with hero image content on mobile devices
- Article summary now moves below hero image on mobile when longer than 150 characters
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
### Changed
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
- Improved image cropping with center positioning for better visual presentation
- Optimized reader header overlay padding and title sizing on mobile
## [0.4.0] - 2025-10-10
### Added
- Mobile-responsive design with overlay sidebar drawer
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
- Auto-collapse sidebar setting for mobile devices
- Touch-optimized UI with 44x44px minimum touch targets
- Safe area inset support for notched devices
- Mobile hamburger menu and backdrop
- Focus trap in mobile sidebar with ESC key support
- Body scroll locking when mobile sidebar is open
- Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toast notifications (bottom position)
- Dynamic viewport height support (100dvh)
- Mobile highlights panel as overlay with toggle button
### Changed
- Sidebar now displays as overlay drawer on mobile (≤768px)
- Highlights panel hidden on mobile for better content focus
- Sidebar auto-closes when selecting content on mobile
- Hover effects disabled on touch devices
- Replace hamburger icon with bookmark icon on mobile
### Fixed
- Ensure bookmarks container fills mobile sidepane properly
- Restore desktop grid layout for highlights panel
- Improve empty state and loading visibility in mobile sidepanes
- Add flex properties to mobile bookmark containers for proper filling
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
- Replace any type with proper bookmark interface for linter compliance
## [0.3.8] - 2025-10-10
### Fixed
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
## [0.3.7] - 2025-10-10
### Fixed
- Logout button functionality - now properly clears active account using clearActive() method
## [0.3.6] - 2025-10-10
### Added
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
- Ultra-compact date format for bookmarks sidebar
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
- Render /explore within ThreePaneLayout to keep side panels visible
### Fixed
- Remove incorrect padding-right from highlights container
- Reduce font size of highlight metadata for cleaner look
- Position highlight FAB button relative to article pane instead of viewport
- Adjust relay indicator position for better visual alignment
- Ensure highlight metadata elements align on single visual line with consistent line-height
- Prevent bookmark icons from being cut off in compact view
- Clean up nested borders in bookmark items and sidebar view mode controls
- Align highlight metadata elements on single line in sidebar
- Change explore header icon from compass to newspaper
### Changed
- Make connecting notification more subtle with muted blue background
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
## [0.3.5] - 2025-10-09
### Fixed
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
- Add Cloudflare Pages routing config for SPA paths
### Changed
- Extend connecting state duration and remove subtitle text for cleaner UI
## [0.3.4] - 2025-10-09
### Fixed
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
## [0.3.3] - 2025-10-09
### Added
- Service Worker for robust offline image caching
- /explore route to discover blog posts from friends on Nostr
- Explore button (newspaper icon) in bookmarks header
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
- Last fetch time display with relative timestamps in bookmarks list
### Changed
- Simplify image caching to use Service Worker transparently
- Move refresh button from top bar to end of bookmarks list
- Make explore page article cards proper links (supports CMD+click to open in new tab)
- Reorganize bookmarks UI for better UX
### Fixed
- Improve image cache resilience for offline viewing and hard reloads
- Correct TypeScript types for cache stats state
- Resolve linter errors for unused parameters
- Import useEventModel from applesauce-react/hooks for proper type safety
- Import Models from applesauce-core instead of applesauce-react
- Use correct useEventModel hook for profile loading in BlogPostCard
## [0.3.0] - 2025-10-09
### Added
@@ -472,6 +590,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9

156
MOBILE_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,156 @@
# Mobile Implementation Summary
## Overview
Boris is now mobile-friendly! The app now works seamlessly on mobile devices with a responsive design that includes:
- Auto-collapsing sidebar that opens as an overlay drawer on small screens
- Touch-optimized UI with proper touch target sizes (44x44px minimum)
- Safe area insets for notched devices (iPhone X+, etc.)
- Focus trap and keyboard navigation in the mobile sidebar
- Mobile-optimized modals, toasts, and other UI elements
## Changes Made
### 1. Viewport & Base Setup
**File: `index.html`**
- Updated viewport meta tag to include `viewport-fit=cover` for proper safe area handling
### 2. Media Query Hooks
**File: `src/hooks/useMediaQuery.ts` (NEW)**
- `useMediaQuery(query)` - Generic hook for any media query
- `useIsMobile()` - Detects mobile viewport (≤768px)
- `useIsTablet()` - Detects tablet viewport (≤1024px)
- `useIsCoarsePointer()` - Detects touch devices
### 3. Mobile CSS Styles
**File: `src/index.css`**
- Added CSS custom properties for mobile breakpoints and safe areas
- Mobile-specific three-pane layout that stacks into single column
- Overlay sidebar with backdrop and transitions
- Touch target improvements (44x44px minimum)
- Disabled hover effects on touch devices
- Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toasts (bottom position with safe area)
- Dynamic viewport height support (`100dvh`)
- Overscroll behavior and body scroll locking
### 4. Sidebar State Management
**File: `src/hooks/useBookmarksUI.ts`**
- Added `isMobile` state from media query
- Added `isSidebarOpen` state for mobile overlay
- Added `toggleSidebar()` function
- Auto-collapse logic based on `autoCollapseSidebarOnMobile` setting
- Mobile sidebar defaults to closed, desktop defaults to open
### 5. Three-Pane Layout Mobile Support
**File: `src/components/ThreePaneLayout.tsx`**
- Mobile hamburger button (visible only on mobile)
- Mobile backdrop for closing sidebar
- Body scroll locking when sidebar is open
- ESC key handler to close sidebar
- Focus trap in sidebar (Tab navigation stays within sidebar)
- Focus restoration when closing sidebar
- Accessibility attributes (`aria-hidden`, `aria-expanded`, etc.)
### 6. Sidebar Header Mobile Controls
**File: `src/components/SidebarHeader.tsx`**
- Close button (X) visible on mobile instead of collapse chevron
- Hamburger button hidden in header (shown in layout instead)
### 7. Bookmark List Mobile Props
**File: `src/components/BookmarkList.tsx`**
- Added `isMobile` prop support
- Passes mobile state to SidebarHeader
### 8. Main Bookmarks Component
**File: `src/components/Bookmarks.tsx`**
- Uses mobile state from `useBookmarksUI`
- Auto-closes sidebar when selecting bookmark on mobile
- Closes sidebar when opening settings on mobile
- Proper desktop/mobile toggle behavior
### 9. Icon Button Enhancement
**File: `src/components/IconButton.tsx`**
- Added optional `className` prop for additional styling
### 10. Mobile Settings
**File: `src/services/settingsService.ts`**
- Added `autoCollapseSidebarOnMobile?: boolean` setting (default: true)
**File: `src/components/Settings/StartupPreferencesSettings.tsx`**
- Added UI toggle for "Auto-collapse sidebar on small screens"
## Accessibility Features
- Focus trap in mobile sidebar (Tab key navigation stays within drawer)
- ESC key closes mobile sidebar
- Backdrop click closes mobile sidebar
- Proper ARIA attributes (`aria-hidden`, `aria-expanded`, `aria-controls`)
- Touch target minimum size enforcement (44x44px)
- Focus restoration when closing sidebar
## Mobile Behaviors
1. **Sidebar**: Slides in from left as overlay drawer with backdrop
2. **Hamburger Menu**: Fixed position top-left when sidebar closed
3. **Selecting Content**: Auto-closes sidebar on mobile
4. **Opening Settings**: Auto-closes sidebar on mobile
5. **Highlights Panel**: Hidden on mobile (content takes full width)
6. **Modals**: Full-screen sheet style from bottom
7. **Toasts**: Bottom position with safe area padding
## Responsive Breakpoints
- **Mobile**: ≤768px (sidebar overlay, single column)
- **Tablet**: ≤1024px (defined but not actively used yet)
- **Desktop**: >768px (three-pane layout as before)
## Browser Support
- Modern browsers with CSS Grid support
- iOS Safari (including safe area insets)
- Chrome for Android
- Firefox Mobile
- Safari on iPadOS
## Safe Area Support
The app respects device safe areas (notches, home indicators) through CSS environment variables:
- `env(safe-area-inset-top)`
- `env(safe-area-inset-bottom)`
- `env(safe-area-inset-left)`
- `env(safe-area-inset-right)`
## Future Enhancements
Potential improvements for future iterations:
- Swipe gesture to open/close sidebar
- Pull-to-refresh on mobile
- Bottom sheet for highlights panel on mobile
- Optimized font sizes for mobile reading
- Mobile-specific view mode (perhaps auto-switch to compact on mobile)
- Haptic feedback on interactions (iOS/Android)
- Share sheet integration
- Install prompt for PWA
## Testing Checklist
- [x] Sidebar opens/closes on mobile
- [x] Hamburger button visible on mobile
- [x] Backdrop closes sidebar
- [x] ESC key closes sidebar
- [x] Focus trap works in sidebar
- [x] Selecting bookmark closes sidebar
- [x] No horizontal scroll
- [x] Touch targets ≥ 44px
- [x] Modals are full-screen on mobile
- [x] Toasts appear at bottom with safe area
- [x] Build completes without errors
- [ ] Test on actual iOS device (iPhone)
- [ ] Test on actual Android device
- [ ] Test with keyboard navigation
- [ ] Test with screen reader
- [ ] Test landscape orientation
- [ ] Test on various screen sizes (320px, 375px, 414px, 768px)
## Commit History
1. `feat: update viewport meta for mobile support`
2. `feat: add media query hooks for responsive design`
3. `feat: add mobile sidebar state management to useBookmarksUI`
4. `feat: add mobile-responsive CSS with breakpoints and safe areas`
5. `feat: implement mobile overlay sidebar with focus trap and ESC handling`
6. `feat: add mobile auto-collapse setting`
7. `fix: resolve TypeScript errors for mobile implementation`

View File

@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
- App: [https://read.withboris.com/](https://read.withboris.com/)
## The Vision

View File

@@ -3,21 +3,21 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Boris - Nostr Bookmarks</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://xn--bris-v0b.com/" />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://xn--bris-v0b.com/" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head>

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.3.1",
"version": "0.4.1",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

6
public/_routes.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
}

View File

@@ -1,5 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://xn--bris-v0b.com/sitemap.xml
Sitemap: https://read.withboris.com/sitemap.xml

56
public/sw.js Normal file
View File

@@ -0,0 +1,56 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

View File

@@ -27,8 +27,7 @@ function AppRoutes({
const accountManager = Hooks.useAccountManager()
const handleLogout = () => {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
accountManager.clearActive()
showToast('Logged out successfully')
}
@@ -61,6 +60,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)

View File

@@ -0,0 +1,61 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const publishedDate = post.published || post.event.created_at
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
addSuffix: true
})
return (
<Link
to={href}
className="blog-post-card"
style={{ textDecoration: 'none', color: 'inherit' }}
>
{post.image && (
<div className="blog-post-card-image">
<img
src={post.image}
alt={post.title}
loading="lazy"
/>
</div>
)}
<div className="blog-post-card-content">
<h3 className="blog-post-card-title">{post.title}</h3>
{post.summary && (
<p className="blog-post-card-summary">{post.summary}</p>
)}
<div className="blog-post-card-meta">
<span className="blog-post-card-author">
<FontAwesomeIcon icon={faUser} />
{displayName}
</span>
<span className="blog-post-card-date">
<FontAwesomeIcon icon={faCalendar} />
{formattedDate}
</span>
</div>
</div>
</Link>
)
}
export default BlogPostCard

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
@@ -22,9 +23,11 @@ interface BookmarkListProps {
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
isMobile?: boolean
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -39,9 +42,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
lastFetchTime,
loading = false,
relayPool,
settings
settings,
isMobile = false
}) => {
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -102,9 +107,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
isMobile={isMobile}
/>
{loading ? (
@@ -115,6 +119,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
) : (
<div className="bookmarks-list">
@@ -130,6 +135,34 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{isClickable && (
<button
className="compact-read-btn"

View File

@@ -13,6 +13,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -33,6 +34,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
// Track previous location for going back from settings
useEffect(() => {
@@ -65,6 +67,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
})
const {
isMobile,
isSidebarOpen,
toggleSidebar,
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
@@ -94,6 +99,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
@@ -113,7 +119,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderLoading,
readerContent,
setReaderContent,
handleSelectUrl
handleSelectUrl: baseHandleSelectUrl
} = useContentSelection({
relayPool,
settings,
@@ -122,6 +128,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setCurrentArticle
})
// Wrap handleSelectUrl to close mobile sidebar when selecting content
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (isMobile && isSidebarOpen) {
toggleSidebar()
}
baseHandleSelectUrl(url, bookmark)
}
const {
highlightButtonRef,
handleTextSelection,
@@ -177,17 +191,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
<ThreePaneLayout
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
lastFetchTime={lastFetchTime}
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => {
navigate('/settings')
setIsCollapsed(true)
if (isMobile) {
toggleSidebar()
} else {
setIsCollapsed(true)
}
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshAll}
@@ -225,6 +246,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -74,7 +74,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
followedPubkeys
})
const { contentRef, handleMouseUp } = useHighlightInteractions({
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -138,7 +138,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleMouseUp}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
@@ -152,7 +153,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)
) : (

129
src/components/Explore.tsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
interface ExploreProps {
relayPool: RelayPool
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const loadBlogPosts = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setLoading(false)
return
}
// Get relay URLs from pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch blog posts from friends
const posts = await fetchBlogPostsFromAuthors(
relayPool,
Array.from(contacts),
relayUrls
)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
}
setBlogPosts(posts)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount])
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
// Create naddr
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading blog posts from your friends...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
}
return (
<div className="explore-container">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
</p>
</div>
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
</div>
)
}
export default Explore

View File

@@ -2,13 +2,14 @@ import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -102,7 +103,41 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getSourceLink = () => {
if (highlight.eventReference) {
return `https://search.dergigi.com/e/${highlight.eventReference}`
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
@@ -248,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (

View File

@@ -11,6 +11,7 @@ interface IconButtonProps {
size?: number
disabled?: boolean
spin?: boolean
className?: string
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -21,11 +22,12 @@ const IconButton: React.FC<IconButtonProps> = ({
variant = 'ghost',
size = 33,
disabled = false,
spin = false
spin = false,
className = ''
}) => {
return (
<button
className={`icon-button ${variant}`}
className={`icon-button ${variant} ${className}`.trim()}
onClick={onClick}
title={title}
aria-label={ariaLabel || title}

View File

@@ -28,36 +28,45 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
if (cachedImage) {
return (
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
<>
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
</div>
{isLongSummary && (
<div className="reader-summary-below-image">
<p className="reader-summary">{summary}</p>
</div>
)}
</div>
</>
)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlane, faGlobe, faCircle } from '@fortawesome/free-solid-svg-icons'
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { isLocalRelay } from '../utils/helpers'
@@ -12,6 +12,7 @@ interface RelayStatusIndicatorProps {
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
if (!relayPool) return null
@@ -25,32 +26,52 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
const offlineMode = connectedUrls.length === 0
// Show "Connecting" for first few seconds or until relays connect
useEffect(() => {
if (connectedUrls.length > 0) {
// Connected! Stop showing connecting state
setIsConnecting(false)
} else {
// No connections yet - show connecting for 8 seconds
setIsConnecting(true)
const timeout = setTimeout(() => {
setIsConnecting(false)
}, 8000)
return () => clearTimeout(timeout)
}
}, [connectedUrls.length])
// Debug logging
React.useEffect(() => {
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay])
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Don't show indicator when fully connected
if (!localOnlyMode && !offlineMode) return null
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
return (
<div className="relay-status-indicator" title={
offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div className="relay-status-icon">
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{offlineMode ? (
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
@@ -62,7 +83,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
</>
)}
</div>
{!offlineMode && (
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
@@ -13,24 +13,34 @@ interface OfflineModeSettingsProps {
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState(getImageCacheStats())
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const handleClearCache = () => {
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
clearImageCache()
setCacheStats(getImageCacheStats())
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats when settings change
// Update cache stats periodically
useEffect(() => {
const updateStats = () => setCacheStats(getImageCacheStats())
const interval = setInterval(updateStats, 2000) // Update every 2 seconds
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])

View File

@@ -49,6 +49,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -16,12 +16,11 @@ interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
relayPool: RelayPool | null
isMobile?: boolean
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
@@ -37,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
accountManager.setActive(account)
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.')
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
} finally {
setIsConnecting(false)
}
@@ -61,11 +60,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
// Refresh bookmarks after creating
if (onRefresh) {
onRefresh()
}
}
const profileImage = getProfileImage()
@@ -73,14 +67,25 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return (
<>
<div className="sidebar-header-bar">
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
{isMobile ? (
<IconButton
icon={faTimes}
onClick={onToggleCollapse}
title="Close sidebar"
ariaLabel="Close sidebar"
variant="ghost"
className="mobile-close-btn"
/>
) : (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
<div className="sidebar-header-right">
<div
className="profile-avatar"
@@ -101,6 +106,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
@@ -108,17 +120,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && (
<IconButton
icon={faPlus}

View File

@@ -1,4 +1,6 @@
import React from 'react'
import React, { useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
@@ -16,18 +18,22 @@ import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
interface ThreePaneLayoutProps {
// Layout state
isCollapsed: boolean
isHighlightsCollapsed: boolean
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
@@ -71,17 +77,173 @@ interface ThreePaneLayoutProps {
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const isMobile = useIsMobile()
const sidebarRef = useRef<HTMLDivElement>(null)
const highlightsRef = useRef<HTMLDivElement>(null)
// Lock body scroll when mobile sidebar or highlights is open
useEffect(() => {
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
document.body.classList.add('mobile-sidebar-open')
} else {
document.body.classList.remove('mobile-sidebar-open')
}
return () => {
document.body.classList.remove('mobile-sidebar-open')
}
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
// Handle ESC key to close sidebar or highlights
useEffect(() => {
if (!isMobile) return
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (props.isSidebarOpen) {
props.onToggleSidebar()
} else if (!props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
// Trap focus in sidebar when open on mobile
useEffect(() => {
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
const sidebar = sidebarRef.current
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
sidebar.addEventListener('keydown', handleTab)
firstElement?.focus()
return () => {
sidebar.removeEventListener('keydown', handleTab)
}
}, [isMobile, props.isSidebarOpen])
// Trap focus in highlights panel when open on mobile
useEffect(() => {
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
const highlights = highlightsRef.current
const focusableElements = highlights.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
highlights.addEventListener('keydown', handleTab)
firstElement?.focus()
return () => {
highlights.removeEventListener('keydown', handleTab)
}
}, [isMobile, props.isHighlightsCollapsed])
const handleBackdropClick = () => {
if (isMobile) {
if (props.isSidebarOpen) {
props.onToggleSidebar()
} else if (!props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}
}
return (
<>
{/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button
className="mobile-hamburger-btn"
onClick={props.onToggleSidebar}
aria-label="Open bookmarks"
aria-expanded={props.isSidebarOpen}
>
<FontAwesomeIcon icon={faBookmark} />
</button>
)}
{/* Mobile highlights button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button
className="mobile-highlights-btn"
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
>
<FontAwesomeIcon icon={faHighlighter} />
</button>
)}
{/* Mobile backdrop */}
{isMobile && (
<div
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
)}
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
>
<BookmarkList
bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed}
isCollapsed={isMobile ? false : props.isCollapsed}
onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout}
viewMode={props.viewMode}
@@ -90,12 +252,14 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
/>
</div>
<div className="pane main">
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
{props.showSettings ? (
<Settings
settings={props.settings}
@@ -103,6 +267,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -128,7 +297,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
/>
)}
</div>
<div className="pane highlights">
<div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
>
<HighlightsPanel
highlights={props.highlights}
loading={props.highlightsLoading}

View File

@@ -34,6 +34,7 @@ export const useBookmarksData = ({
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -93,6 +94,7 @@ export const useBookmarksData = ({
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
@@ -119,6 +121,7 @@ export const useBookmarksData = ({
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll

View File

@@ -3,12 +3,15 @@ import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService'
import { ViewMode } from '../components/Bookmarks'
import { useIsMobile } from './useMediaQuery'
interface UseBookmarksUIParams {
settings: UserSettings
}
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const isMobile = useIsMobile()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('compact')
@@ -23,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
mine: true
})
// Auto-collapse sidebar on mobile based on settings
useEffect(() => {
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
if (isMobile && autoCollapse) {
setIsSidebarOpen(false)
} else if (!isMobile) {
setIsSidebarOpen(true)
}
}, [isMobile, settings.autoCollapseSidebarOnMobile])
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
@@ -34,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
})
}, [settings])
const toggleSidebar = () => {
setIsSidebarOpen(prev => !prev)
}
return {
isMobile,
isSidebarOpen,
setIsSidebarOpen,
toggleSidebar,
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,

View File

@@ -56,8 +56,8 @@ export const useHighlightInteractions = ({
}
}, [selectedHighlightId])
// Handle text selection
const handleMouseUp = useCallback(() => {
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
@@ -76,6 +76,6 @@ export const useHighlightInteractions = ({
}, 10)
}, [onTextSelection, onClearSelection])
return { contentRef, handleMouseUp }
return { contentRef, handleSelectionEnd }
}

View File

@@ -1,90 +1,34 @@
import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService'
/**
* Hook to cache and retrieve images from localStorage
* Hook to return image URL for display
* Service Worker handles all caching transparently
* Images are cached on first load and available offline automatically
*
* @param imageUrl - The URL of the image to cache
* @param settings - User settings to determine if caching is enabled
* @returns The cached data URL or the original URL
* @param imageUrl - The URL of the image to display
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
settings: UserSettings | undefined
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): string | undefined {
const [cachedUrl, setCachedUrl] = useState<string | undefined>(imageUrl)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!imageUrl) {
setCachedUrl(undefined)
return
}
// If caching is disabled, just use the original URL
const enableCache = settings?.enableImageCache ?? true // Default to enabled
if (!enableCache) {
setCachedUrl(imageUrl)
return
}
// Check if already cached
const cached = getCachedImage(imageUrl)
if (cached) {
console.log('📦 Using cached image:', imageUrl.substring(0, 50))
setCachedUrl(cached)
return
}
// Otherwise, show original URL while caching in background
setCachedUrl(imageUrl)
// Cache image in background
if (!isLoading) {
setIsLoading(true)
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(imageUrl, maxSize)
.then(dataUrl => {
setCachedUrl(dataUrl)
})
.catch(err => {
console.error('Failed to cache image:', err)
// Keep using original URL on error
})
.finally(() => {
setIsLoading(false)
})
}
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
return cachedUrl
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* Simpler hook variant that just caches on mount if enabled
* Useful for article cover images
* Pre-load image to ensure it's cached by Service Worker
* Triggers a fetch so the SW can cache it even if not visible yet
*/
export function useCacheImageOnLoad(
imageUrl: string | undefined,
settings: UserSettings | undefined
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): void {
useEffect(() => {
if (!imageUrl) return
const enableCache = settings?.enableImageCache ?? true
if (!enableCache) return
// Check if already cached
const cached = getCachedImage(imageUrl)
if (cached) return
// Cache in background
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(imageUrl, maxSize).catch(err => {
console.error('Failed to cache image:', err)
})
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
// Service Worker will cache on first fetch
// This hook is now a no-op, kept for API compatibility
// The browser will automatically fetch and cache images when they're used in <img> tags
void imageUrl
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react'
/**
* Hook to detect if a media query matches
* @param query The media query string (e.g., '(max-width: 768px)')
* @returns true if the media query matches, false otherwise
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
})
useEffect(() => {
if (typeof window === 'undefined') return
const mediaQuery = window.matchMedia(query)
// Update state if the media query changes
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}
// Legacy browsers
else {
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}
}, [query])
return matches
}
/**
* Hook to detect if the user is on a coarse pointer device (touch)
* @returns true if the user is using a coarse pointer (touch), false otherwise
*/
export function useIsCoarsePointer(): boolean {
return useMediaQuery('(pointer: coarse)')
}
/**
* Hook to detect if the viewport is mobile-sized
* @returns true if viewport width is <= 768px, false otherwise
*/
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)')
}
/**
* Hook to detect if the viewport is tablet-sized
* @returns true if viewport width is <= 1024px, false otherwise
*/
export function useIsTablet(): boolean {
return useMediaQuery('(max-width: 1024px)')
}

View File

@@ -22,12 +22,40 @@
--highlights-collapsed-width: 56px;
--main-max-width: 900px;
--main-horizontal-padding: 1rem;
/* Mobile breakpoints */
--mobile-breakpoint: 768px;
--tablet-breakpoint: 1024px;
/* Mobile touch target minimum */
--min-touch-target: 44px;
/* Safe area insets for notched devices */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Use dynamic viewport height if supported */
@supports (height: 100dvh) {
body {
min-height: 100dvh;
}
}
body.mobile-sidebar-open {
overflow: hidden;
position: fixed;
width: 100%;
}
#root {
@@ -36,6 +64,12 @@ body {
padding: 1rem;
}
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app {
text-align: center;
position: relative;
@@ -71,15 +105,16 @@ body {
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 0.75rem 1rem;
padding: 1rem;
border-top: 1px solid #333;
background: #1a1a1a;
border-radius: 0 0 12px 12px;
background: transparent;
border-radius: 0;
}
.bookmarks-container .bookmarks-list {
padding: 0.25rem;
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
@@ -105,16 +140,52 @@ body {
margin-left: auto;
}
.mobile-hamburger-btn {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.mobile-hamburger-btn:active {
transform: scale(0.95);
}
.mobile-close-btn {
display: none;
}
@media (max-width: 768px) {
.mobile-hamburger-btn {
display: flex;
}
.sidebar-header-bar .toggle-sidebar-btn {
display: none;
}
.mobile-close-btn {
display: flex;
}
}
.view-mode-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 1rem;
justify-content: center;
gap: 0.5rem;
}
.profile-avatar {
@@ -305,6 +376,29 @@ body {
.icon-button.ghost { background: #2a2a2a; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.icon-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
}
}
/* Disable hover effects on touch devices */
@media (pointer: coarse) {
.icon-button:hover {
background: #2a2a2a;
}
.icon-button.ghost:hover {
background: #2a2a2a;
}
.icon-button:active {
background: #333;
}
}
.bookmark-events {
margin: 1rem 0;
}
@@ -398,12 +492,24 @@ body {
text-align: center;
padding: 3rem;
color: #888;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0.5rem 0;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.bookmarks-list {
display: flex;
flex-direction: column;
@@ -432,6 +538,13 @@ body {
column-gap: 0;
height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease;
position: relative;
}
@supports (height: 100dvh) {
.three-pane {
height: calc(100dvh - 2rem);
}
}
.three-pane.sidebar-collapsed {
@@ -446,6 +559,22 @@ body {
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
}
/* Mobile three-pane layout */
@media (max-width: 768px) {
.three-pane {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100vh;
height: 100dvh;
}
.three-pane.sidebar-collapsed,
.three-pane.highlights-collapsed,
.three-pane.sidebar-collapsed.highlights-collapsed {
grid-template-columns: 1fr;
}
}
.pane.sidebar {
overflow-y: auto;
height: 100%;
@@ -475,6 +604,133 @@ body {
height: 100%;
}
/* Ensure panes are stacked in the correct order on desktop */
@media (min-width: 769px) {
/* Desktop stacking to keep highlights above main without overlap */
.three-pane .pane.sidebar { z-index: 1; }
.three-pane .pane.main { z-index: 1; }
.three-pane .pane.highlights { z-index: 2; }
}
/* Mobile pane styles */
@media (max-width: 768px) {
/* Both sidepanes slide in as overlays */
.pane.sidebar,
.pane.highlights {
position: fixed;
top: 0;
width: 85%;
max-width: 320px;
height: 100vh;
height: 100dvh;
background: #1a1a1a;
z-index: 1001; /* Above backdrop */
transition: transform 0.3s ease;
box-shadow: none;
display: flex;
flex-direction: column;
}
/* Ensure content fills the mobile sidepanes */
.pane.sidebar > *,
.pane.highlights > * {
width: 100%;
height: 100%;
}
/* Remove borders from containers in mobile overlays */
.pane.sidebar .bookmarks-container,
.pane.highlights .highlights-container {
border: none;
border-radius: 0;
flex: 1;
min-height: 0;
}
/* Bookmarks sidebar from left */
.pane.sidebar {
left: 0;
transform: translateX(-100%);
}
.pane.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.5);
}
/* Highlights sidebar from right */
.pane.highlights {
right: 0;
transform: translateX(100%);
}
.pane.highlights.mobile-open {
transform: translateX(0);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5);
}
.pane.main {
grid-column: 1;
grid-row: 1;
padding: 0.5rem;
max-width: 100%;
transition: opacity 0.2s ease;
}
/* Hide main content when sidepanes are open on mobile */
.three-pane .pane.main.mobile-hidden {
opacity: 0;
pointer-events: none;
}
.mobile-sidebar-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999; /* Below sidepanes */
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-sidebar-backdrop.visible {
display: block;
opacity: 1;
}
.mobile-highlights-btn {
display: none;
position: fixed;
top: 1rem;
right: 1rem;
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.mobile-highlights-btn:active {
transform: scale(0.95);
}
@media (max-width: 768px) {
.mobile-highlights-btn {
display: flex;
}
}
}
.reader {
background: #1a1a1a;
border: 1px solid #333;
@@ -746,12 +1002,26 @@ body {
gap: 1.5rem;
}
@media (max-width: 768px) {
.bookmarks-grid {
gap: 0.75rem;
}
.bookmarks-grid.bookmarks-compact {
gap: 0.25rem;
}
.bookmarks-grid.bookmarks-large {
gap: 1rem;
}
}
.individual-bookmark {
background: #2a2a2a;
background: transparent;
padding: 1rem;
border-radius: 8px;
transition: all 0.2s ease;
border: 1px solid #333;
border: 1px solid transparent;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@@ -759,23 +1029,26 @@ body {
}
.individual-bookmark:hover {
border-color: #444;
background: #2d2d2d;
border-color: transparent;
background: #2a2a2a;
}
/* Compact view styles */
.individual-bookmark.compact {
padding: 0.3rem 0.25rem;
padding: 0.5rem 0.5rem;
background: transparent;
border-bottom: 1px solid #333;
border: none;
border-bottom: 1px solid #2a2a2a;
border-radius: 0;
box-shadow: none;
width: 100%;
max-width: 100%;
overflow: hidden;
}
.individual-bookmark.compact:hover {
background: #2a2a2a;
background: #252525;
border-bottom-color: #333;
transform: none;
box-shadow: none;
}
@@ -783,11 +1056,11 @@ body {
.compact-row {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
height: 28px;
justify-content: space-between;
width: 100%;
min-width: 0;
overflow: hidden;
}
.compact-row.clickable {
@@ -808,7 +1081,7 @@ body {
}
.compact-text {
flex: 1 1 0;
flex: 1;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
@@ -816,7 +1089,6 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bookmark-date-compact {
@@ -837,10 +1109,9 @@ body {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
width: 24px;
height: 22px;
flex-shrink: 0;
margin-left: auto;
transition: color 0.2s ease;
}
@@ -1166,6 +1437,51 @@ body {
border: 1px solid rgba(100, 108, 255, 0.4);
}
.reader-summary-below-image {
display: none;
}
@media (max-width: 768px) {
.reader-header-overlay .reader-summary.hide-on-mobile {
display: none;
}
.reader-summary-below-image {
display: block;
padding: 0 0 1.5rem 0;
margin-top: -1rem;
}
.reader-summary-below-image .reader-summary {
color: #aaa;
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
.reader-hero-image {
min-height: 280px;
max-height: 400px;
height: 50vh;
}
.reader-hero-image img {
height: 100%;
width: 100%;
object-fit: cover;
object-position: center;
}
.reader-header-overlay {
padding: 1.5rem 1rem 1rem;
}
.reader-header-overlay .reader-title {
font-size: 1.5rem;
line-height: 1.3;
}
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -1208,12 +1524,22 @@ body {
}
.individual-bookmark {
background: #f5f5f5;
border-color: #ddd;
background: transparent;
border-color: transparent;
}
.individual-bookmark:hover {
border-color: #646cff;
background: #f5f5f5;
border-color: transparent;
}
.individual-bookmark.compact {
border-bottom-color: #e5e5e5;
}
.individual-bookmark.compact:hover {
background: #fafafa;
border-bottom-color: #ddd;
}
.individual-bookmarks h4 {
@@ -1279,7 +1605,6 @@ body {
flex-direction: column;
height: 100%;
overflow: hidden;
padding-right: 1rem;
}
.highlights-container.collapsed {
@@ -1570,7 +1895,7 @@ body {
.highlight-relay-indicator {
position: absolute;
bottom: -4px;
bottom: -2px;
left: 0;
font-size: 0.7rem;
color: #888;
@@ -1635,22 +1960,33 @@ body {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-size: 0.8rem;
color: #888;
flex-wrap: wrap;
flex-wrap: nowrap;
min-height: 20px;
}
.highlight-author {
color: #aaa;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
line-height: 1;
}
.highlight-meta-separator {
color: #666;
flex-shrink: 0;
line-height: 1;
}
.highlight-time {
color: #888;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
}
.highlight-source {
@@ -1660,6 +1996,9 @@ body {
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
}
.highlight-source:hover {
@@ -2283,6 +2622,27 @@ body {
font-size: 0.95rem;
}
@media (max-width: 768px) {
.toast {
top: auto;
bottom: calc(1rem + var(--safe-area-bottom));
right: 1rem;
left: 1rem;
max-width: calc(100% - 2rem);
}
@keyframes toast-slide-in {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
}
.toast-success {
border-color: #28a745;
}
@@ -2337,6 +2697,22 @@ body {
box-sizing: border-box;
}
@media (max-width: 768px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
.modal-content {
max-width: 100%;
max-height: 95vh;
max-height: 95dvh;
border-radius: 16px 16px 0 0;
margin: 0;
padding-bottom: var(--safe-area-bottom);
}
}
.modal-header {
display: flex;
align-items: center;
@@ -2494,6 +2870,23 @@ body {
cursor: default;
}
.relay-status-indicator.connecting {
background: rgba(100, 108, 255, 0.15);
border: 1px solid rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting:hover {
background: rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting .relay-status-icon {
color: rgba(100, 108, 255, 0.9);
}
.relay-status-indicator.connecting .relay-status-title {
color: rgba(100, 108, 255, 1);
}
.relay-status-indicator:hover {
background: rgba(245, 158, 11, 1);
transform: translateY(-2px);
@@ -2555,3 +2948,172 @@ body {
.three-pane.sidebar-collapsed .relay-status-indicator {
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
}
/* Explore Page Styles */
.explore-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
}
.explore-header {
text-align: center;
margin-bottom: 3rem;
}
.explore-header h1 {
font-size: 2.5rem;
margin: 0 0 1rem 0;
color: #646cff;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.explore-subtitle {
font-size: 1.125rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.explore-loading,
.explore-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 50vh;
color: rgba(255, 255, 255, 0.7);
}
.explore-error {
color: #ff6b6b;
}
.explore-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.blog-post-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
}
.blog-post-card:hover {
border-color: #646cff;
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
}
.blog-post-card-image {
width: 100%;
height: 200px;
overflow: hidden;
background: #0f0f0f;
}
.blog-post-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.blog-post-card:hover .blog-post-card-image img {
transform: scale(1.05);
}
.blog-post-card-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.blog-post-card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: rgba(255, 255, 255, 0.95);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.blog-post-card-summary {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
flex: 1;
}
.blog-post-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
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;
}
@media (max-width: 768px) {
.explore-container {
padding: 1rem;
}
.explore-header h1 {
font-size: 2rem;
}
.explore-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.blog-post-card-summary {
-webkit-line-clamp: 2;
font-size: 0.8rem;
}
.blog-post-card-content {
padding: 1rem;
}
}

View File

@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for offline image caching
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
}
})
}
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -7,7 +7,6 @@ import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { cacheImage } from './imageCacheService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -146,13 +145,7 @@ export async function fetchArticleByNaddr(
// Save to cache before returning
saveToCache(naddr, content)
// Cache cover image if enabled and present
if (image && settings?.enableImageCache !== false) {
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(image, maxSize).catch(err => {
console.warn('Failed to cache article cover image:', err)
})
}
// Image caching is handled automatically by Service Worker
return content
} catch (err) {

View File

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface BlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
/**
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from
* @param relayUrls - Array of relay URLs to query
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const events = await lastValueFrom(
relayPool
.req(relayUrls, {
kinds: [30023],
authors: pubkeys,
limit: 100 // Fetch up to 100 recent posts
})
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
)
console.log('📊 Blog post events fetched:', events.length)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch blog posts:', error)
return []
}
}

View File

@@ -11,7 +11,8 @@ import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const {
getHighlightText,
@@ -75,9 +76,19 @@ export async function createHighlight(
// Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) {
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
} else {
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com'])
}
// Add p tag (author tag) for nostr-native content
// This tags the original author so they can see highlights of their work
if (typeof source === 'object' && 'kind' in source) {
// Only add p tag if it doesn't already exist
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
if (!hasPTag) {
highlightEvent.tags.push(['p', source.pubkey])
}
}
// Add zap tags for nostr-native content (NIP-57 Appendix G)

View File

@@ -1,253 +1,18 @@
/**
* Image Cache Service
*
* Caches images in localStorage for offline access.
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
* Utility functions for managing the Service Worker's image cache
* Service Worker automatically caches images on fetch
*/
const CACHE_PREFIX = 'img_cache_'
const CACHE_METADATA_KEY = 'img_cache_metadata'
interface CacheMetadata {
[url: string]: {
key: string
size: number
lastAccessed: number
}
}
/**
* Get cache metadata
*/
function getMetadata(): CacheMetadata {
try {
const data = localStorage.getItem(CACHE_METADATA_KEY)
return data ? JSON.parse(data) : {}
} catch {
return {}
}
}
/**
* Save cache metadata
*/
function saveMetadata(metadata: CacheMetadata): void {
try {
localStorage.setItem(CACHE_METADATA_KEY, JSON.stringify(metadata))
} catch (err) {
console.warn('Failed to save image cache metadata:', err)
}
}
/**
* Calculate total cache size in bytes
*/
function getTotalCacheSize(): number {
const metadata = getMetadata()
return Object.values(metadata).reduce((sum, item) => sum + item.size, 0)
}
/**
* Convert bytes to MB
*/
function bytesToMB(bytes: number): number {
return bytes / (1024 * 1024)
}
/**
* Convert MB to bytes
*/
function mbToBytes(mb: number): number {
return mb * 1024 * 1024
}
/**
* Generate cache key for URL
*/
function getCacheKey(url: string): string {
// Use a simple hash of the URL
let hash = 0
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return `${CACHE_PREFIX}${Math.abs(hash)}`
}
/**
* Evict least recently used images until cache is under limit
*/
function evictLRU(maxSizeBytes: number): void {
const metadata = getMetadata()
const entries = Object.entries(metadata)
// Sort by last accessed (oldest first)
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
let currentSize = getTotalCacheSize()
for (const [url, item] of entries) {
if (currentSize <= maxSizeBytes) break
try {
localStorage.removeItem(item.key)
delete metadata[url]
currentSize -= item.size
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
} catch (err) {
console.warn('Failed to evict image:', err)
}
}
saveMetadata(metadata)
}
/**
* Fetch image and convert to data URL
*/
async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
const blob = await response.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert image to data URL'))
}
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
/**
* Cache an image
*/
export async function cacheImage(
url: string,
maxCacheSizeMB: number = 50
): Promise<string> {
try {
// Check if already cached
const cached = getCachedImage(url)
if (cached) {
console.log('✅ Image already cached:', url.substring(0, 50))
return cached
}
// Fetch and convert to data URL
console.log('📥 Caching image:', url.substring(0, 50))
const dataUrl = await fetchImageAsDataUrl(url)
const size = dataUrl.length
// Check if image alone exceeds cache limit
if (bytesToMB(size) > maxCacheSizeMB) {
console.warn(`⚠️ Image too large to cache (${bytesToMB(size).toFixed(2)}MB > ${maxCacheSizeMB}MB)`)
return url // Return original URL if too large
}
const maxSizeBytes = mbToBytes(maxCacheSizeMB)
// Evict old images if necessary
const currentSize = getTotalCacheSize()
if (currentSize + size > maxSizeBytes) {
evictLRU(maxSizeBytes - size)
}
// Store image
const key = getCacheKey(url)
const metadata = getMetadata()
try {
localStorage.setItem(key, dataUrl)
metadata[url] = {
key,
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
return dataUrl
} catch (err) {
// If storage fails, try evicting more and retry once
console.warn('Storage full, evicting more items...')
evictLRU(maxSizeBytes / 2) // Free up half the cache
try {
localStorage.setItem(key, dataUrl)
metadata[url] = {
key,
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
return dataUrl
} catch {
console.error('Failed to cache image after eviction')
return url // Return original URL on failure
}
}
} catch (err) {
console.error('Failed to cache image:', err)
return url // Return original URL on error
}
}
/**
* Get cached image
*/
export function getCachedImage(url: string): string | null {
try {
const metadata = getMetadata()
const item = metadata[url]
if (!item) return null
const dataUrl = localStorage.getItem(item.key)
if (!dataUrl) {
// Clean up stale metadata
delete metadata[url]
saveMetadata(metadata)
return null
}
// Update last accessed time
item.lastAccessed = Date.now()
metadata[url] = item
saveMetadata(metadata)
return dataUrl
} catch {
return null
}
}
const CACHE_NAME = 'boris-image-cache-v1'
/**
* Clear all cached images
*/
export function clearImageCache(): void {
export async function clearImageCache(): Promise<void> {
try {
const metadata = getMetadata()
for (const item of Object.values(metadata)) {
try {
localStorage.removeItem(item.key)
} catch (err) {
console.warn('Failed to remove cached image:', err)
}
}
localStorage.removeItem(CACHE_METADATA_KEY)
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
@@ -255,24 +20,55 @@ export function clearImageCache(): void {
}
/**
* Get cache statistics
* Get cache statistics by inspecting Cache API directly
*/
export async function getImageCacheStatsAsync(): Promise<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}> {
try {
const cache = await caches.open(CACHE_NAME)
const requests = await cache.keys()
let totalSize = 0
const items: Array<{ url: string, sizeMB: number }> = []
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
const sizeMB = blob.size / (1024 * 1024)
totalSize += blob.size
items.push({ url: request.url, sizeMB })
}
}
return {
totalSizeMB: totalSize / (1024 * 1024),
itemCount: requests.length,
items
}
} catch (err) {
console.error('Failed to get cache stats:', err)
return { totalSizeMB: 0, itemCount: 0, items: [] }
}
}
/**
* Synchronous wrapper for cache stats (returns approximate values)
* For real-time stats, use getImageCacheStatsAsync
*/
export function getImageCacheStats(): {
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
} {
const metadata = getMetadata()
const entries = Object.entries(metadata)
// Return placeholder - actual stats require async Cache API access
// Component should use getImageCacheStatsAsync for real values
return {
totalSizeMB: bytesToMB(getTotalCacheSize()),
itemCount: entries.length,
items: entries.map(([url, item]) => ({
url,
sizeMB: bytesToMB(item.size),
lastAccessed: new Date(item.lastAccessed)
}))
totalSizeMB: 0,
itemCount: 0,
items: []
}
}

View File

@@ -45,6 +45,8 @@ export interface UserSettings {
// Image cache settings
enableImageCache?: boolean // Enable caching images in localStorage
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
// Mobile settings
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
}
export async function loadSettings(

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { formatDistanceToNow } from 'date-fns'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const now = new Date()
const seconds = differenceInSeconds(now, date)
const minutes = differenceInMinutes(now, date)
const hours = differenceInHours(now, date)
const days = differenceInDays(now, date)
const months = differenceInMonths(now, date)
const years = differenceInYears(now, date)
if (seconds < 60) return 'now'
if (minutes < 60) return `${minutes}m`
if (hours < 24) return `${hours}h`
if (days < 30) return `${days}d`
if (months < 12) return `${months}mo`
return `${years}y`
}
// Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers

9
vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}