Compare commits

...

153 Commits

Author SHA1 Message Date
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
Gigi
035d4d3bd0 chore: bump version to 0.3.1 2025-10-09 17:36:37 +01:00
Gigi
43d5554c0c feat: change default image cache size to 210MB
Increase default cache size from 50MB to 210MB for better offline experience
2025-10-09 17:31:53 +01:00
Gigi
724a3e5cfa refactor: move 'Rebroadcast events' setting to Startup & Behavior section
Move the rebroadcast setting from Flight Mode to Startup & Behavior as it's more about behavior than offline mode
2025-10-09 17:31:07 +01:00
Gigi
0c49988d36 refactor: rename 'Startup Preferences' to 'Startup & Behavior' 2025-10-09 17:30:40 +01:00
Gigi
70de68848b refactor: move image cache setting to top of Flight Mode section
Reorder settings so 'Use local image cache' appears before 'Use local relays as cache'
2025-10-09 17:29:37 +01:00
Gigi
8a12ae72cb fix: ensure cache size input uses same font and size as surrounding text
Use inherit for fontSize, fontFamily, and color to match parent styling
2025-10-09 17:29:03 +01:00
Gigi
f8d5d19a9f refactor: inline textbox in cache stats display
Move max cache size input inline with stats text: '( X MB / [input] MB used )'
2025-10-09 17:28:20 +01:00
Gigi
dbd20e676f refactor: use IconButton component for cache clear button
Replace inline styled button with existing IconButton component to keep code DRY
2025-10-09 17:27:53 +01:00
Gigi
bbdf47fb94 refactor: update cache stats display format
Change from '(X.X MB, N images)' to '( X.X MB / [ max ] MB used )'
2025-10-09 17:27:21 +01:00
Gigi
1b754e02dc refactor: update cache setting label to 'Use local image cache' 2025-10-09 17:26:52 +01:00
Gigi
a2e410252a refactor: condense cache settings to single line
- Combine all cache settings into one horizontal line
- Shorten 'Cache images for offline viewing' to 'Cache images'
- Shorten 'Max cache size (MB):' to 'Max (MB):'
- Simplify current stats display with parentheses
- Use flexbox with wrap for responsive layout
2025-10-09 17:26:13 +01:00
Gigi
c9a14d151d refactor: simplify image cache settings UI
- Remove 'Image Cache' heading
- Remove explanatory text about localStorage
- Replace slider with simple number input for cache size
- Replace 'Clear Cache' button text with trash icon
- Make cache stats display more compact
2025-10-09 17:25:18 +01:00
Gigi
b286562e86 fix: extend article hero image to pane edges
Remove padding/margins from article hero images so they extend all the way
to the top, left, and right edges of the article pane. Uses negative margins
to counteract the reader container's padding.
2025-10-09 17:24:24 +01:00
Gigi
507288f51c feat: add image caching for offline mode
- Add imageCacheService with localStorage-based image caching and LRU eviction
- Create useImageCache hook for React components to fetch and cache images
- Integrate image caching with article service to cache cover images on load
- Add image cache settings (enable/disable, size limit) to user settings
- Update ReaderHeader to use cached images for article covers
- Update BookmarkViews (CardView, LargeView) to use cached images
- Add image cache configuration UI in OfflineModeSettings with:
  - Toggle to enable/disable image caching
  - Slider to set cache size limit (10-200 MB)
  - Display current cache stats (size and image count)
  - Clear cache button

Images are cached in localStorage for offline viewing, with a configurable
size limit (default 50MB). LRU eviction ensures cache stays within limits.
2025-10-09 17:23:31 +01:00
Gigi
e08bc54f15 refactor(relay): adjust offline indicator polling to 5s 2025-10-09 17:01:20 +01:00
Gigi
4306069191 fix(relay): make offline indicator poll every 3s for better responsiveness 2025-10-09 17:00:53 +01:00
Gigi
56e56af8ec docs: update CHANGELOG for version 0.3.0 2025-10-09 16:59:05 +01:00
Gigi
4d65cd73a7 chore: bump version to 0.3.0 2025-10-09 16:56:56 +01:00
Gigi
d36d5b33b6 fix(lint): remove unused isLocalRelay import
Remove unused import to fix linter error
2025-10-09 16:56:36 +01:00
Gigi
4cd54834ce fix(highlights): update relay info after automatic sync completes
When offline sync completes successfully, update the highlight's publishedRelays to show all relays and change icon from plane to server. Previously only manual rebroadcast updated this info.
2025-10-09 16:51:10 +01:00
Gigi
1134a41192 fix(highlights): always show relay list in tooltip
Remove special case text - always show the actual list of relays. Use plane icon for local-only highlights but still show relay list in tooltip.
2025-10-09 16:47:25 +01:00
Gigi
aced38b147 fix(highlights): only show successfully reachable relays in flight mode
When creating highlights in flight mode (no remote connection), only show local relays in the relay indicator tooltip. Check connection status to determine which relays are actually reachable before setting publishedRelays field.
2025-10-09 16:41:45 +01:00
Gigi
82f52f73cc fix(highlights): always publish to all configured relays
Remove relay.connected filter when publishing/rebroadcasting. Now always attempts to publish to all configured relays and lets the relay pool handle connection state management. This ensures highlights are broadcast to all relays, not just those that report as connected at publish time.
2025-10-09 16:27:00 +01:00
Gigi
4239f50129 fix(highlights): include local relays in relay indicator tooltip
Remove filter that excluded local relays from the fallback tooltip - now shows all configured relays including localhost
2025-10-09 16:24:32 +01:00
Gigi
4e3bb36ea5 perf: reduce relay status polling interval to 20 seconds
Change relay status polling from 5s (default) and 2s (Settings/Indicator) to 20s across the board to reduce CPU usage and network requests
2025-10-09 16:23:59 +01:00
Gigi
0c58f4347b fix(highlights): show remote relay list for fetched highlights
Instead of 'no relay info', show the list of remote relays we're connected to as a fallback for highlights that don't have publishedRelays metadata (i.e., highlights fetched from other users)
2025-10-09 16:23:28 +01:00
Gigi
2dd0711a20 fix(types): add missing eventStore prop to ThreePaneLayoutProps
Add eventStore property to ThreePaneLayoutProps interface and import IEventStore to fix TypeScript errors
2025-10-09 16:22:06 +01:00
Gigi
53b3dd1c7f refactor(highlights): simplify relay indicator tooltip to show only relay list
Remove verbose text from tooltip - just show the relay URLs for debug purposes
2025-10-09 16:21:12 +01:00
Gigi
47e2204c3f fix(highlights): improve relay indicator tooltip accuracy
Update tooltip text to be more accurate about relay information:
- Show 'Published to X relays' for user-created highlights with publishedRelays
- Show 'Seen on X relays' for highlights with seenOnRelays tracking
- Show 'Fetched from network' for highlights without relay metadata
- Add seenOnRelays field to Highlight type for future relay tracking
2025-10-09 16:16:50 +01:00
Gigi
cc8b742731 fix(highlights): always show relay indicator icon
Previously the relay indicator was only shown for highlights with publishedRelays info (user-created highlights). Now it's always visible:
- Show server icon by default for all highlights
- Show plane icon for local-only/offline highlights
- Show spinner during rebroadcast/sync
- Always allow clicking to rebroadcast any highlight
2025-10-09 16:14:50 +01:00
Gigi
529fc6b630 fix(settings): make Relays heading same level as Flight Mode
Add section-title class to Relays heading to match Flight Mode formatting
2025-10-09 16:12:18 +01:00
Gigi
0c5c4b6c23 refactor(highlights): consolidate sync state into relay indicator
Show automatic rebroadcast/sync state in the relay indicator instead of separate meta spinner:
- Relay indicator shows spinner during both manual rebroadcast and auto-sync
- Update tooltip to distinguish between manual rebroadcast and auto-sync
- Remove redundant syncing indicator from meta area
- Clean up unused CSS for syncing indicator

This provides a single, consistent visual indicator for all relay broadcast states.
2025-10-09 16:11:33 +01:00
Gigi
d7320c4bc8 feat(highlights): add click-to-rebroadcast functionality to relay indicator
Make relay indicator icons clickable to trigger manual rebroadcast to all connected relays:
- Click plane icon (local/offline) to rebroadcast to remote relays
- Click server icon to rebroadcast to all relays
- Show spinner while rebroadcasting
- Update icon from plane to server on successful rebroadcast
- Keep plane icon on failure
- Pass relayPool and eventStore through component chain
- Add local state management for highlight updates in HighlightsPanel
- Enhance CSS with scale animation on hover/active
2025-10-09 16:10:43 +01:00
Gigi
98c107d387 refactor(highlights): consolidate relay/status indicators into single icon
Replace multiple redundant indicators (flight mode, local only, relay info) with a single relay indicator icon in the bottom-left of each highlight:
- Show plane icon for local-only or offline-created highlights
- Show server icon for highlights published to remote relays
- Keep spinner in meta area for actively syncing highlights
- Remove duplicate indicators from meta area
- Clean up unused CSS and imports
2025-10-09 16:07:50 +01:00
Gigi
ebe801ae92 fix(relays): keep all relay connections alive, not just local ones
Previously only local relays had keep-alive subscriptions, causing remote relays to disconnect when no active subscriptions were running. This made the app appear to be in flight mode even when online.

Now create a persistent subscription for all relays to maintain connections.
2025-10-09 16:05:26 +01:00
Gigi
d9730bb5f8 feat(highlights): add relay indicator icon to highlight items
Add a small server icon at the bottom-left of each highlight that shows which relays the highlight was published to. The icon appears when publishedRelays information is available (for user-created highlights) and displays a tooltip with the list of relay URLs on hover.

- Import faServer icon from FontAwesome
- Add relay indicator to HighlightItem component
- Display formatted relay list in tooltip
- Add CSS styling for the indicator with hover effects
- Support both dark and light modes
2025-10-09 16:04:24 +01:00
Gigi
6a142f5163 fix(highlights): publish to all connected relays instead of just one
The highlight creation service was getting all relays from the pool without checking if they were actually connected. This caused highlights to only be published to a subset of relays (sometimes just one).

Now properly filters relays using relay.connected to ensure highlights are published to all actually connected relays when online.
2025-10-09 16:01:16 +01:00
Gigi
2105dfe3f6 refactor: remove calendar icon from publication date
- Remove calendar icon (faCalendar) from publication date display
- Display only the formatted date text
- Remove icon-specific CSS styling (gap, svg styles)
- Cleaner, more minimal date display in top-right corner
2025-10-09 15:57:38 +01:00
Gigi
24c0889e9f refactor: add subtle border to publication date
- Add subtle white border with 20% opacity
- Rounded corners (6px border-radius)
- Helps define the date element without being too prominent
2025-10-09 15:56:47 +01:00
Gigi
db30c05aa0 refactor: simplify publication date styling
- Remove background and border from publication date
- Use white text with subtle drop shadow for all layouts
- Icon now uses drop-shadow filter for better visibility
- Cleaner, more minimal appearance that works well on any background
2025-10-09 15:56:26 +01:00
Gigi
4504377c36 feat: move publication date to top-right corner
- Position publication date in top-right corner of article header
- Works for both hero image and non-image layouts
- Add subtle background and border for better visibility
- On hero images: dark semi-transparent background with backdrop blur
- On regular headers: uses surface-secondary background
- Remove date from inline metadata (reading time and highlights remain)
2025-10-09 15:53:46 +01:00
Gigi
3c1114ad21 fix: resolve TypeScript type errors in offline sync
- Import IAccount from applesauce-accounts (not applesauce-core)
- Remove unused account parameter from syncLocalEventsToRemote
- Fix undefined vs null type mismatch in Bookmarks component
- All linter and type checks now pass cleanly
2025-10-09 15:52:34 +01:00
Gigi
e7c05b2c52 feat: keep local relay connections alive in flight mode
- Add persistent keep-alive subscription for local relays
- Prevents disconnection when no other subscriptions are active
- Uses minimal subscription (kinds: [0], limit: 0) to keep connection open
- Properly cleans up subscription on app unmount
- Resolves issue where local relays disconnect after idle period in flight mode
2025-10-09 14:08:37 +01:00
Gigi
ca35e4e7cc fix: plane icon now shows for offline-created highlights
- Add useEffect to watch highlight.isOfflineCreated prop changes
- State now updates when prop changes (not just on initial mount)
- Add isOfflineCreated to console log for easier debugging
- Fixes issue where plane icon wouldn't appear for new offline highlights

The bug was that showOfflineIndicator state was only set once during
component initialization. If the highlight prop didn't have isOfflineCreated
set at that moment, the icon would never appear even if the prop changed later.
2025-10-09 14:05:58 +01:00
Gigi
2d5e48a64e perf: make highlight creation instant by non-blocking relay publish
Major UX improvement:
- Store event in EventStore FIRST (before publishing)
- Return highlight immediately (no await on relay publish)
- Publish to relays in background asynchronously
- UI now updates instantly (<50ms) instead of waiting seconds

Before:
1. Create event
2. Wait for relay publish (1-5 seconds)
3. Store in EventStore
4. Return to UI

After:
1. Create event
2. Store in EventStore
3. Return to UI immediately 
4. Publish to relays in background

Benefits:
- Instant highlight appearance in UI
- No blocking on network operations
- Better perceived performance
- Especially noticeable in flight mode with slow local relays
- Event still saved even if publishing fails
2025-10-09 14:01:55 +01:00
Gigi
be86634a65 fix: skip rebroadcasting when in flight mode
- Check actual relay connectivity before rebroadcasting
- Skip rebroadcast to all relays if no remote relays connected
- Still allows rebroadcast to local relays in flight mode
- Prevents unnecessary publish attempts to unreachable relays
- Logs: '✈️ Flight mode: skipping rebroadcast to remote relays'

This prevents the app from trying to rebroadcast fetched events to
remote relays when only local relays are connected (flight mode).
2025-10-09 14:00:57 +01:00
Gigi
a2041bd14d feat: show sync progress and hide indicator after successful sync
- Show spinning blue icon while event is syncing to remote relays
- Hide offline indicator completely after successful sync
- Add sync state tracking with listeners for real-time updates
- Track successful vs failed syncs separately
- Only clear offline flag for successfully synced events
- Blue spinner (#3b82f6) indicates active sync
- Clean UI: no indicator after sync completes

Behavior:
1. Create highlight offline → plane icon
2. Come back online → spinner replaces plane
3. Sync completes → no indicator (clean)
4. Sync fails → plane icon returns
2025-10-09 13:56:12 +01:00
Gigi
d294287c64 refactor: use applesauce EventStore for offline event management
Major improvements:
- Store highlights in EventStore immediately when created
- Query EventStore instead of local relays for offline sync
- Pass eventStore to highlight creation service and hooks
- Simplified offline sync: no more relay queries, just EventStore lookups
- More efficient and reliable offline event tracking
- Better integration with applesauce architecture

Benefits:
- Faster sync (no relay queries needed)
- More reliable (events always in EventStore)
- Cleaner code (leveraging applesauce patterns)
- Better separation of concerns
2025-10-09 13:54:47 +01:00
Gigi
95162d4423 feat: add flight mode indicator to offline-created highlights
- Add isOfflineCreated property to Highlight type
- Set flag when highlight is created in local-only mode
- Display small plane icon in highlight sidebar for offline-created highlights
- Lighter amber color (#fbbf24) to distinguish from Local badge
- Tooltip: 'Created while in flight mode'
- Visual indicator helps users track which highlights need syncing
2025-10-09 13:51:04 +01:00
Gigi
4224c989c6 fix: improve offline sync with better tracking and logging
- Track events explicitly when created in offline mode
- Mark highlights as offline-created when isLocalOnly is true
- Add extensive debug logging throughout sync process
- Increase query timeout from 5s to 10s for better reliability
- Add 2-second delay before syncing to allow relays to connect
- Log relay state transitions and event counts
- Log each event received during sync query
- Should help diagnose and fix offline sync issues
2025-10-09 13:49:35 +01:00
Gigi
3330f22f82 fix: clean up sync state tracking in offline sync service
- Remove duplicate state tracking from service
- State transition detection now fully handled by hook
- Fix remaining syncState reference bug
- Simplify sync lock mechanism
2025-10-09 13:38:23 +01:00
Gigi
450776f9d0 feat: automatic offline sync - rebroadcast local events when back online
- Create offlineSyncService to sync local-only events to remote relays
- Create useOfflineSync hook to detect online/offline transitions
- When user comes back online (remote relays connect), automatically:
  - Query local relays for user's events from last 24 hours
  - Rebroadcast highlights and bookmarks to remote relays
- Integrate sync into Bookmarks component
- Enables seamless offline workflow:
  - User can work offline with local relays
  - Events are automatically synced when connection restored
  - No manual intervention required
2025-10-09 13:37:33 +01:00
Gigi
0478713fd5 refactor: rename 'Offline Mode' to 'Flight Mode'
- Change 'Local Only' to 'Flight Mode' in relay status indicator
- Rename settings section from 'Offline Mode' to 'Flight Mode'
- Better reflects the airplane icon metaphor
- More intuitive terminology for local-only relay mode
2025-10-09 13:34:39 +01:00
Gigi
0f2b94cc61 style: use wifi icon for disconnected remote relays
- Replace red dot (faCircle) with red wifi icon (faWifi)
- Better visual representation of network disconnection
- Icon size now consistent at 1rem across all states
2025-10-09 13:32:22 +01:00
Gigi
b511d40375 refactor: improve relay list display with airplane icons
- Remove separate 'Offline' section, show all relays in one list
- Local relays always shown first (sorted to top)
- Local relays use airplane icon instead of checkmark
- Airplane icon is green when connected, red when offline
- Remote relays use green checkmark (connected) or red dot (offline)
- Last seen timestamp shown only for disconnected relays
- Simplified layout for better readability
2025-10-09 13:31:09 +01:00
Gigi
d090b953bf fix: check actual relay connection status instead of pool membership
- Check relay.connected property to determine if relay is actually connected
- Previously only checked if relay was in pool, not if connection was active
- Add debug logging to help diagnose connection status issues
- This should fix the airplane indicator not showing when offline
- Relays should now correctly show as disconnected after being offline
2025-10-09 13:29:43 +01:00
Gigi
19595d19ca feat: improve font size scale and default
- Change font sizes from [14,16,18,20,22,24] to [16,18,21,24,28,32]
- Larger sizes now more spread out (28px and 32px)
- Set default font size to 21px instead of 18px
- Better progression for reading comfort
2025-10-09 13:19:07 +01:00
Gigi
239ebba439 style: improve publishing date display
- Use shorter date format (MMM d, yyyy instead of MMMM d, yyyy)
- Add subtle styling with reduced opacity and smaller font
- Make calendar icon smaller and more muted
- Style overlay version for hero images with subtle white text
2025-10-09 13:16:45 +01:00
Gigi
67c6b75cb7 feat: add 6th font size option for balanced UI
- Add 24px font size option
- Now has 6 font sizes to match 6 color options
- Better visual balance in settings UI
2025-10-09 13:13:40 +01:00
Gigi
502dbd801a feat: place Reading Font and Font Size settings side-by-side
- Wrap both settings in a flex container
- Reading Font takes flexible space with min-width
- Font Size takes only necessary space
- Responsive with flex-wrap for smaller screens
- Better use of horizontal space in settings UI
2025-10-09 13:12:57 +01:00
Gigi
e114223e46 refactor: simplify rebroadcast setting text
- Change 'Rebroadcast events to all relays' to 'Rebroadcast events while browsing'
- More concise and user-friendly wording
2025-10-09 13:11:25 +01:00
Gigi
a9c73d35ef feat: add localhost:4869 as second local relay
- Add ws://localhost:4869 to RELAYS configuration
- Update comment to reflect multiple local relays
- Support additional local relay option for users
2025-10-09 13:10:20 +01:00
Gigi
b8f20b73d1 refactor: simplify text 'local relay(s)' to 'local relays' 2025-10-09 13:08:52 +01:00
Gigi
dc8d687f0c refactor: move local relay info box to Offline Mode section
- Move recommendation text from Relays to Offline Mode section
- Info box about Citrine and nostr-relay-tray now appears at end of Offline Mode
- Remove unused handleLinkClick and useNavigate from RelaySettings
- Add handleLinkClick to OfflineModeSettings for clickable links
- Clean up unused onClose prop in RelaySettings
2025-10-09 13:08:24 +01:00
Gigi
3180fc7c73 refactor: move rebroadcast settings to new Offline Mode section
- Create new OfflineModeSettings component
- Move 'Use local relay(s) as cache' checkbox
- Move 'Rebroadcast events to all relays' checkbox
- Position Offline Mode section before Relays section
- Keep consistent checkbox styling
- Remove settings/onUpdate props from RelaySettings (no longer needed)
2025-10-09 13:07:09 +01:00
Gigi
a0cba9fb6f refactor: use consistent checkbox style for rebroadcast settings
- Match existing checkbox pattern from other settings
- Use setting-group, checkbox-label, and setting-checkbox classes
- Add proper id and htmlFor attributes for accessibility
- Consistent with LayoutNavigationSettings and other checkbox settings
- Keep code DRY with unified styling approach
2025-10-09 13:05:33 +01:00
Gigi
3483532944 refactor: simplify rebroadcast settings UI
- Remove icons from checkbox labels
- Shorten text to simple checkbox labels
- Cleaner, more minimal design
- Settings: 'Use local relay(s) as cache' and 'Rebroadcast events to all relays'
2025-10-09 13:04:50 +01:00
Gigi
db20e73ea3 refactor: integrate rebroadcast settings into Relays section
- Move rebroadcast checkboxes from separate section into Relays section
- Add plane and globe icons to rebroadcast settings
- Remove separate RelayRebroadcastSettings component
- Settings now flow better with rebroadcast options at top, relay list below
- Maintains all functionality while improving UI organization
2025-10-09 13:04:12 +01:00
Gigi
b055294afc feat: add relay rebroadcast settings for caching and propagation
- Add two new settings:
  - Use local relay(s) as cache (default: enabled)
  - Rebroadcast events to all relays (default: disabled)
- Create rebroadcastService to handle rebroadcasting events
- Hook into article, bookmark, and highlight fetching services
- Automatically rebroadcast fetched events based on settings:
  - Articles when opened
  - Bookmarks when fetched
  - Highlights when fetched
- Add RelayRebroadcastSettings component with plane/globe icons
- Benefits:
  - Local caching for offline access
  - Content propagation across nostr network
  - User control over bandwidth usage
2025-10-09 13:01:38 +01:00
Gigi
831cb18b66 feat: improve relay status responsiveness for flight mode
- Reduce relay connection window from 20 minutes to 10 seconds
- Change 'Recently Seen' section to 'Offline' with red styling
- Use red circle icon for offline relays instead of gray
- Poll relay status every 2 seconds in settings (faster feedback)
- Poll relay status every 2 seconds in status indicator
- Now when entering flight mode:
  - Local relay stays connected (green checkmark with plane icon)
  - All remote relays move to red 'Offline' section within 10 seconds
  - Status is highly responsive and clear
2025-10-09 12:49:37 +01:00
Gigi
bb51788a1d feat: add plane icon indicator for local relays in settings
- Show plane icon badge next to local relays in relay list
- Badge appears for both active and recently seen local relays
- Uses amber styling to match local-only mode theme
- Includes tooltip explaining the relay is local
- Makes it easy to identify local relays at a glance in settings
2025-10-09 12:48:18 +01:00
Gigi
4cf2ac9172 feat: add relay status indicator for local-only/offline mode
- Create RelayStatusIndicator component with plane icon for local-only mode
- Position indicator in bottom-left corner with amber styling
- Show when only local relays are connected or completely offline
- Hide indicator when remote relays are available (normal operation)
- Add pulsing globe icon animation to indicate checking for connection
- Include hover effects and smooth transitions
- Auto-adjust position when sidebar is collapsed
- Display relay count and clear status messages
2025-10-09 12:47:29 +01:00
Gigi
bdab9c06e4 fix: make highlight creation resilient to offline/flight mode
- Wrap relay publish in try-catch to handle failures gracefully
- Attempt to publish to local relay even when no relays are connected
- Always return highlight object even if publish fails completely
- Add detailed logging to track publish status and failures
- Mark highlights as local-only when publish fails or only local relays available
- Ensure UI always displays newly created highlights immediately
2025-10-09 12:45:51 +01:00
Gigi
6636d540aa feat: add offline highlight creation with local relay tracking
- Add relay tracking to Highlight type (publishedRelays, isLocalOnly fields)
- Create utility functions to identify local relays (localhost/127.0.0.1)
- Update highlight creation service to track which relays received the event
- Detect when highlights are only on local relays and mark accordingly
- Add visual indicator in UI for local-only highlights with amber badge
- Enable immediate display of highlights created offline
- Ensure highlights work even when only local relay is available
2025-10-09 12:40:04 +01:00
Gigi
aa8332831f docs: update CHANGELOG for versions 0.2.7-0.2.10 2025-10-09 12:35:05 +01:00
57 changed files with 3220 additions and 358 deletions

4
.gitignore vendored
View File

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

View File

@@ -5,6 +5,211 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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)
### 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
## [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
- Flight Mode with offline highlight creation and local relay support
- Automatic offline sync - rebroadcast local events when back online
- Relay indicator icon on highlight items showing sync status
- Click-to-rebroadcast functionality for highlights
- Flight mode indicator (plane icon) on offline-created highlights
- Relay rebroadcast settings for caching and propagation
- Local relay status indicator for local-only/offline mode
- Second local relay support (localhost:4869)
- Relay connection status tracking and display
- 6th font size option for better UI scaling
### Fixed
- Highlight creation resilient to offline/flight mode
- TypeScript type errors in offline sync
- Relay indicator tooltip accuracy and reliability
- Always show relay indicator icon on highlights
- Show remote relay list for fetched highlights
- Publish highlights to all connected relays instead of just one
- Keep all relay connections alive, not just local ones
- Check actual relay connection status instead of pool membership
- Skip rebroadcasting when in flight mode
- Update relay info after automatic sync completes
- Only show successfully reachable relays in flight mode
- Include local relays in relay indicator tooltip
### Changed
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
- Move publication date to top-right corner with subtle border styling
- Consolidate relay/status indicators into single unified icon
- Simplify relay indicator tooltip to show relay list
- Move rebroadcast settings to dedicated Flight Mode section
- Place Reading Font and Font Size settings side-by-side
- Improve font size scale and default value
- Use wifi icon for disconnected remote relays
- Use airplane icons for local relay indicators
- Make Relays heading same level as Flight Mode in settings
- Simplify rebroadcast settings UI with consistent checkbox style
### Performance
- Make highlight creation instant with non-blocking relay publish
- Reduce relay status polling interval to 20 seconds
- Show sync progress and hide indicator after successful sync
## [0.2.10] - 2025-10-09
### Added
- URL-based settings navigation with /settings route
- Active zap split preset highlighting
- Educational links about relays in reader view
- Article publication date display in reader
- Local relay recommendations in settings
- Relays section showing active and recently connected relays
### Fixed
- Remove trailing slash from relay URLs
- Constrain Reading Font dropdown width
### Changed
- Rename 'Default View Mode' to 'Default Bookmark View' in settings
- Reorganize settings layout for better UX
- Use sidebar-style colored buttons for highlight visibility
- Simplify Relays section presentation
## [0.2.9] - 2025-10-09
### Fixed
- Deduplicate highlights in streaming callbacks
## [0.2.8] - 2025-10-09
### Added
- Display article summary in header
- Overlay title and metadata on hero images
- Apply reading font to article titles
### Fixed
- Pass article summary through to ReadableContent
- Correct Jina AI Reader proxy URL format
### Changed
- Update homepage URL to read.withboris.com
- Reorder toolbar buttons for better UX
## [0.2.7] - 2025-10-08
### Added
- Web bookmark creation (NIP-B0, kind:39701)
- Tags support for web bookmarks per NIP-B0
- Auto-fetch title and description when URL is pasted
- Prioritize OpenGraph tags for metadata extraction
- Auto-extract tags from metadata with boris as default tag
- Zap split preset buttons
- Boris support percentage to zap splits
- Respect existing zap tags in source content when creating highlights
### Fixed
- Revert to fetchReadableContent to avoid CORS issues
- Improve modal spacing with proper box-sizing
- Prevent sliders from jumping when resetting settings
- Pass relayPool as prop instead of using non-existent hook
- Correct type signature for addZapTags function
### Changed
- Reorder toolbar buttons for better UX
- DRY up tag extraction with normalizeTags helper
- Use url-metadata package for robust metadata extraction
- Make zap split sliders independent using weights
- Move zap splits to dedicated settings section
- Publish bookmarks to relays in background for better performance
## [0.2.6] - 2025-10-08 ## [0.2.6] - 2025-10-08
### Added ### Added
@@ -359,6 +564,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[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
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
[0.2.7]: https://github.com/dergigi/boris/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/dergigi/boris/compare/v0.2.5...v0.2.6 [0.2.6]: https://github.com/dergigi/boris/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/dergigi/boris/compare/v0.2.4...v0.2.5 [0.2.5]: https://github.com/dergigi/boris/compare/v0.2.4...v0.2.5
[0.2.4]: https://github.com/dergigi/boris/compare/v0.2.3...v0.2.4 [0.2.4]: https://github.com/dergigi/boris/compare/v0.2.3...v0.2.4

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 ## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/) - App: [https://read.withboris.com/](https://read.withboris.com/)
## The Vision ## The Vision

View File

@@ -3,21 +3,21 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <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> <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." /> <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 --> <!-- Open Graph / Social Media -->
<meta property="og:type" content="website" /> <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: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: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" /> <meta property="og:site_name" content="Boris" />
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <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: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." /> <meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head> </head>

View File

@@ -1,6 +1,6 @@
{ {
"name": "boris", "name": "boris",
"version": "0.2.10", "version": "0.3.8",
"description": "A minimal nostr client for bookmark management", "description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/", "homepage": "https://read.withboris.com/",
"type": "module", "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: * User-agent: *
Allow: / 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 accountManager = Hooks.useAccountManager()
const handleLogout = () => { const handleLogout = () => {
accountManager.setActive(undefined as never) accountManager.clearActive()
localStorage.removeItem('active')
showToast('Logged out successfully') 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 />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes> </Routes>
) )
@@ -118,6 +126,19 @@ function App() {
console.log('Created relay group with', RELAYS.length, 'relays (including local)') console.log('Created relay group with', RELAYS.length, 'relays (including local)')
console.log('Relay URLs:', RELAYS) console.log('Relay URLs:', RELAYS)
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
})
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(pool as any)._keepAliveSubscription = keepAliveSub
// Attach address/replaceable loaders so ProfileModel can fetch profiles // Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, { const addressLoader = createAddressLoader(pool, {
eventStore: store, eventStore: store,
@@ -134,6 +155,12 @@ function App() {
return () => { return () => {
accountsSub.unsubscribe() accountsSub.unsubscribe()
activeSub.unsubscribe() activeSub.unsubscribe()
// Clean up keep-alive subscription if it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((pool as any)._keepAliveSubscription) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pool as any)._keepAliveSubscription.unsubscribe()
}
} }
} }

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

@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { CompactView } from './BookmarkViews/CompactView' import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView' import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView' import { CardView } from './BookmarkViews/CardView'
import { UserSettings } from '../services/settingsService'
interface BookmarkItemProps { interface BookmarkItemProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
index: number index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode viewMode?: ViewMode
settings?: UserSettings
} }
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => { export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
const [ogImage, setOgImage] = useState<string | null>(null) const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}` const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleImage, articleImage,
articleSummary articleSummary,
settings
} }
if (viewMode === 'compact') { if (viewMode === 'compact') {

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem' import { BookmarkItem } from './BookmarkItem'
@@ -8,6 +9,7 @@ import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton' import IconButton from './IconButton'
import { ViewMode } from './Bookmarks' import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
interface BookmarkListProps { interface BookmarkListProps {
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -21,8 +23,11 @@ interface BookmarkListProps {
onOpenSettings: () => void onOpenSettings: () => void
onRefresh?: () => void onRefresh?: () => void
isRefreshing?: boolean isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean loading?: boolean
relayPool: RelayPool | null relayPool: RelayPool | null
settings?: UserSettings
isMobile?: boolean
} }
export const BookmarkList: React.FC<BookmarkListProps> = ({ export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -37,8 +42,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings, onOpenSettings,
onRefresh, onRefresh,
isRefreshing, isRefreshing,
lastFetchTime,
loading = false, loading = false,
relayPool relayPool,
settings,
isMobile = false
}) => { }) => {
// Helper to check if a bookmark has either content or a URL // Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => { const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -99,9 +107,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse} onToggleCollapse={onToggleCollapse}
onLogout={onLogout} onLogout={onLogout}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool} relayPool={relayPool}
isMobile={isMobile}
/> />
{loading ? ( {loading ? (
@@ -123,9 +130,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index} index={index}
onSelectUrl={onSelectUrl} onSelectUrl={onSelectUrl}
viewMode={viewMode} viewMode={viewMode}
settings={settings}
/> />
)} )}
</div> </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>
)} )}
<div className="view-mode-controls"> <div className="view-mode-controls">

View File

@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import IconButton from '../IconButton' import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers' import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared' import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface CardViewProps { interface CardViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -22,6 +24,7 @@ interface CardViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string articleImage?: string
articleSummary?: string articleSummary?: string
settings?: UserSettings
} }
export const CardView: React.FC<CardViewProps> = ({ export const CardView: React.FC<CardViewProps> = ({
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleImage, articleImage,
articleSummary articleSummary,
settings
}) => { }) => {
const cachedImage = useImageCache(articleImage, settings)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false) const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length const contentLength = (bookmark.content || '').length
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
return ( return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}> <div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && ( {isArticle && cachedImage && (
<div <div
className="article-hero-image" className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }} style={{ backgroundImage: `url(${cachedImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)} onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/> />
)} )}

View File

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

View File

@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils' import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared' import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface LargeViewProps { interface LargeViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -19,6 +21,7 @@ interface LargeViewProps {
getAuthorDisplayName: () => string getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string articleSummary?: string
settings?: UserSettings
} }
export const LargeView: React.FC<LargeViewProps> = ({ export const LargeView: React.FC<LargeViewProps> = ({
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
eventNevent, eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleSummary articleSummary,
settings
}) => { }) => {
const cachedImage = useImageCache(previewImage || undefined, settings)
const isArticle = bookmark.kind === 30023 const isArticle = bookmark.kind === 30023
return ( return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}> <div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{(hasUrls || (isArticle && previewImage)) && ( {(hasUrls || (isArticle && cachedImage)) && (
<div <div
className="large-preview-image" className="large-preview-image"
onClick={() => { onClick={() => {
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
onSelectUrl?.(extractedUrls[0]) onSelectUrl?.(extractedUrls[0])
} }
}} }}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined} style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
> >
{!previewImage && hasUrls && ( {!previewImage && hasUrls && (
<div className="preview-placeholder"> <div className="preview-placeholder">

View File

@@ -10,7 +10,10 @@ import { useBookmarksData } from '../hooks/useBookmarksData'
import { useContentSelection } from '../hooks/useContentSelection' import { useContentSelection } from '../hooks/useContentSelection'
import { useHighlightCreation } from '../hooks/useHighlightCreation' import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout' import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large' export type ViewMode = 'compact' | 'cards' | 'large'
@@ -31,6 +34,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
: undefined : undefined
const showSettings = location.pathname === '/settings' const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
// Track previous location for going back from settings // Track previous location for going back from settings
useEffect(() => { useEffect(() => {
@@ -50,7 +54,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager accountManager
}) })
// Monitor relay status for offline sync
const relayStatuses = useRelayStatus({ relayPool })
// Automatically sync local events to remote relays when coming back online
useOfflineSync({
relayPool,
account: activeAccount || null,
eventStore,
relayStatuses,
enabled: true
})
const { const {
isMobile,
isSidebarOpen,
toggleSidebar,
isCollapsed, isCollapsed,
setIsCollapsed, setIsCollapsed,
isHighlightsCollapsed, isHighlightsCollapsed,
@@ -80,6 +99,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading, setHighlightsLoading,
followedPubkeys, followedPubkeys,
isRefreshing, isRefreshing,
lastFetchTime,
handleFetchHighlights, handleFetchHighlights,
handleRefreshAll handleRefreshAll
} = useBookmarksData({ } = useBookmarksData({
@@ -88,7 +108,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager, accountManager,
naddr, naddr,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId currentArticleEventId,
settings
}) })
const { const {
@@ -98,7 +119,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderLoading, setReaderLoading,
readerContent, readerContent,
setReaderContent, setReaderContent,
handleSelectUrl handleSelectUrl: baseHandleSelectUrl
} = useContentSelection({ } = useContentSelection({
relayPool, relayPool,
settings, settings,
@@ -107,6 +128,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setCurrentArticle 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 { const {
highlightButtonRef, highlightButtonRef,
handleTextSelection, handleTextSelection,
@@ -115,6 +144,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useHighlightCreation({ } = useHighlightCreation({
activeAccount, activeAccount,
relayPool, relayPool,
eventStore,
currentArticle, currentArticle,
selectedUrl, selectedUrl,
readerContent, readerContent,
@@ -134,7 +164,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading, setHighlightsLoading,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
setCurrentArticleEventId, setCurrentArticleEventId,
setCurrentArticle setCurrentArticle,
settings
}) })
// Load external URL if /r/* route is used // Load external URL if /r/* route is used
@@ -160,21 +191,29 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
<ThreePaneLayout <ThreePaneLayout
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed} isHighlightsCollapsed={isHighlightsCollapsed}
isSidebarOpen={isSidebarOpen}
showSettings={showSettings} showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks} bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading} bookmarksLoading={bookmarksLoading}
viewMode={viewMode} viewMode={viewMode}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)} lastFetchTime={lastFetchTime}
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout} onLogout={onLogout}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
onOpenSettings={() => { onOpenSettings={() => {
navigate('/settings') navigate('/settings')
setIsCollapsed(true) if (isMobile) {
toggleSidebar()
} else {
setIsCollapsed(true)
}
setIsHighlightsCollapsed(true) setIsHighlightsCollapsed(true)
}} }}
onRefresh={handleRefreshAll} onRefresh={handleRefreshAll}
relayPool={relayPool} relayPool={relayPool}
eventStore={eventStore}
readerLoading={readerLoading} readerLoading={readerLoading}
readerContent={readerContent} readerContent={readerContent}
selectedUrl={selectedUrl} selectedUrl={selectedUrl}
@@ -207,6 +246,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef} highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight} onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)} hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined} toastMessage={toastMessage ?? undefined}
toastType={toastType} toastType={toastType}
onClearToast={clearToast} onClearToast={clearToast}

View File

@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML' import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent' import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions' import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
interface ContentPanelProps { interface ContentPanelProps {
loading: boolean loading: boolean
@@ -30,6 +31,7 @@ interface ContentPanelProps {
highlightVisibility?: HighlightVisibility highlightVisibility?: HighlightVisibility
currentUserPubkey?: string currentUserPubkey?: string
followedPubkeys?: Set<string> followedPubkeys?: Set<string>
settings?: UserSettings
// For highlight creation // For highlight creation
onTextSelection?: (text: string) => void onTextSelection?: (text: string) => void
onClearSelection?: () => void onClearSelection?: () => void
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
showHighlights = true, showHighlights = true,
highlightStyle = 'marker', highlightStyle = 'marker',
highlightColor = '#ffff00', highlightColor = '#ffff00',
settings,
onHighlightClick, onHighlightClick,
selectedHighlightId, selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true }, highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
readingTimeText={readingStats ? readingStats.text : null} readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights} hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length} highlightCount={relevantHighlights.length}
settings={settings}
/> />
{markdown || html ? ( {markdown || html ? (
markdown ? ( markdown ? (

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

@@ -1,10 +1,15 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons' import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' 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 { interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse' level?: 'mine' | 'friends' | 'nostrverse'
@@ -15,10 +20,24 @@ interface HighlightItemProps {
onSelectUrl?: (url: string) => void onSelectUrl?: (url: string) => void
isSelected?: boolean isSelected?: boolean
onHighlightClick?: (highlightId: string) => void onHighlightClick?: (highlightId: string) => void
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
} }
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => { export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
isSelected,
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
}) => {
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
// Resolve the profile of the user who made the highlight // Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey]) const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -30,6 +49,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
} }
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
const unsubscribe = onSyncStateChange((eventId, syncingState) => {
if (eventId === highlight.id) {
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
isLocalOnly: false,
isOfflineCreated: false
}
onHighlightUpdate(updatedHighlight)
}
}
}
})
return unsubscribe
}, [highlight, onHighlightUpdate])
useEffect(() => { useEffect(() => {
if (isSelected && itemRef.current) { if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -51,13 +103,146 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
const getSourceLink = () => { const getSourceLink = () => {
if (highlight.eventReference) { 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 return highlight.urlReference
} }
const sourceLink = getSourceLink() const sourceLink = getSourceLink()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent triggering highlight selection
if (!relayPool || !eventStore || isRebroadcasting) return
setIsRebroadcasting(true)
try {
// Get the event from the event store
const event = eventStore.getEvent(highlight.id)
if (!event) {
console.error('Event not found in store:', highlight.id)
return
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
await relayPool.publish(targetRelays, event)
console.log('✅ Rebroadcast successful!')
// Update the highlight with new relay info
const isLocalOnly = areAllRelaysLocal(targetRelays)
const updatedHighlight = {
...highlight,
publishedRelays: targetRelays,
isLocalOnly,
isOfflineCreated: false
}
// Notify parent of the update
if (onHighlightUpdate) {
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
setIsRebroadcasting(false)
}
}
// Determine relay indicator icon and tooltip
const getRelayIndicatorInfo = () => {
// Show spinner if manually rebroadcasting OR auto-syncing
if (isRebroadcasting || isSyncing) {
return {
icon: faSpinner,
tooltip: isRebroadcasting ? 'rebroadcasting...' : 'syncing...',
spin: true
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Show server icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
if (highlight.seenOnRelays && highlight.seenOnRelays.length > 0) {
const relayNames = highlight.seenOnRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
const relayIndicator = getRelayIndicatorInfo()
return ( return (
<div <div
ref={itemRef} ref={itemRef}
@@ -68,6 +253,16 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
> >
<div className="highlight-quote-icon"> <div className="highlight-quote-icon">
<FontAwesomeIcon icon={faQuoteLeft} /> <FontAwesomeIcon icon={faQuoteLeft} />
{relayIndicator && (
<div
className="highlight-relay-indicator"
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
>
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
</div> </div>
<div className="highlight-content"> <div className="highlight-content">
@@ -88,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
</span> </span>
<span className="highlight-meta-separator"></span> <span className="highlight-meta-separator"></span>
<span className="highlight-time"> <span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })} {formatDateCompact(highlight.created_at)}
</span> </span>
{sourceLink && ( {sourceLink && (

View File

@@ -6,6 +6,8 @@ import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights' import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed' import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader' import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
export interface HighlightVisibility { export interface HighlightVisibility {
nostrverse: boolean nostrverse: boolean
@@ -28,6 +30,8 @@ interface HighlightsPanelProps {
highlightVisibility?: HighlightVisibility highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string> followedPubkeys?: Set<string>
relayPool?: RelayPool | null
eventStore?: IEventStore | null
} }
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -44,9 +48,12 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
currentUserPubkey, currentUserPubkey,
highlightVisibility = { nostrverse: true, friends: true, mine: true }, highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightVisibilityChange, onHighlightVisibilityChange,
followedPubkeys = new Set() followedPubkeys = new Set(),
relayPool,
eventStore
}) => { }) => {
const [showHighlights, setShowHighlights] = useState(true) const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
const handleToggleHighlights = () => { const handleToggleHighlights = () => {
const newValue = !showHighlights const newValue = !showHighlights
@@ -54,8 +61,19 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleHighlights?.(newValue) onToggleHighlights?.(newValue)
} }
// Keep track of highlight updates
React.useEffect(() => {
setLocalHighlights(highlights)
}, [highlights])
const handleHighlightUpdate = (updatedHighlight: Highlight) => {
setLocalHighlights(prev =>
prev.map(h => h.id === updatedHighlight.id ? updatedHighlight : h)
)
}
const filteredHighlights = useFilteredHighlights({ const filteredHighlights = useFilteredHighlights({
highlights, highlights: localHighlights,
selectedUrl, selectedUrl,
highlightVisibility, highlightVisibility,
currentUserPubkey, currentUserPubkey,
@@ -108,6 +126,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onSelectUrl={onSelectUrl} onSelectUrl={onSelectUrl}
isSelected={highlight.id === selectedHighlightId} isSelected={highlight.id === selectedHighlightId}
onHighlightClick={onHighlightClick} onHighlightClick={onHighlightClick}
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
/> />
))} ))}
</div> </div>

View File

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

View File

@@ -1,7 +1,9 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock, faCalendar } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns' import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { UserSettings } from '../services/settingsService'
interface ReaderHeaderProps { interface ReaderHeaderProps {
title?: string title?: string
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
readingTimeText?: string | null readingTimeText?: string | null
hasHighlights: boolean hasHighlights: boolean
highlightCount: number highlightCount: number
settings?: UserSettings
} }
const ReaderHeader: React.FC<ReaderHeaderProps> = ({ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -20,24 +23,25 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
published, published,
readingTimeText, readingTimeText,
hasHighlights, hasHighlights,
highlightCount highlightCount,
settings
}) => { }) => {
const formattedDate = published ? format(new Date(published * 1000), 'MMMM d, yyyy') : null const cachedImage = useImageCache(image, settings)
if (image) { const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
if (cachedImage) {
return ( return (
<div className="reader-hero-image"> <div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} /> <img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && ( {title && (
<div className="reader-header-overlay"> <div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2> <h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>} {summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta"> <div className="reader-meta">
{formattedDate && (
<div className="publish-date">
<FontAwesomeIcon icon={faCalendar} />
<span>{formattedDate}</span>
</div>
)}
{readingTimeText && ( {readingTimeText && (
<div className="reading-time"> <div className="reading-time">
<FontAwesomeIcon icon={faClock} /> <FontAwesomeIcon icon={faClock} />
@@ -61,15 +65,14 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<> <>
{title && ( {title && (
<div className="reader-header"> <div className="reader-header">
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
<h2 className="reader-title">{title}</h2> <h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>} {summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta"> <div className="reader-meta">
{formattedDate && (
<div className="publish-date">
<FontAwesomeIcon icon={faCalendar} />
<span>{formattedDate}</span>
</div>
)}
{readingTimeText && ( {readingTimeText && (
<div className="reading-time"> <div className="reading-time">
<FontAwesomeIcon icon={faClock} /> <FontAwesomeIcon icon={faClock} />

View File

@@ -0,0 +1,94 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
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'
interface RelayStatusIndicatorProps {
relayPool: RelayPool | null
}
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
// Get currently connected relays
const connectedRelays = relayStatuses.filter(r => r.isInPool)
const connectedUrls = connectedRelays.map(r => r.url)
// Determine connection status
const hasLocalRelay = connectedUrls.some(url => isLocalRelay(url))
const hasRemoteRelay = connectedUrls.some(url => !isLocalRelay(url))
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
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
return (
<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={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{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>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings' import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings' import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings' import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings' import RelaySettings from './Settings/RelaySettings'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
@@ -18,7 +19,7 @@ const DEFAULT_SETTINGS: UserSettings = {
sidebarCollapsed: true, sidebarCollapsed: true,
highlightsCollapsed: true, highlightsCollapsed: true,
readingFont: 'source-serif-4', readingFont: 'source-serif-4',
fontSize: 18, fontSize: 21,
highlightStyle: 'marker', highlightStyle: 'marker',
highlightColor: '#ffff00', highlightColor: '#ffff00',
highlightColorNostrverse: '#9333ea', highlightColorNostrverse: '#9333ea',
@@ -30,6 +31,8 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitHighlighterWeight: 50, zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1, zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50, zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
} }
interface SettingsProps { interface SettingsProps {
@@ -57,6 +60,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
const saveTimeoutRef = useRef<number | null>(null) const saveTimeoutRef = useRef<number | null>(null)
const isLocallyUpdating = useRef(false) const isLocallyUpdating = useRef(false)
// Poll for relay status updates
const relayStatuses = useRelayStatus({ relayPool }) const relayStatuses = useRelayStatus({ relayPool })
useEffect(() => { useEffect(() => {
@@ -158,6 +162,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} /> <LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} /> <StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} /> <ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} /> <RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,173 @@
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 { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
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 = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<IconButton
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
variant="ghost"
size={28}
/>
</div>
)}
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div>
)
}
export default OfflineModeSettings

View File

@@ -20,30 +20,32 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Reading & Display</h3> <h3 className="section-title">Reading & Display</h3>
<div className="setting-group setting-inline"> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="readingFont">Reading Font</label> <div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
<div className="setting-control"> <label htmlFor="readingFont">Reading Font</label>
<FontSelector <div className="setting-control">
value={settings.readingFont || 'source-serif-4'} <FontSelector
onChange={(font) => onUpdate({ readingFont: font })} value={settings.readingFont || 'source-serif-4'}
/> onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div> </div>
</div>
<div className="setting-group setting-inline"> <div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
<label>Font Size</label> <label>Font Size</label>
<div className="setting-buttons"> <div className="setting-buttons">
{[14, 16, 18, 20, 22].map(size => ( {[16, 18, 21, 24, 28, 32].map(size => (
<button <button
key={size} key={size}
onClick={() => onUpdate({ fontSize: size })} onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 18) === size ? 'active' : ''}`} className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`} title={`${size}px`}
style={{ fontSize: `${size - 2}px` }} style={{ fontSize: `${size - 2}px` }}
> >
A A
</button> </button>
))} ))}
</div>
</div> </div>
</div> </div>
@@ -149,7 +151,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
className="preview-content" className="preview-content"
style={{ style={{
fontFamily: previewFontFamily, fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 18}px`, fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00') '--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
} as React.CSSProperties} } as React.CSSProperties}
> >

View File

@@ -1,25 +1,16 @@
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faCircle, faClock } from '@fortawesome/free-solid-svg-icons' import { faCheckCircle, faWifi, faClock, faPlane } from '@fortawesome/free-solid-svg-icons'
import { RelayStatus } from '../../services/relayStatusService' import { RelayStatus } from '../../services/relayStatusService'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { isLocalRelay } from '../../utils/helpers'
interface RelaySettingsProps { interface RelaySettingsProps {
relayStatuses: RelayStatus[] relayStatuses: RelayStatus[]
onClose?: () => void onClose?: () => void
} }
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses, onClose }) => { const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
const navigate = useNavigate()
const activeRelays = relayStatuses.filter(r => r.isInPool)
const recentRelays = relayStatuses.filter(r => !r.isInPool)
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const formatRelayUrl = (url: string) => { const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '') return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
} }
@@ -32,110 +23,109 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses, onClose })
} }
} }
// Sort relays: local relays first, then by connection status, then by URL
const sortedRelays = [...relayStatuses].sort((a, b) => {
const aIsLocal = isLocalRelay(a.url)
const bIsLocal = isLocalRelay(b.url)
// Local relays always first
if (aIsLocal && !bIsLocal) return -1
if (!aIsLocal && bIsLocal) return 1
// Within local or remote groups, connected before disconnected
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
// Finally sort by URL
return a.url.localeCompare(b.url)
})
const getRelayIcon = (relay: RelayStatus) => {
const isLocal = isLocalRelay(relay.url)
const isConnected = relay.isInPool
if (isLocal) {
return {
icon: faPlane,
color: isConnected ? '#22c55e' : '#ef4444',
size: '1rem'
}
} else {
if (isConnected) {
return {
icon: faCheckCircle,
color: '#22c55e',
size: '1rem'
}
} else {
return {
icon: faWifi,
color: '#ef4444',
size: '1rem'
}
}
}
}
return ( return (
<div className="settings-section"> <div className="settings-section">
<h3>Relays</h3> <h3 className="section-title">Relays</h3>
{activeRelays.length > 0 && ( {sortedRelays.length > 0 && (
<div className="relay-group" style={{ marginBottom: '1.5rem' }}>
<div className="relay-list">
{activeRelays.map((relay) => (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem'
}}
>
<FontAwesomeIcon
icon={faCheckCircle}
style={{
color: 'var(--success, #22c55e)',
fontSize: '1rem'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{recentRelays.length > 0 && (
<div className="relay-group"> <div className="relay-group">
<h4 style={{
fontSize: '0.85rem',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Recently Seen
</h4>
<div className="relay-list"> <div className="relay-list">
{recentRelays.map((relay) => ( {sortedRelays.map((relay) => {
<div const iconConfig = getRelayIcon(relay)
key={relay.url} const isDisconnected = !relay.isInPool
className="relay-item"
style={{ return (
display: 'flex', <div
alignItems: 'center', key={relay.url}
gap: '0.75rem', className="relay-item"
padding: '0.75rem', style={{
background: 'var(--surface-secondary)', display: 'flex',
borderRadius: '6px', alignItems: 'center',
marginBottom: '0.5rem', gap: '0.75rem',
opacity: 0.7 padding: '0.75rem',
}} background: 'var(--surface-secondary)',
> borderRadius: '6px',
<FontAwesomeIcon marginBottom: '0.5rem',
icon={faCircle} opacity: isDisconnected ? 0.7 : 1
style={{ }}
color: 'var(--text-tertiary, #6b7280)', >
fontSize: '0.7rem' <FontAwesomeIcon
}} icon={iconConfig.icon}
/> style={{
<div style={{ flex: 1, minWidth: 0 }}> color: iconConfig.color,
<div style={{ fontSize: iconConfig.size
fontSize: '0.9rem', }}
fontFamily: 'var(--font-mono, monospace)', />
whiteSpace: 'nowrap', <div style={{ flex: 1, minWidth: 0 }}>
overflow: 'hidden', <div style={{
textOverflow: 'ellipsis' fontSize: '0.9rem',
}}> fontFamily: 'var(--font-mono, monospace)',
{formatRelayUrl(relay.url)} whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div> </div>
{isDisconnected && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.8rem',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap'
}}>
<FontAwesomeIcon icon={faClock} />
{formatLastSeen(relay.lastSeen)}
</div>
)}
</div> </div>
<div style={{ )
display: 'flex', })}
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.8rem',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap'
}}>
<FontAwesomeIcon icon={faClock} />
{formatLastSeen(relay.lastSeen)}
</div>
</div>
))}
</div> </div>
</div> </div>
)} )}
@@ -145,57 +135,6 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses, onClose })
No relay connections found No relay connections found
</p> </p>
)} )}
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div> </div>
) )
} }

View File

@@ -9,7 +9,7 @@ interface StartupPreferencesSettingsProps {
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => { const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return ( return (
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Startup Preferences</h3> <h3 className="section-title">Startup & Behavior</h3>
<div className="setting-group"> <div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label"> <label htmlFor="sidebarCollapsed" className="checkbox-label">
@@ -36,6 +36,32 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Start with highlights panel collapsed</span> <span>Start with highlights panel collapsed</span>
</label> </label>
</div> </div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<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> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
@@ -16,12 +16,11 @@ interface SidebarHeaderProps {
onToggleCollapse: () => void onToggleCollapse: () => void
onLogout: () => void onLogout: () => void
onOpenSettings: () => void onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
relayPool: RelayPool | null 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 [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@@ -61,11 +60,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
} }
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS) await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
// Refresh bookmarks after creating
if (onRefresh) {
onRefresh()
}
} }
const profileImage = getProfileImage() const profileImage = getProfileImage()
@@ -73,14 +67,25 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return ( return (
<> <>
<div className="sidebar-header-bar"> <div className="sidebar-header-bar">
<button {isMobile ? (
onClick={onToggleCollapse} <IconButton
className="toggle-sidebar-btn" icon={faTimes}
title="Collapse bookmarks sidebar" onClick={onToggleCollapse}
aria-label="Collapse bookmarks sidebar" title="Close sidebar"
> ariaLabel="Close sidebar"
<FontAwesomeIcon icon={faChevronRight} /> variant="ghost"
</button> 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="sidebar-header-right">
<div <div
className="profile-avatar" className="profile-avatar"
@@ -101,6 +106,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Home" ariaLabel="Home"
variant="ghost" variant="ghost"
/> />
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton <IconButton
icon={faGear} icon={faGear}
onClick={onOpenSettings} onClick={onOpenSettings}
@@ -108,17 +120,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings" ariaLabel="Settings"
variant="ghost" variant="ghost"
/> />
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && ( {activeAccount && (
<IconButton <IconButton
icon={faPlus} icon={faPlus}

View File

@@ -1,11 +1,15 @@
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 { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList' import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel' import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel' import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings' import Settings from './Settings'
import Toast from './Toast' import Toast from './Toast'
import { HighlightButton } from './HighlightButton' import { HighlightButton } from './HighlightButton'
import { RelayStatusIndicator } from './RelayStatusIndicator'
import { ViewMode } from './Bookmarks' import { ViewMode } from './Bookmarks'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
@@ -14,24 +18,29 @@ import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel' import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton' import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader' import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
interface ThreePaneLayoutProps { interface ThreePaneLayoutProps {
// Layout state // Layout state
isCollapsed: boolean isCollapsed: boolean
isHighlightsCollapsed: boolean isHighlightsCollapsed: boolean
isSidebarOpen: boolean
showSettings: boolean showSettings: boolean
showExplore?: boolean
// Bookmarks pane // Bookmarks pane
bookmarks: Bookmark[] bookmarks: Bookmark[]
bookmarksLoading: boolean bookmarksLoading: boolean
viewMode: ViewMode viewMode: ViewMode
isRefreshing: boolean isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void onToggleSidebar: () => void
onLogout: () => void onLogout: () => void
onViewModeChange: (mode: ViewMode) => void onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void onOpenSettings: () => void
onRefresh: () => void onRefresh: () => void
relayPool: RelayPool | null relayPool: RelayPool | null
eventStore: IEventStore | null
// Content pane // Content pane
readerLoading: boolean readerLoading: boolean
@@ -68,17 +77,173 @@ interface ThreePaneLayoutProps {
toastMessage?: string toastMessage?: string
toastType?: 'success' | 'error' toastType?: 'success' | 'error'
onClearToast: () => void onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
} }
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => { 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 ( 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={`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 <BookmarkList
bookmarks={props.bookmarks} bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl} onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed} isCollapsed={isMobile ? false : props.isCollapsed}
onToggleCollapse={props.onToggleSidebar} onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout} onLogout={props.onLogout}
viewMode={props.viewMode} viewMode={props.viewMode}
@@ -87,11 +252,14 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onOpenSettings={props.onOpenSettings} onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh} onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing} isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading} loading={props.bookmarksLoading}
relayPool={props.relayPool} relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
/> />
</div> </div>
<div className="pane main"> <div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
{props.showSettings ? ( {props.showSettings ? (
<Settings <Settings
settings={props.settings} settings={props.settings}
@@ -99,6 +267,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClose={props.onCloseSettings} onClose={props.onCloseSettings}
relayPool={props.relayPool} relayPool={props.relayPool}
/> />
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : ( ) : (
<ContentPanel <ContentPanel
loading={props.readerLoading} loading={props.readerLoading}
@@ -120,10 +293,15 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClearSelection={props.onClearSelection} onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey} currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys} followedPubkeys={props.followedPubkeys}
settings={props.settings}
/> />
)} )}
</div> </div>
<div className="pane highlights"> <div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
>
<HighlightsPanel <HighlightsPanel
highlights={props.highlights} highlights={props.highlights}
loading={props.highlightsLoading} loading={props.highlightsLoading}
@@ -139,6 +317,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightVisibility={props.highlightVisibility} highlightVisibility={props.highlightVisibility}
onHighlightVisibilityChange={props.onHighlightVisibilityChange} onHighlightVisibilityChange={props.onHighlightVisibilityChange}
followedPubkeys={props.followedPubkeys} followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
/> />
</div> </div>
</div> </div>
@@ -149,6 +329,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightColor={props.settings.highlightColor || '#ffff00'} highlightColor={props.settings.highlightColor || '#ffff00'}
/> />
)} )}
<RelayStatusIndicator relayPool={props.relayPool} />
{props.toastMessage && ( {props.toastMessage && (
<Toast <Toast
message={props.toastMessage} message={props.toastMessage}

View File

@@ -3,9 +3,10 @@
* Single set of relays used throughout the application * Single set of relays used throughout the application
*/ */
// All relays including local relay // All relays including local relays
export const RELAYS = [ export const RELAYS = [
'ws://localhost:10547', 'ws://localhost:10547',
'ws://localhost:4869',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.nostr.band', 'wss://relay.nostr.band',

View File

@@ -5,6 +5,7 @@ import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService' import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface UseArticleLoaderProps { interface UseArticleLoaderProps {
naddr: string | undefined naddr: string | undefined
@@ -18,6 +19,7 @@ interface UseArticleLoaderProps {
setCurrentArticleCoordinate: (coord: string | undefined) => void setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void setCurrentArticle?: (article: NostrEvent) => void
settings?: UserSettings
} }
export function useArticleLoader({ export function useArticleLoader({
@@ -31,7 +33,8 @@ export function useArticleLoader({
setHighlightsLoading, setHighlightsLoading,
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
setCurrentArticleEventId, setCurrentArticleEventId,
setCurrentArticle setCurrentArticle,
settings
}: UseArticleLoaderProps) { }: UseArticleLoaderProps) {
useEffect(() => { useEffect(() => {
if (!relayPool || !naddr) return if (!relayPool || !naddr) return
@@ -44,7 +47,7 @@ export function useArticleLoader({
// Keep highlights panel collapsed by default - only open on user interaction // Keep highlights panel collapsed by default - only open on user interaction
try { try {
const article = await fetchArticleByNaddr(relayPool, naddr) const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
setReaderContent({ setReaderContent({
title: article.title, title: article.title,
markdown: article.markdown, markdown: article.markdown,
@@ -86,7 +89,8 @@ export function useArticleLoader({
const highlightsList = Array.from(highlightsMap.values()) const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
} }
} },
settings
) )
console.log(`📌 Found ${highlightsMap.size} highlights`) console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) { } catch (err) {
@@ -106,5 +110,5 @@ export function useArticleLoader({
} }
loadArticle() loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle]) }, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
} }

View File

@@ -5,6 +5,7 @@ import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
interface UseBookmarksDataParams { interface UseBookmarksDataParams {
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -15,6 +16,7 @@ interface UseBookmarksDataParams {
naddr?: string naddr?: string
currentArticleCoordinate?: string currentArticleCoordinate?: string
currentArticleEventId?: string currentArticleEventId?: string
settings?: UserSettings
} }
export const useBookmarksData = ({ export const useBookmarksData = ({
@@ -23,7 +25,8 @@ export const useBookmarksData = ({
accountManager, accountManager,
naddr, naddr,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId currentArticleEventId,
settings
}: UseBookmarksDataParams) => { }: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true) const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -31,6 +34,7 @@ export const useBookmarksData = ({
const [highlightsLoading, setHighlightsLoading] = useState(true) const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => { const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return if (!relayPool || !activeAccount) return
@@ -43,11 +47,11 @@ export const useBookmarksData = ({
setBookmarksLoading(true) setBookmarksLoading(true)
try { try {
const fullAccount = accountManager.getActive() const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks) await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
} finally { } finally {
setBookmarksLoading(false) setBookmarksLoading(false)
} }
}, [relayPool, activeAccount, accountManager]) }, [relayPool, activeAccount, accountManager, settings])
const handleFetchHighlights = useCallback(async () => { const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return if (!relayPool) return
@@ -67,11 +71,12 @@ export const useBookmarksData = ({
const highlightsList = Array.from(highlightsMap.values()) const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
} }
} },
settings
) )
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) { } else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey) const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights) setHighlights(fetchedHighlights)
} }
} catch (err) { } catch (err) {
@@ -79,7 +84,7 @@ export const useBookmarksData = ({
} finally { } finally {
setHighlightsLoading(false) setHighlightsLoading(false)
} }
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId]) }, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
const handleRefreshAll = useCallback(async () => { const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return if (!relayPool || !activeAccount || isRefreshing) return
@@ -89,6 +94,7 @@ export const useBookmarksData = ({
await handleFetchBookmarks() await handleFetchBookmarks()
await handleFetchHighlights() await handleFetchHighlights()
await handleFetchContacts() await handleFetchContacts()
setLastFetchTime(Date.now())
} catch (err) { } catch (err) {
console.error('Failed to refresh data:', err) console.error('Failed to refresh data:', err)
} finally { } finally {
@@ -115,6 +121,7 @@ export const useBookmarksData = ({
setHighlightsLoading, setHighlightsLoading,
followedPubkeys, followedPubkeys,
isRefreshing, isRefreshing,
lastFetchTime,
handleFetchBookmarks, handleFetchBookmarks,
handleFetchHighlights, handleFetchHighlights,
handleRefreshAll handleRefreshAll

View File

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

View File

@@ -1,9 +1,10 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService' import { ReadableContent } from '../services/readerService'
import { createHighlight, eventToHighlight } from '../services/highlightCreationService' import { createHighlight } from '../services/highlightCreationService'
import { HighlightButtonRef } from '../components/HighlightButton' import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
@@ -11,6 +12,7 @@ interface UseHighlightCreationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any activeAccount: any
relayPool: RelayPool | null relayPool: RelayPool | null
eventStore: IEventStore | null
currentArticle: NostrEvent | undefined currentArticle: NostrEvent | undefined
selectedUrl: string | undefined selectedUrl: string | undefined
readerContent: ReadableContent | undefined readerContent: ReadableContent | undefined
@@ -21,6 +23,7 @@ interface UseHighlightCreationParams {
export const useHighlightCreation = ({ export const useHighlightCreation = ({
activeAccount, activeAccount,
relayPool, relayPool,
eventStore,
currentArticle, currentArticle,
selectedUrl, selectedUrl,
readerContent, readerContent,
@@ -38,7 +41,7 @@ export const useHighlightCreation = ({
}, []) }, [])
const handleCreateHighlight = useCallback(async (text: string) => { const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool) { if (!activeAccount || !relayPool || !eventStore) {
console.error('Missing requirements for highlight creation') console.error('Missing requirements for highlight creation')
return return
} }
@@ -54,25 +57,34 @@ export const useHighlightCreation = ({
? currentArticle.content ? currentArticle.content
: readerContent?.markdown || readerContent?.html : readerContent?.markdown || readerContent?.html
const signedEvent = await createHighlight( console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
const newHighlight = await createHighlight(
text, text,
source, source,
activeAccount, activeAccount,
relayPool, relayPool,
eventStore,
contentForContext, contentForContext,
undefined, undefined,
settings settings
) )
console.log('✅ Highlight created successfully!') console.log('✅ Highlight created successfully!', {
highlightButtonRef.current?.clearSelection() id: newHighlight.id,
isLocalOnly: newHighlight.isLocalOnly,
isOfflineCreated: newHighlight.isOfflineCreated,
publishedRelays: newHighlight.publishedRelays
})
const newHighlight = eventToHighlight(signedEvent) highlightButtonRef.current?.clearSelection()
onHighlightCreated(newHighlight) onHighlightCreated(newHighlight)
} catch (error) { } catch (error) {
console.error('Failed to create highlight:', error) console.error('Failed to create highlight:', error)
// Re-throw to allow parent to handle
throw error
} }
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings]) }, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
return { return {
highlightButtonRef, highlightButtonRef,

View File

@@ -0,0 +1,34 @@
import { UserSettings } from '../services/settingsService'
/**
* 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 display
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): string | undefined {
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* 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,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): void {
// 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

@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { syncLocalEventsToRemote } from '../services/offlineSyncService'
import { isLocalRelay } from '../utils/helpers'
import { RelayStatus } from '../services/relayStatusService'
interface UseOfflineSyncParams {
relayPool: RelayPool | null
account: IAccount | null
eventStore: IEventStore | null
relayStatuses: RelayStatus[]
enabled?: boolean
}
export function useOfflineSync({
relayPool,
account: _account,
eventStore,
relayStatuses,
enabled = true
}: UseOfflineSyncParams) {
const previousStateRef = useRef<{
hasRemoteRelays: boolean
initialized: boolean
}>({
hasRemoteRelays: false,
initialized: false
})
useEffect(() => {
if (!enabled || !relayPool || !_account || !eventStore) return
const connectedRelays = relayStatuses.filter(r => r.isInPool)
const hasRemoteRelays = connectedRelays.some(r => !isLocalRelay(r.url))
const hasLocalRelays = connectedRelays.some(r => isLocalRelay(r.url))
// Skip the first check to avoid syncing on initial load
if (!previousStateRef.current.initialized) {
previousStateRef.current = {
hasRemoteRelays,
initialized: true
}
return
}
// Detect transition: from local-only to having remote relays
const wasLocalOnly = !previousStateRef.current.hasRemoteRelays && hasLocalRelays
const isNowOnline = hasRemoteRelays
if (wasLocalOnly && isNowOnline) {
console.log('✈️ Detected transition: Flight Mode → Online')
console.log('📊 Relay state:', {
connectedRelays: connectedRelays.length,
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
})
// Wait a moment for relays to fully establish connections
setTimeout(() => {
console.log('🚀 Starting sync after delay...')
syncLocalEventsToRemote(relayPool, eventStore)
}, 2000)
}
previousStateRef.current.hasRemoteRelays = hasRemoteRelays
}, [relayPool, _account, eventStore, relayStatuses, enabled])
}

View File

@@ -9,7 +9,7 @@ interface UseRelayStatusParams {
export function useRelayStatus({ export function useRelayStatus({
relayPool, relayPool,
pollingInterval = 5000 pollingInterval = 20000
}: UseRelayStatusParams) { }: UseRelayStatusParams) {
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([]) const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])

View File

@@ -58,7 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Apply font settings after font is loaded // Apply font settings after font is loaded
root.setProperty('--reading-font', getFontFamily(fontKey)) root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`) root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
// Set highlight colors for three levels // Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00') root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')

View File

@@ -22,12 +22,40 @@
--highlights-collapsed-width: 56px; --highlights-collapsed-width: 56px;
--main-max-width: 900px; --main-max-width: 900px;
--main-horizontal-padding: 1rem; --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 { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; 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 { #root {
@@ -36,6 +64,12 @@ body {
padding: 1rem; padding: 1rem;
} }
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app { .app {
text-align: center; text-align: center;
position: relative; position: relative;
@@ -71,15 +105,16 @@ body {
.bookmarks-container .view-mode-controls { .bookmarks-container .view-mode-controls {
margin-top: auto; margin-top: auto;
padding: 0.75rem 1rem; padding: 1rem;
border-top: 1px solid #333; border-top: 1px solid #333;
background: #1a1a1a; background: transparent;
border-radius: 0 0 12px 12px; border-radius: 0;
} }
.bookmarks-container .bookmarks-list { .bookmarks-container .bookmarks-list {
padding: 0.25rem; padding: 0.5rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
flex: 1; flex: 1;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -105,16 +140,52 @@ body {
margin-left: auto; 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 { .view-mode-controls {
display: flex; display: flex;
align-items: center; 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; justify-content: center;
gap: 0.5rem;
} }
.profile-avatar { .profile-avatar {
@@ -305,6 +376,29 @@ body {
.icon-button.ghost { background: #2a2a2a; } .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 { .bookmark-events {
margin: 1rem 0; margin: 1rem 0;
} }
@@ -398,12 +492,24 @@ body {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #888; color: #888;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
.empty-state p { .empty-state p {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.bookmarks-list { .bookmarks-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -432,6 +538,13 @@ body {
column-gap: 0; column-gap: 0;
height: calc(100vh - 2rem); height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease; transition: grid-template-columns 0.3s ease;
position: relative;
}
@supports (height: 100dvh) {
.three-pane {
height: calc(100dvh - 2rem);
}
} }
.three-pane.sidebar-collapsed { .three-pane.sidebar-collapsed {
@@ -446,6 +559,22 @@ body {
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); 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 { .pane.sidebar {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
@@ -475,6 +604,133 @@ body {
height: 100%; 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 { .reader {
background: #1a1a1a; background: #1a1a1a;
border: 1px solid #333; border: 1px solid #333;
@@ -502,6 +758,7 @@ body {
.reader-header { .reader-header {
margin-bottom: 2rem; margin-bottom: 2rem;
position: relative;
} }
.reader-title { .reader-title {
@@ -524,6 +781,31 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
.publish-date {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.813rem;
color: rgba(136, 136, 136, 0.7);
opacity: 0.85;
}
.publish-date svg {
font-size: 0.75rem;
opacity: 0.6;
}
.publish-date-topright {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 0.813rem;
color: #fff;
padding: 0.4rem 0.75rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.reading-time { .reading-time {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -720,12 +1002,26 @@ body {
gap: 1.5rem; 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 { .individual-bookmark {
background: #2a2a2a; background: transparent;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid #333; border: 1px solid transparent;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
word-break: break-word; word-break: break-word;
@@ -733,23 +1029,26 @@ body {
} }
.individual-bookmark:hover { .individual-bookmark:hover {
border-color: #444; border-color: transparent;
background: #2d2d2d; background: #2a2a2a;
} }
/* Compact view styles */ /* Compact view styles */
.individual-bookmark.compact { .individual-bookmark.compact {
padding: 0.3rem 0.25rem; padding: 0.5rem 0.5rem;
background: transparent; background: transparent;
border-bottom: 1px solid #333; border: none;
border-bottom: 1px solid #2a2a2a;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow: hidden;
} }
.individual-bookmark.compact:hover { .individual-bookmark.compact:hover {
background: #2a2a2a; background: #252525;
border-bottom-color: #333;
transform: none; transform: none;
box-shadow: none; box-shadow: none;
} }
@@ -757,11 +1056,11 @@ body {
.compact-row { .compact-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
height: 28px; height: 28px;
justify-content: space-between;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
overflow: hidden;
} }
.compact-row.clickable { .compact-row.clickable {
@@ -782,7 +1081,7 @@ body {
} }
.compact-text { .compact-text {
flex: 1 1 0; flex: 1;
min-width: 0; min-width: 0;
color: #ccc; color: #ccc;
font-size: 0.85rem; font-size: 0.85rem;
@@ -790,7 +1089,6 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
} }
.bookmark-date-compact { .bookmark-date-compact {
@@ -811,10 +1109,9 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 24px;
height: 22px; height: 22px;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
@@ -1069,9 +1366,9 @@ body {
/* Hero image in reader view */ /* Hero image in reader view */
.reader-hero-image { .reader-hero-image {
width: 100%; width: calc(100% + 1.5rem);
margin: 0 0 2rem 0; margin: -0.75rem -0.75rem 2rem -0.75rem;
border-radius: 8px; border-radius: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
min-height: 300px; min-height: 300px;
@@ -1116,6 +1413,17 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
.reader-header-overlay .publish-date {
color: rgba(255, 255, 255, 0.65);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
opacity: 1;
}
.reader-header-overlay .publish-date svg {
opacity: 0.7;
}
.reader-header-overlay .reading-time, .reader-header-overlay .reading-time,
.reader-header-overlay .highlight-indicator { .reader-header-overlay .highlight-indicator {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
@@ -1171,12 +1479,22 @@ body {
} }
.individual-bookmark { .individual-bookmark {
background: #f5f5f5; background: transparent;
border-color: #ddd; border-color: transparent;
} }
.individual-bookmark:hover { .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 { .individual-bookmarks h4 {
@@ -1220,6 +1538,14 @@ body {
color: #646cff; color: #646cff;
} }
.highlight-relay-indicator {
color: #666;
}
.highlight-relay-indicator:hover {
color: #333;
}
.highlight-text { .highlight-text {
color: #213547; color: #213547;
} }
@@ -1234,7 +1560,6 @@ body {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
padding-right: 1rem;
} }
.highlights-container.collapsed { .highlights-container.collapsed {
@@ -1520,6 +1845,28 @@ body {
font-size: 1.2rem; font-size: 1.2rem;
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.25rem; margin-top: 0.25rem;
position: relative;
}
.highlight-relay-indicator {
position: absolute;
bottom: -2px;
left: 0;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
}
.highlight-relay-indicator:hover {
opacity: 1;
color: #aaa;
transform: scale(1.1);
}
.highlight-relay-indicator:active {
transform: scale(0.95);
} }
/* Level-colored quote icon */ /* Level-colored quote icon */
@@ -1568,22 +1915,33 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.875rem; font-size: 0.8rem;
color: #888; color: #888;
flex-wrap: wrap; flex-wrap: nowrap;
min-height: 20px;
} }
.highlight-author { .highlight-author {
color: #aaa; color: #aaa;
font-weight: 500; font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
line-height: 1;
} }
.highlight-meta-separator { .highlight-meta-separator {
color: #666; color: #666;
flex-shrink: 0;
line-height: 1;
} }
.highlight-time { .highlight-time {
color: #888; color: #888;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
} }
.highlight-source { .highlight-source {
@@ -1593,6 +1951,9 @@ body {
color: #646cff; color: #646cff;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
} }
.highlight-source:hover { .highlight-source:hover {
@@ -2216,6 +2577,27 @@ body {
font-size: 0.95rem; 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 { .toast-success {
border-color: #28a745; border-color: #28a745;
} }
@@ -2270,6 +2652,22 @@ body {
box-sizing: border-box; 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 { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2407,3 +2805,261 @@ body {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* Relay Status Indicator */
.relay-status-indicator {
position: fixed;
bottom: 1.5rem;
left: 1.5rem;
z-index: 999;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(245, 158, 11, 0.95);
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
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);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.relay-status-icon {
font-size: 1.25rem;
color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
}
.relay-status-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.relay-status-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.relay-status-subtitle {
font-size: 0.75rem;
color: rgba(26, 26, 26, 0.8);
line-height: 1.2;
}
.relay-status-pulse {
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
}
.pulse-icon {
font-size: 0.875rem;
color: rgba(26, 26, 26, 0.6);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
/* Adjust for collapsed sidebar */
.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;
}
}

View File

@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' 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( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View File

@@ -5,6 +5,8 @@ import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -71,11 +73,13 @@ function saveToCache(naddr: string, content: ArticleContent): void {
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param naddr - The article's naddr * @param naddr - The article's naddr
* @param bypassCache - If true, skip cache and fetch fresh from relays * @param bypassCache - If true, skip cache and fetch fresh from relays
* @param settings - User settings for rebroadcast options
*/ */
export async function fetchArticleByNaddr( export async function fetchArticleByNaddr(
relayPool: RelayPool, relayPool: RelayPool,
naddr: string, naddr: string,
bypassCache = false bypassCache = false,
settings?: UserSettings
): Promise<ArticleContent> { ): Promise<ArticleContent> {
try { try {
// Check cache first unless bypassed // Check cache first unless bypassed
@@ -120,6 +124,9 @@ export async function fetchArticleByNaddr(
events.sort((a, b) => b.created_at - a.created_at) events.sort((a, b) => b.created_at - a.created_at)
const article = events[0] const article = events[0]
// Rebroadcast article to local/all relays based on settings
await rebroadcastEvents([article], relayPool, settings)
const title = getArticleTitle(article) || 'Untitled Article' const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article) const image = getArticleImage(article)
const published = getArticlePublished(article) const published = getArticlePublished(article)
@@ -138,6 +145,8 @@ export async function fetchArticleByNaddr(
// Save to cache before returning // Save to cache before returning
saveToCache(naddr, content) saveToCache(naddr, content)
// Image caching is handled automatically by Service Worker
return content return content
} catch (err) { } catch (err) {
console.error('Failed to fetch article:', err) console.error('Failed to fetch article:', err)

View File

@@ -14,13 +14,16 @@ import {
} from './bookmarkHelpers' } from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
export const fetchBookmarks = async ( export const fetchBookmarks = async (
relayPool: RelayPool, relayPool: RelayPool,
activeAccount: unknown, // Full account object with extension capabilities activeAccount: unknown, // Full account object with extension capabilities
setBookmarks: (bookmarks: Bookmark[]) => void setBookmarks: (bookmarks: Bookmark[]) => void,
settings?: UserSettings
) => { ) => {
try { try {
@@ -37,6 +40,9 @@ export const fetchBookmarks = async (
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray()) .pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
) )
console.log('📊 Raw events fetched:', rawEvents.length, 'events') console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Check for events with potentially encrypted content // Check for events with potentially encrypted content
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0) const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)

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

@@ -3,13 +3,16 @@ import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts' import { IAccount } from 'applesauce-accounts'
import { AddressPointer } from 'nostr-tools/nip19' import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits // Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3' // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const { const {
getHighlightText, getHighlightText,
@@ -26,17 +29,18 @@ const { HighlightBlueprint } = Blueprints
/** /**
* Creates and publishes a highlight event (NIP-84) * Creates and publishes a highlight event (NIP-84)
* Supports both nostr-native articles and external URLs * Supports both nostr-native articles and external URLs
* Returns the signed event for immediate UI updates * Returns a Highlight object with relay tracking info for immediate UI updates
*/ */
export async function createHighlight( export async function createHighlight(
selectedText: string, selectedText: string,
source: NostrEvent | string, source: NostrEvent | string,
account: IAccount, account: IAccount,
relayPool: RelayPool, relayPool: RelayPool,
eventStore: IEventStore,
contentForContext?: string, contentForContext?: string,
comment?: string, comment?: string,
settings?: UserSettings settings?: UserSettings
): Promise<NostrEvent> { ): Promise<Highlight> {
if (!selectedText || !source) { if (!selectedText || !source) {
throw new Error('Missing required data to create highlight') throw new Error('Missing required data to create highlight')
} }
@@ -72,9 +76,19 @@ export async function createHighlight(
// Update the alt tag to identify Boris as the creator // Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt') const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) { 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 { } 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) // Add zap tags for nostr-native content (NIP-57 Appendix G)
@@ -104,13 +118,60 @@ export async function createHighlight(
// Sign the event // Sign the event
const signedEvent = await factory.sign(highlightEvent) const signedEvent = await factory.sign(highlightEvent)
// Publish to relays (including local relay) // Publish to all configured relays - let the relay pool handle connection state
await relayPool.publish(RELAYS, signedEvent) const targetRelays = RELAYS
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent) // Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(signedEvent)
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
// Return the signed event for immediate UI updates // Check current connection status - are we online or in flight mode?
return signedEvent const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
const hasRemoteConnection = connectedRelays.some(url =>
!url.includes('localhost') && !url.includes('127.0.0.1')
)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Highlight relay status:', {
targetRelays: targetRelays.length,
expectedSuccessRelays,
isLocalOnly,
hasRemoteConnection,
eventId: signedEvent.id
})
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info and return IMMEDIATELY
const highlight = eventToHighlight(signedEvent)
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
highlight.isLocalOnly = isLocalOnly
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
// Publish to relays in the background (non-blocking)
// This allows instant UI updates while publishing happens asynchronously
relayPool.publish(targetRelays, signedEvent)
.then(() => {
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
})
.catch((error) => {
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
})
// Return the highlight immediately for instant UI updates
return highlight
} }
/** /**

View File

@@ -4,18 +4,23 @@ import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
/** /**
* Fetches highlights for a specific article by its address coordinate and/or event ID * Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article") * @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag * @param eventId - Optional event ID to also query by 'e' tag
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlightsForArticle = async ( export const fetchHighlightsForArticle = async (
relayPool: RelayPool, relayPool: RelayPool,
articleCoordinate: string, articleCoordinate: string,
eventId?: string, eventId?: string,
onHighlight?: (highlight: Highlight) => void onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate) console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
@@ -75,6 +80,9 @@ export const fetchHighlightsForArticle = async (
const rawEvents = [...aTagEvents, ...eTagEvents] const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length) console.log('📊 Total raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
if (rawEvents.length > 0) { if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2)) console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else { } else {
@@ -99,10 +107,12 @@ export const fetchHighlightsForArticle = async (
* Fetches highlights for a specific URL * Fetches highlights for a specific URL
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for * @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlightsForUrl = async ( export const fetchHighlightsForUrl = async (
relayPool: RelayPool, relayPool: RelayPool,
url: string url: string,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url) console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
@@ -124,6 +134,9 @@ export const fetchHighlightsForUrl = async (
console.log('📊 Highlights for URL:', rawEvents.length) console.log('📊 Highlights for URL:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents) const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights) return sortHighlights(highlights)
@@ -138,11 +151,13 @@ export const fetchHighlightsForUrl = async (
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param pubkey - The user's public key * @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive * @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/ */
export const fetchHighlights = async ( export const fetchHighlights = async (
relayPool: RelayPool, relayPool: RelayPool,
pubkey: string, pubkey: string,
onHighlight?: (highlight: Highlight) => void onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
@@ -172,6 +187,9 @@ export const fetchHighlights = async (
console.log('📊 Raw highlight events fetched:', rawEvents.length) console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events // Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents) const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length) console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)

View File

@@ -0,0 +1,74 @@
/**
* Image Cache Service
*
* Utility functions for managing the Service Worker's image cache
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
/**
* Clear all cached images
*/
export async function clearImageCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
}
}
/**
* 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 }>
} {
// Return placeholder - actual stats require async Cache API access
// Component should use getImageCacheStatsAsync for real values
return {
totalSizeMB: 0,
itemCount: 0,
items: []
}
}

View File

@@ -0,0 +1,158 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
let isSyncing = false
// Track events created during offline period
const offlineCreatedEvents = new Set<string>()
// Track events currently being synced
const syncingEvents = new Set<string>()
// Callbacks to notify when sync state changes
const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> = []
/**
* Marks an event as created during offline period
*/
export function markEventAsOfflineCreated(eventId: string): void {
offlineCreatedEvents.add(eventId)
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
}
/**
* Check if an event is currently being synced
*/
export function isEventSyncing(eventId: string): boolean {
return syncingEvents.has(eventId)
}
/**
* Subscribe to sync state changes
*/
export function onSyncStateChange(callback: (eventId: string, isSyncing: boolean) => void): () => void {
syncStateListeners.push(callback)
return () => {
const index = syncStateListeners.indexOf(callback)
if (index > -1) syncStateListeners.splice(index, 1)
}
}
/**
* Notify listeners of sync state change
*/
function notifySyncStateChange(eventId: string, isSyncing: boolean): void {
syncStateListeners.forEach(listener => listener(eventId, isSyncing))
}
/**
* Syncs local-only events to remote relays when coming back online
* Now uses applesauce EventStore instead of querying relays
*/
export async function syncLocalEventsToRemote(
relayPool: RelayPool,
eventStore: IEventStore
): Promise<void> {
if (isSyncing) {
console.log('⏳ Sync already in progress, skipping...')
return
}
console.log('🔄 Coming back online - syncing local events to remote relays...')
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
isSyncing = true
try {
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
console.log(`📡 Remote relays: ${remoteRelays.length}`)
if (remoteRelays.length === 0) {
console.log('⚠️ No remote relays available for sync')
isSyncing = false
return
}
if (offlineCreatedEvents.size === 0) {
console.log('✅ No offline events to sync')
isSyncing = false
return
}
// Get events from EventStore using the tracked IDs
const eventsToSync: NostrEvent[] = []
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
for (const eventId of offlineCreatedEvents) {
const event = eventStore.getEvent(eventId)
if (event) {
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
eventsToSync.push(event)
} else {
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
}
}
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
if (eventsToSync.length === 0) {
console.log('✅ No events found in EventStore to sync')
isSyncing = false
offlineCreatedEvents.clear()
return
}
// Deduplicate events by id
const uniqueEvents = Array.from(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
// Mark all events as syncing
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
notifySyncStateChange(event.id, true)
})
// Publish to remote relays
let successCount = 0
const successfulIds: string[] = []
for (const event of uniqueEvents) {
try {
await relayPool.publish(remoteRelays, event)
successCount++
successfulIds.push(event.id)
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
} catch (error) {
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
}
}
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
// Clear syncing state and offline tracking for successful events
successfulIds.forEach(eventId => {
syncingEvents.delete(eventId)
offlineCreatedEvents.delete(eventId)
notifySyncStateChange(eventId, false)
})
// Clear syncing state for failed events
uniqueEvents.forEach(event => {
if (!successfulIds.includes(event.id)) {
syncingEvents.delete(event.id)
notifySyncStateChange(event.id, false)
}
})
} catch (error) {
console.error('❌ Error during offline sync:', error)
} finally {
isSyncing = false
}
}

View File

@@ -0,0 +1,78 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from './settingsService'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
/**
* Rebroadcasts events to relays based on user settings
* @param events Events to rebroadcast
* @param relayPool The relay pool to use for publishing
* @param settings User settings to determine which relays to broadcast to
*/
export async function rebroadcastEvents(
events: NostrEvent[],
relayPool: RelayPool,
settings?: UserSettings
): Promise<void> {
if (!events || events.length === 0) {
return
}
// Check if any rebroadcast is enabled
const useLocalCache = settings?.useLocalRelayAsCache ?? true
const broadcastToAll = settings?.rebroadcastToAllRelays ?? false
if (!useLocalCache && !broadcastToAll) {
return // No rebroadcast enabled
}
// Check current relay connectivity - don't rebroadcast in flight mode
const connectedRelays = Array.from(relayPool.relays.values())
const connectedRemoteRelays = connectedRelays.filter(relay => relay.connected && !isLocalRelay(relay.url))
const hasRemoteConnection = connectedRemoteRelays.length > 0
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
if (broadcastToAll && !hasRemoteConnection) {
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
return
}
// Determine target relays based on settings
let targetRelays: string[] = []
if (broadcastToAll) {
// Broadcast to all relays (only if we have remote connection)
targetRelays = RELAYS
} else if (useLocalCache) {
// Only broadcast to local relays
targetRelays = RELAYS.filter(isLocalRelay)
}
if (targetRelays.length === 0) {
console.log('📡 No target relays for rebroadcast')
return
}
// Rebroadcast each event
const rebroadcastPromises = events.map(async (event) => {
try {
await relayPool.publish(targetRelays, event)
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
} catch (error) {
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
}
})
// Execute all rebroadcasts (don't block on completion)
Promise.all(rebroadcastPromises).catch((err) => {
console.warn('⚠️ Some rebroadcasts failed:', err)
})
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
broadcastToAll,
useLocalCache,
targetRelays
})
}

View File

@@ -6,7 +6,8 @@ export interface RelayStatus {
lastSeen: number // timestamp lastSeen: number // timestamp
} }
const RECENT_CONNECTION_WINDOW = 20 * 60 * 1000 // 20 minutes // How long to show disconnected relays as "recently seen" before hiding them
const RECENT_CONNECTION_WINDOW = 10 * 1000 // 10 seconds
// In-memory tracking of relay last seen times // In-memory tracking of relay last seen times
const relayLastSeen = new Map<string, number>() const relayLastSeen = new Map<string, number>()
@@ -17,29 +18,48 @@ const relayLastSeen = new Map<string, number>()
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] { export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const statuses: RelayStatus[] = [] const statuses: RelayStatus[] = []
const now = Date.now() const now = Date.now()
const currentRelayUrls = new Set<string>() const currentlyConnectedUrls = new Set<string>()
// Update relays currently in the pool // Check all relays in the pool for their actual connection status
for (const relay of relayPool.relays.values()) { for (const relay of relayPool.relays.values()) {
currentRelayUrls.add(relay.url) const isConnected = relay.connected
relayLastSeen.set(relay.url, now)
if (isConnected) {
currentlyConnectedUrls.add(relay.url)
relayLastSeen.set(relay.url, now)
}
statuses.push({ statuses.push({
url: relay.url, url: relay.url,
isInPool: true, isInPool: isConnected,
lastSeen: now lastSeen: isConnected ? now : (relayLastSeen.get(relay.url) || now)
}) })
} }
// Add recently seen relays that are no longer in the pool // Debug logging
const connectedCount = statuses.filter(s => s.isInPool).length
const disconnectedCount = statuses.filter(s => !s.isInPool).length
if (connectedCount === 0 || disconnectedCount > 0) {
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
const connected = statuses.filter(s => s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
const disconnected = statuses.filter(s => !s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
if (connected.length > 0) console.log('✅ Connected:', connected.join(', '))
if (disconnected.length > 0) console.log('❌ Disconnected:', disconnected.join(', '))
}
// Add recently seen relays that are no longer connected
const cutoffTime = now - RECENT_CONNECTION_WINDOW const cutoffTime = now - RECENT_CONNECTION_WINDOW
for (const [url, lastSeen] of relayLastSeen.entries()) { for (const [url, lastSeen] of relayLastSeen.entries()) {
if (!currentRelayUrls.has(url) && lastSeen >= cutoffTime) { if (!currentlyConnectedUrls.has(url) && lastSeen >= cutoffTime) {
statuses.push({ // Check if this relay is already in statuses (might be in pool but not connected)
url, const existingStatus = statuses.find(s => s.url === url)
isInPool: false, if (!existingStatus) {
lastSeen statuses.push({
}) url,
isInPool: false,
lastSeen
})
}
} }
} }

View File

@@ -39,6 +39,14 @@ export interface UserSettings {
zapSplitHighlighterWeight?: number // default 50 zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1 zapSplitBorisWeight?: number // default 2.1
zapSplitAuthorWeight?: number // default 50 zapSplitAuthorWeight?: number // default 50
// Relay rebroadcast settings
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
// 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( export async function loadSettings(

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react' 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 { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention' import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh // 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 }) 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 // Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers // Intentionally no exports except components and render helpers

View File

@@ -40,3 +40,26 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article', buttonText: 'READ NOW' } return { type: 'article', buttonText: 'READ NOW' }
} }
/**
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/
export const isLocalRelay = (relayUrl: string): boolean => {
return relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')
}
/**
* Checks if all relays in the list are local relays
*/
export const areAllRelaysLocal = (relayUrls: string[]): boolean => {
if (!relayUrls || relayUrls.length === 0) return false
return relayUrls.every(isLocalRelay)
}
/**
* Checks if at least one relay is a remote (non-local) relay
*/
export const hasRemoteRelay = (relayUrls: string[]): boolean => {
if (!relayUrls || relayUrls.length === 0) return false
return relayUrls.some(url => !isLocalRelay(url))
}

9
vercel.json Normal file
View File

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