Compare commits

...

216 Commits
v0.2.7 ... pwa

Author SHA1 Message Date
Gigi
fd374cd705 docs: remove temporary PWA launch checklist and implementation summary 2025-10-12 07:18:20 +01:00
Gigi
20b4658bef docs(pwa): update implementation summary to reflect final icons and next steps 2025-10-12 07:18:02 +01:00
Gigi
0850ba250c chore(lint): fix service worker typings and satisfy ESLint for worker globals 2025-10-12 07:15:52 +01:00
Gigi
b71d188fd8 chore: remove favicon zip file after extraction 2025-10-11 21:01:27 +01:00
Gigi
579f6b9a96 docs: update PWA docs to reflect branded icons are now in place 2025-10-11 20:58:56 +01:00
Gigi
d9403a73c6 feat(icons): replace placeholder icons with branded favicons
- Replace placeholder PWA icons with proper Boris-branded icons
- Add favicon.ico, favicon-16x16.png, favicon-32x32.png
- Add apple-touch-icon.png for iOS devices
- Update index.html with proper favicon links
- All icons extracted from boris-favicon.zip
- PWA icons now use android-chrome-192x192 and android-chrome-512x512
2025-10-11 20:58:26 +01:00
Gigi
747811fa94 docs: add PWA launch checklist 2025-10-11 20:43:13 +01:00
Gigi
489e480394 docs: add PWA implementation summary and guide 2025-10-11 20:42:29 +01:00
Gigi
418bcb0295 feat(pwa): upgrade to full PWA with vite-plugin-pwa
- Add web app manifest with proper metadata and icon support
- Configure vite-plugin-pwa with injectManifest strategy
- Migrate service worker to Workbox with precaching and runtime caching
- Add runtime caching for cross-origin images (preserves existing behavior)
- Add runtime caching for cross-origin article HTML for offline reading
- Create PWA install hook and UI component in settings
- Add online/offline status monitoring and toast notifications
- Add service worker update notifications
- Add placeholder PWA icons (192x192, 512x512, maskable variants)
- Update HTML with manifest link and theme-color meta tag
- Preserve existing relay/airplane mode functionality (WebSockets not intercepted)

The app now passes PWA installability criteria while maintaining all existing
offline functionality. Icons should be replaced with proper branded designs.
2025-10-11 20:41:49 +01:00
Gigi
88f01554e7 fix: improve mobile touch targets for highlight icons
- Increase touch target size to 44x44px on mobile (relay & delete icons)
- Add proper padding to both icons for larger tap area
- Increase spacing between relay and delete icons
- Expand quote icon container width on mobile to accommodate both targets
- Increase icon font size on mobile (0.85rem) for better visibility
- Move icons slightly outward (-8px) to prevent overlap

Icons are now much easier to tap on mobile devices without
accidentally hitting the wrong button.
2025-10-11 09:06:17 +01:00
Gigi
c85092a644 fix: color /me highlights with 'my highlights' color setting
- Set level: 'mine' on all highlights in /me page
- Highlights now use the customizable --highlight-color-mine CSS variable
- Quote icons and borders automatically match user's color preference
- Consistent styling with highlights shown elsewhere in the app
2025-10-11 09:04:48 +01:00
Gigi
096478bcec feat: add author info card for nostr-native articles
- Create AuthorCard component showing profile picture and bio
- Display author card after mark as read button
- Only shown for nostr-native articles (not external URLs)
- Fetch author profile data using applesauce ProfileModel
- Card displays author name, avatar, and bio (truncated to 3 lines)
- Responsive design with smaller avatar on mobile
- Elegant card styling matching app design system

Author information helps readers learn more about article authors
directly within the reading experience.
2025-10-11 08:55:37 +01:00
Gigi
b8de4a85e0 docs: update CHANGELOG for v0.4.3 release 2025-10-11 08:40:05 +01:00
Gigi
a5b7cedfaa chore: bump version to 0.4.3 2025-10-11 08:39:16 +01:00
Gigi
0adb8d6766 feat: add highlight deletion with confirmation dialog
- Create deletionService for NIP-09 kind:5 event deletion requests
- Add ConfirmDialog component for user confirmation before deletion
- Add subtle delete button to highlight items (trash icon)
- Only show delete button for user's own highlights
- Position delete button symmetrically opposite to relay indicator
- Add confirmation dialog to prevent accidental deletions
- Remove highlights from UI immediately after deletion request
- Style delete button with red hover color
- Add comprehensive confirmation dialog styling (danger/warning/info variants)

Implements NIP-09 Event Deletion Request.
Users can now delete their own highlights after confirming the action.
2025-10-11 08:38:22 +01:00
Gigi
6a6b8c4fad feat: add mark as read button for articles
- Create reactionService for handling kind:7 and kind:17 reactions
- Add mark as read button at the end of articles (📚 emoji)
- Use kind:7 reaction for nostr-native articles (/a/ paths)
- Use kind:17 reaction for external websites (/r/ paths)
- Pass activeAccount and currentArticle props through component tree
- Add responsive styling for mark as read button
- Button shows loading state while creating reaction
- Only visible when user is logged in

Implements NIP-25 (kind:7 reactions) and NIP-25 (kind:17 website reactions).
Users can now mark articles as read, creating a permanent record on nostr.
2025-10-11 08:34:36 +01:00
Gigi
4f952816ea feat: compact relay status indicator on mobile
- Show only icon on mobile (44x44px touch target)
- Tap to expand for full details
- Smooth transition between compact and expanded states
- Maintains full display on desktop
- Reduces screen clutter on mobile while keeping info accessible

Flight mode notice now just shows airplane icon on mobile.
Tap it to see full connection details.
2025-10-11 08:09:29 +01:00
Gigi
76835e2509 feat: add /me page to show user's recent highlights
- Create Me component to display logged-in user's highlights
- Add /me route to App routing
- Update SidebarHeader to navigate to /me when clicking profile avatar
- Integrate Me page in ThreePaneLayout (same as Settings/Explore)
- Show user profile info and highlight count
- List all highlights created by the user

Clicking the profile picture now takes you to your personal highlights page.
2025-10-11 08:07:20 +01:00
Gigi
63af770c83 docs: update CHANGELOG for v0.4.2 release 2025-10-11 03:25:11 +01:00
Gigi
165c427e5f chore: bump version to 0.4.2 2025-10-11 03:24:15 +01:00
Gigi
a0e30aa197 fix: resolve all linting and type errors
- Remove unused React import from nostrUriResolver
- Add block scoping to switch case statements
- Add react-hooks plugin to eslint config
- Fix exhaustive-deps warnings in components
- Fix DecodeResult type to use ReturnType<typeof decode>
- Update dependency arrays to include all used values
- Add eslint-disable comment for intentional dependency omission

All linting warnings resolved. TypeScript type checking passes.
2025-10-11 03:23:38 +01:00
Gigi
3a8203d26e fix: mobile button scroll detection on main pane element
- Update useScrollDirection to accept elementRef parameter
- Detect scroll on main pane div instead of window
- Create mainPaneRef and attach to scrollable content area
- Fix issue where scroll events weren't detected on mobile

On mobile, content scrolls within .pane.main (overflow-y: auto) not on window.
Now buttons properly hide on scroll down and show on scroll up.
2025-10-11 01:48:45 +01:00
Gigi
ffe848883e feat: resolve and display article titles for naddr references
- Add articleTitleResolver service to fetch article titles from relays
- Extract naddr identifiers from markdown content
- Fetch article titles in parallel using relay pool
- Replace naddr references with actual article titles
- Fallback to identifier if title fetch fails
- Update markdown processing to be async for title resolution
- Pass relayPool through component tree to enable resolution

Example: nostr:naddr1... now shows as "My Article Title" instead of "article:identifier"

Improves readability by showing human-friendly article titles in cross-references
2025-10-11 01:47:11 +01:00
Gigi
078a13c45d fix: link naddr articles internally instead of to njump
- Articles (naddr) now link to /a/{naddr} route (internal)
- Other nostr identifiers still link to njump.me (external)
- Improved article labels to show identifier instead of generic text
- Better UX: clicking article references opens them in-app
2025-10-11 01:43:54 +01:00
Gigi
8a69d5bc6b feat: resolve NIP-19 identifiers in article content
- Add nostrUriResolver utility to detect and replace nostr: URIs
- Support npub, note, nprofile, nevent, and naddr identifiers
- Convert nostr: URIs to clickable njump.me links
- Process markdown before rendering to handle nostr mentions
- Add CSS styling for nostr-uri-link class
- Implements NIP-19 and NIP-27 (nostr: URI scheme)

Nostr-native articles can now contain references like:
- nostr:npub1... → @npub1abc...
- nostr:note1... → note:note1abc...
- nostr:naddr1... → article:identifier

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

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

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

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00
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
Gigi
4ea03c9042 chore: bump version to 0.2.10 2025-10-09 12:33:38 +01:00
Gigi
4720416f2c fix(settings): remove trailing slash from relay URLs
- Update formatRelayUrl to strip trailing / from URLs
- Cleaner display of relay addresses
2025-10-09 12:31:30 +01:00
Gigi
8ad9e652fb feat(settings): highlight active zap split preset
- Add isPresetActive function to detect current preset
- Add 'active' class to preset button matching current weights
- Organize presets in a central object for easier maintenance
- Users can now see which preset is currently applied
2025-10-09 12:31:02 +01:00
Gigi
98c72389e2 refactor(settings): rename 'Default View Mode' to 'Default Bookmark View'
- More descriptive label clarifying this controls bookmark display
- Better indicates what view is being configured
2025-10-09 12:30:18 +01:00
Gigi
e032f432dd refactor(settings): move Show highlights checkbox after Default Highlight Visibility
- Reorder settings for better logical flow
- Show highlights toggle now appears after visibility controls
- Positioned right before the preview section
2025-10-09 12:29:49 +01:00
Gigi
852465bee7 fix(settings): constrain Reading Font dropdown width
- Wrap FontSelector in setting-control div
- Prevents dropdown from stretching across full page width
- Matches layout of other inline controls like color pickers
2025-10-09 12:29:13 +01:00
Gigi
39d0147cfa feat(routing): add /settings route and URL-based settings navigation
- Add /settings route to router
- Derive showSettings from location.pathname instead of state
- Update onOpenSettings to navigate to /settings
- Update onCloseSettings to navigate back to previous location
- Track previous location to restore context when closing settings
- Remove showSettings state from useBookmarksUI hook
2025-10-09 12:27:43 +01:00
Gigi
10cc7ce9b0 refactor(settings): move Default Highlight Visibility to Reading & Display
- Move setting from Startup Preferences to Reading & Display section
- Position above preview so changes are immediately visible
- Update preview to respect defaultHighlightVisibility settings
- Each highlight level (mine/friends/nostrverse) now toggles in preview
2025-10-09 12:24:50 +01:00
Gigi
6b8442ebdd refactor(settings): combine relay info into single paragraph
- Merge two separate paragraphs into one continuous text
- Remove line break between relay recommendations and educational links
2025-10-09 12:23:40 +01:00
Gigi
5aba283e92 refactor(settings): use sidebar-style colored buttons for highlight visibility
- Replace generic IconButton with colored level-toggle-btn elements
- Match the UI style from HighlightsPanelHeader in sidebar
- Show highlight colors (purple, orange, yellow) when active
- Use same CSS classes and structure for visual consistency
2025-10-09 12:23:05 +01:00
Gigi
59df232e2e refactor(settings): simplify Relays section by removing summary text
- Remove 'X active relays' summary count
- Remove 'Active' heading for cleaner UI
- Keep 'Recently Seen' heading for context since those are different
2025-10-09 12:21:36 +01:00
Gigi
702c001d46 feat(settings): add educational links about relays in reader view
- Add message with links to learn about relays (nostr.how and substack article)
- Links open in Boris's reader view via /r/ route instead of external tabs
- Close settings panel when links are clicked to show the content
- Use react-router navigation for seamless in-app experience
2025-10-09 12:21:09 +01:00
Gigi
48a9919db8 feat(reader): display article publication date
- Add published field to ReadableContent interface
- Pass published date from article loader through component chain
- Display formatted publication date in ReaderHeader with calendar icon
- Format date as 'MMMM d, yyyy' using date-fns
2025-10-09 12:15:28 +01:00
Gigi
d6d0755b89 feat(settings): add local relay recommendations to Relays section
- Add informational message recommending Citrine or nostr-relay-tray
- Include direct links to download pages for both local relay options
2025-10-09 12:13:41 +01:00
Gigi
facdd36145 feat(settings): add Relays section showing active and recently connected relays
- Add relayStatusService to track relay connections with 20-minute history
- Add useRelayStatus hook for polling relay status updates
- Create RelaySettings component to display active and recent relays
- Update Settings and ThreePaneLayout to integrate relay status display
- Shows relay connection status with visual indicators and timestamps
2025-10-09 12:09:53 +01:00
Gigi
5d379a280b chore: bump version to 0.2.9 2025-10-08 13:14:28 +01:00
Gigi
22a02d228d fix: deduplicate highlights in streaming callbacks
- Replace array accumulation with Map keyed by highlight ID
- Prevents duplicate highlights when same event arrives from multiple relays
- Fix applied in useArticleLoader and useBookmarksData hooks
- Only updates UI when new unique highlight arrives
- Resolves issue where highlights appeared twice in sidebar
2025-10-08 12:55:27 +01:00
Gigi
61fd5bbadc chore: bump version to 0.2.8 2025-10-08 12:42:24 +01:00
Gigi
d642c87527 fix: pass article summary through to ReadableContent
- Add summary field when converting ArticleContent to ReadableContent
- Fix contentLoader.ts to include article.summary
- Fix useArticleLoader.ts to include article.summary
- Article summaries now properly display in reader header
- Resolves missing summary display for kind:30023 articles
2025-10-08 12:41:03 +01:00
Gigi
fea425b5d0 feat: display article summary in header
- Add summary field to ReadableContent interface
- Pass summary through ContentPanel to ReaderHeader
- Display summary below title in both overlay and standard layouts
- Style summary with reading font for consistency
- Summary appears in white with shadow in image overlays
- Summary appears in gray (#aaa) in standard headers
- Enhances article preview and reading experience
2025-10-08 12:35:05 +01:00
Gigi
1609c6e580 feat: overlay title and metadata on hero images
- Position title and metadata absolutely over hero images
- Add gradient background for text readability (dark at bottom)
- Use backdrop-filter blur for metadata badges
- White text with shadow for better contrast
- Maintain original layout when no image present
- Creates more immersive reading experience
2025-10-08 12:30:00 +01:00
Gigi
270ea94c70 feat: apply reading font to article titles
- Add font-family: var(--reading-font) to .reader-title class
- Ensures consistent typography between titles and body text
- Titles now respect user's reading font preference from settings
2025-10-08 12:09:36 +01:00
Gigi
83e2f23357 chore: update homepage URL to read.withboris.com 2025-10-08 12:08:11 +01:00
Gigi
9df0261071 fix: correct Jina AI Reader proxy URL format
- Remove hardcoded http:// prefix in proxy URL
- Preserve original protocol (http/https) when constructing proxy URL
- Fix: https://r.jina.ai/https://example.com instead of /http://example.com
- Resolves metadata fetching issues for HTTPS URLs
2025-10-08 12:00:03 +01:00
Gigi
1dfe66651a refactor: reorder toolbar buttons
- New order: Profile, Home, Settings, Refresh, Plus, Logout
- Navigation first (Home, Settings)
- Actions in middle (Refresh, Plus)
- Logout at end
2025-10-08 11:58:28 +01:00
Gigi
dcb7933ede refactor: reorder toolbar buttons
- New order: Profile, Home, Refresh, Add, Settings, Logout
- Groups navigation (Profile, Home) at start
- Action buttons (Refresh, Add) in middle
- Settings and Logout at end
2025-10-08 11:48:19 +01:00
87 changed files with 9765 additions and 304 deletions

View File

@@ -0,0 +1,6 @@
---
description: anything related to UI/UX
alwaysApply: false
---
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)

4
.gitignore vendored
View File

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

View File

@@ -5,6 +5,305 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.3] - 2025-10-11
### Added
- Mark as read functionality for articles (NIP-25)
- Button at the end of each article to mark as read with 📚 emoji
- Creates kind:7 reactions for nostr-native articles (`/a/` paths)
- Creates kind:17 reactions for external websites (`/r/` paths)
- Button shows loading state while publishing reaction
- Only visible when user is logged in
- Highlight deletion with confirmation dialog (NIP-09)
- Small delete button (trash icon) on highlight items
- Only visible for user's own highlights
- Confirmation dialog prevents accidental deletions
- Styled to match relay indicator (subtle, same size)
- Removes highlights from UI immediately after deletion request
- `/me` page showing user's recent highlights
- Accessible by clicking profile picture in bookmark sidebar
- Displays all highlights created by the logged-in user
- Uses same rendering as Settings and Explore pages
- Includes highlight count in header
- Confirmation dialog component
- Reusable modal with danger/warning/info variants
- Backdrop blur effect
- Mobile-responsive design
- Prevents accidental destructive actions
### Changed
- Relay status indicator on mobile now displays in compact mode
- Shows only airplane icon by default (44x44px touch target)
- Tap to expand for full connection details
- Reduces screen clutter on mobile while keeping info accessible
- Smooth transition between compact and expanded states
- Desktop view remains unchanged (always shows full details)
## [0.4.2] - 2025-10-11
### Added
- NIP-19 identifier resolution in article content (NIP-19, NIP-27)
- Support for `nostr:npub1...`, `nostr:note1...`, `nostr:nprofile1...`, `nostr:nevent1...`, `nostr:naddr1...`
- Converts nostr: URIs to clickable links with human-readable labels
- Automatically fetches and displays article titles for `naddr` references
- Falls back to identifier when title fetch fails
- Auto-hide mobile UI buttons on scroll down
- Floating bookmark/highlights buttons hide when scrolling down
- Buttons reappear when scrolling up for distraction-free reading
- Smooth opacity transitions for better UX
- Scroll direction detection hook (`useScrollDirection`)
- Supports both window and element-based scroll detection
- Configurable threshold and enable/disable options
### Changed
- Article references (`naddr`) now link internally to `/a/{naddr}` instead of external njump.me
- Sidebar auto-closes on mobile when navigating to content via routes
- Handles clicking on blog posts in Explore view
- Complements existing sidebar auto-close for bookmarks
- Markdown processing now async to support article title resolution
- Article title resolution fetches titles in parallel for better performance
### Fixed
- Mobile button scroll detection now correctly monitors main pane element
- Previously monitored window scroll which didn't work on mobile
- Content scrolls within `.pane.main` div on mobile devices
- All ESLint warnings and TypeScript type errors resolved
- Added react-hooks plugin to ESLint configuration
- Fixed exhaustive-deps warnings in components
- Added block scoping to switch case statements
- Corrected type references for nostr-tools decode result
## [0.4.1] - 2025-10-10
### Fixed
- Long article summaries overlapping with hero image content on mobile devices
- Article summary now moves below hero image on mobile when longer than 150 characters
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
### Changed
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
- Improved image cropping with center positioning for better visual presentation
- Optimized reader header overlay padding and title sizing on mobile
## [0.4.0] - 2025-10-10
### Added
- Mobile-responsive design with overlay sidebar drawer
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
- Auto-collapse sidebar setting for mobile devices
- Touch-optimized UI with 44x44px minimum touch targets
- Safe area inset support for notched devices
- Mobile hamburger menu and backdrop
- Focus trap in mobile sidebar with ESC key support
- Body scroll locking when mobile sidebar is open
- Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toast notifications (bottom position)
- Dynamic viewport height support (100dvh)
- Mobile highlights panel as overlay with toggle button
### Changed
- Sidebar now displays as overlay drawer on mobile (≤768px)
- Highlights panel hidden on mobile for better content focus
- Sidebar auto-closes when selecting content on mobile
- Hover effects disabled on touch devices
- Replace hamburger icon with bookmark icon on mobile
### Fixed
- Ensure bookmarks container fills mobile sidepane properly
- Restore desktop grid layout for highlights panel
- Improve empty state and loading visibility in mobile sidepanes
- Add flex properties to mobile bookmark containers for proper filling
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
- Replace any type with proper bookmark interface for linter compliance
## [0.3.8] - 2025-10-10
### Fixed
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
## [0.3.7] - 2025-10-10
### Fixed
- Logout button functionality - now properly clears active account using clearActive() method
## [0.3.6] - 2025-10-10
### Added
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
- Ultra-compact date format for bookmarks sidebar
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
- Render /explore within ThreePaneLayout to keep side panels visible
### Fixed
- Remove incorrect padding-right from highlights container
- Reduce font size of highlight metadata for cleaner look
- Position highlight FAB button relative to article pane instead of viewport
- Adjust relay indicator position for better visual alignment
- Ensure highlight metadata elements align on single visual line with consistent line-height
- Prevent bookmark icons from being cut off in compact view
- Clean up nested borders in bookmark items and sidebar view mode controls
- Align highlight metadata elements on single line in sidebar
- Change explore header icon from compass to newspaper
### Changed
- Make connecting notification more subtle with muted blue background
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
## [0.3.5] - 2025-10-09
### Fixed
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
- Add Cloudflare Pages routing config for SPA paths
### Changed
- Extend connecting state duration and remove subtitle text for cleaner UI
## [0.3.4] - 2025-10-09
### Fixed
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
## [0.3.3] - 2025-10-09
### Added
- Service Worker for robust offline image caching
- /explore route to discover blog posts from friends on Nostr
- Explore button (newspaper icon) in bookmarks header
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
- Last fetch time display with relative timestamps in bookmarks list
### Changed
- Simplify image caching to use Service Worker transparently
- Move refresh button from top bar to end of bookmarks list
- Make explore page article cards proper links (supports CMD+click to open in new tab)
- Reorganize bookmarks UI for better UX
### Fixed
- Improve image cache resilience for offline viewing and hard reloads
- Correct TypeScript types for cache stats state
- Resolve linter errors for unused parameters
- Import useEventModel from applesauce-react/hooks for proper type safety
- Import Models from applesauce-core instead of applesauce-react
- Use correct useEventModel hook for profile loading in BlogPostCard
## [0.3.0] - 2025-10-09
### Added
- 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
### Added
@@ -359,6 +658,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
[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.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

156
MOBILE_IMPLEMENTATION.md Normal file
View File

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

View File

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

View File

@@ -2,22 +2,27 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Boris - Nostr Bookmarks</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://xn--bris-v0b.com/" />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://xn--bris-v0b.com/" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head>

4181
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"name": "boris",
"version": "0.2.7",
"version": "0.4.3",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://xn--bris-v0b.com/",
"homepage": "https://read.withboris.com/",
"type": "module",
"scripts": {
"dev": "vite",
@@ -40,7 +40,9 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0"
},
"eslintConfig": {
"root": true,
@@ -59,7 +61,8 @@
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"react-refresh"
"react-refresh",
"react-hooks"
],
"rules": {
"react-refresh/only-export-components": [
@@ -68,6 +71,7 @@
"allowConstantExport": true
}
],
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{

6
public/_routes.json Normal file
View File

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

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "Boris - Nostr Bookmarks",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#0f172a",
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

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

View File

@@ -11,6 +11,7 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
@@ -27,8 +28,7 @@ function AppRoutes({
const accountManager = Hooks.useAccountManager()
const handleLogout = () => {
accountManager.setActive(undefined as never)
localStorage.removeItem('active')
accountManager.clearActive()
showToast('Logged out successfully')
}
@@ -52,6 +52,33 @@ function AppRoutes({
/>
}
/>
<Route
path="/settings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -62,6 +89,7 @@ function App() {
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
const { toastMessage, toastType, showToast, clearToast } = useToast()
const isOnline = useOnlineStatus()
useEffect(() => {
const initializeApp = async () => {
@@ -109,6 +137,19 @@ function App() {
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
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
const addressLoader = createAddressLoader(pool, {
eventStore: store,
@@ -125,6 +166,12 @@ function App() {
return () => {
accountsSub.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()
}
}
}
@@ -138,6 +185,25 @@ function App() {
}
}, [])
// Monitor online/offline status
useEffect(() => {
if (!isOnline) {
showToast('You are offline. Some features may be limited.')
}
}, [isOnline, showToast])
// Listen for service worker updates
useEffect(() => {
const handleSWUpdate = () => {
showToast('New version available! Refresh to update.')
}
window.addEventListener('sw-update-available', handleSWUpdate)
return () => {
window.removeEventListener('sw-update-available', handleSWUpdate)
}
}, [showToast])
if (!eventStore || !accountManager || !relayPool) {
return (
<div className="loading">

View File

@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
clearTimeout(fetchTimeoutRef.current)
}
}
}, [url]) // Only depend on url
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface AuthorCardProps {
authorPubkey: string
}
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
}
const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about
return (
<div className="author-card">
<div className="author-card-avatar">
{authorImage ? (
<img src={authorImage} alt={getAuthorName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<div className="author-card-content">
<div className="author-card-name">{getAuthorName()}</div>
{authorBio && (
<p className="author-card-bio">{authorBio}</p>
)}
</div>
</div>
)
}
export default AuthorCard

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 { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
import { UserSettings } from '../services/settingsService'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
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 short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
settings
}
if (viewMode === 'compact') {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
@@ -10,7 +10,11 @@ import { useBookmarksData } from '../hooks/useBookmarksData'
import { useContentSelection } from '../hooks/useContentSelection'
import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -23,10 +27,23 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3))
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showMe = location.pathname === '/me'
// Track previous location for going back from settings/me/explore
useEffect(() => {
if (!showSettings && !showMe && !showExplore) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -39,7 +56,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
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 {
isMobile,
isSidebarOpen,
toggleSidebar,
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
@@ -50,8 +82,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,
@@ -62,6 +92,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightVisibility
} = useBookmarksUI({ settings })
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
useEffect(() => {
if (isMobile && isSidebarOpen) {
toggleSidebar()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
const {
bookmarks,
bookmarksLoading,
@@ -71,6 +109,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
@@ -79,7 +118,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
settings
})
const {
@@ -89,15 +129,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderLoading,
readerContent,
setReaderContent,
handleSelectUrl
handleSelectUrl: baseHandleSelectUrl
} = useContentSelection({
relayPool,
settings,
setIsCollapsed,
setShowSettings,
setShowSettings: () => {}, // No-op since we use route-based settings now
setCurrentArticle
})
// Wrap handleSelectUrl to close mobile sidebar when selecting content
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (isMobile && isSidebarOpen) {
toggleSidebar()
}
baseHandleSelectUrl(url, bookmark)
}
const {
highlightButtonRef,
handleTextSelection,
@@ -106,6 +154,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useHighlightCreation({
activeAccount,
relayPool,
eventStore,
currentArticle,
selectedUrl,
readerContent,
@@ -125,7 +174,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
setCurrentArticle,
settings
})
// Load external URL if /r/* route is used
@@ -151,27 +201,40 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
<ThreePaneLayout
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
lastFetchTime={lastFetchTime}
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => {
setShowSettings(true)
setIsCollapsed(true)
navigate('/settings')
if (isMobile) {
toggleSidebar()
} else {
setIsCollapsed(true)
}
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshAll}
relayPool={relayPool}
eventStore={eventStore}
readerLoading={readerLoading}
readerContent={readerContent}
selectedUrl={selectedUrl}
settings={settings}
onSaveSettings={saveSettings}
onCloseSettings={() => setShowSettings(false)}
onCloseSettings={() => {
// Navigate back to previous location or default
const backTo = previousLocationRef.current || '/'
navigate(backTo)
}}
classifiedHighlights={classifiedHighlights}
showHighlights={showHighlights}
selectedHighlightId={selectedHighlightId}
@@ -184,6 +247,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
activeAccount={activeAccount}
currentArticle={currentArticle}
highlights={highlights}
highlightsLoading={highlightsLoading}
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
@@ -194,6 +259,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'warning'
}) => {
if (!isOpen) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className={`confirm-dialog-icon ${variant}`}>
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<h3 className="confirm-dialog-title">{title}</h3>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-btn cancel"
onClick={onCancel}
>
{cancelText}
</button>
<button
className={`confirm-dialog-btn confirm ${variant}`}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
export default ConfirmDialog

View File

@@ -1,8 +1,11 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers'
@@ -11,6 +14,9 @@ import { HighlightVisibility } from './HighlightsPanel'
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
import AuthorCard from './AuthorCard'
interface ContentPanelProps {
loading: boolean
@@ -19,6 +25,8 @@ interface ContentPanelProps {
markdown?: string
selectedUrl?: string
image?: string
summary?: string
published?: number
highlights?: Highlight[]
showHighlights?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -28,6 +36,10 @@ interface ContentPanelProps {
highlightVisibility?: HighlightVisibility
currentUserPubkey?: string
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -40,10 +52,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
markdown,
selectedUrl,
image,
summary,
published,
highlights = [],
showHighlights = true,
highlightStyle = 'marker',
highlightColor = '#ffff00',
settings,
relayPool,
activeAccount,
currentArticle,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -52,7 +70,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
html,
@@ -67,7 +86,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
followedPubkeys
})
const { contentRef, handleMouseUp } = useHighlightInteractions({
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -83,6 +102,44 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const handleMarkAsRead = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot mark as read: no account or relay pool')
return
}
setIsMarkingAsRead(true)
try {
if (isNostrArticle && currentArticle) {
// Kind 7 reaction for nostr-native articles
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
// Kind 17 reaction for external websites
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
} finally {
setIsMarkingAsRead(false)
}
}
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -109,7 +166,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{processedMarkdown || markdown}
</ReactMarkdown>
</div>
)}
@@ -117,34 +174,63 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<ReaderHeader
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
/>
{markdown || html ? (
markdown ? (
renderedMarkdownHtml && finalHtml ? (
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
</div>
)
) : (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleMouseUp}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
)}
{/* Mark as Read button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className="mark-as-read-btn"
onClick={handleMarkAsRead}
disabled={isMarkingAsRead}
title="Mark as Read"
>
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
</button>
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp}
/>
)
)}
{/* Author info card for nostr-native articles */}
{isNostrArticle && currentArticle && (
<div className="author-card-container">
<AuthorCard authorPubkey={currentArticle.pubkey} />
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

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,18 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -15,10 +23,30 @@ interface HighlightItemProps {
onSelectUrl?: (url: string) => void
isSelected?: boolean
onHighlightClick?: (highlightId: string) => void
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
isSelected,
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate,
onHighlightDelete
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const activeAccount = Hooks.useActiveAccount()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -30,6 +58,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
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(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -51,14 +112,191 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
const getSourceLink = () => {
if (highlight.eventReference) {
return `https://search.dergigi.com/e/${highlight.eventReference}`
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
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()
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteConfirm(true)
}
const handleConfirmDelete = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot delete: no account or relay pool')
return
}
setIsDeleting(true)
setShowDeleteConfirm(false)
try {
await createDeletionRequest(
highlight.id,
9802, // kind for highlights
'Deleted by user',
activeAccount,
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {
onHighlightDelete(highlight.id)
}
} catch (error) {
console.error('Failed to delete highlight:', error)
} finally {
setIsDeleting(false)
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
return (
<>
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
@@ -68,6 +306,25 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
>
<div className="highlight-quote-icon">
<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>
)}
{canDelete && (
<div
className="highlight-delete-btn"
title="Delete highlight"
onClick={handleDeleteClick}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</div>
)}
</div>
<div className="highlight-content">
@@ -88,7 +345,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (
@@ -106,6 +363,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -6,6 +6,8 @@ import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
export interface HighlightVisibility {
nostrverse: boolean
@@ -28,6 +30,8 @@ interface HighlightsPanelProps {
highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string>
relayPool?: RelayPool | null
eventStore?: IEventStore | null
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -44,9 +48,12 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
currentUserPubkey,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightVisibilityChange,
followedPubkeys = new Set()
followedPubkeys = new Set(),
relayPool,
eventStore
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
const handleToggleHighlights = () => {
const newValue = !showHighlights
@@ -54,8 +61,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
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 handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
}
const filteredHighlights = useFilteredHighlights({
highlights,
highlights: localHighlights,
selectedUrl,
highlightVisibility,
currentUserPubkey,
@@ -108,6 +131,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onSelectUrl={onSelectUrl}
isSelected={highlight.id === selectedHighlightId}
onHighlightClick={onHighlightClick}
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>

View File

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

119
src/components/Me.tsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
interface MeProps {
relayPool: RelayPool
}
const Me: React.FC<MeProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [highlights, setHighlights] = useState<Highlight[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
useEffect(() => {
const loadHighlights = async () => {
if (!activeAccount) {
setError('Please log in to view your highlights')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Fetch highlights created by the user
const userHighlights = await fetchHighlights(
relayPool,
activeAccount.pubkey
)
if (userHighlights.length === 0) {
setError('No highlights yet. Start highlighting content to see them here!')
}
setHighlights(userHighlights)
} catch (err) {
console.error('Failed to load highlights:', err)
setError('Failed to load highlights. Please try again.')
} finally {
setLoading(false)
}
}
loadHighlights()
}, [relayPool, activeAccount])
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setHighlights(prev => prev.filter(h => h.id !== highlightId))
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading your highlights...</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={faUser} />
{getUserDisplayName()}
</h1>
<p className="explore-subtitle">
<FontAwesomeIcon icon={faHighlighter} /> {highlights.length} highlight{highlights.length !== 1 ? 's' : ''}
</p>
</div>
<div className="highlights-list me-highlights-list">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
</div>
)
}
export default Me

View File

@@ -1,32 +1,86 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { UserSettings } from '../services/settingsService'
interface ReaderHeaderProps {
title?: string
image?: string
summary?: string
published?: number
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
settings?: UserSettings
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title,
image,
summary,
published,
readingTimeText,
hasHighlights,
highlightCount
highlightCount,
settings
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
if (cachedImage) {
return (
<>
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
</div>
{isLongSummary && (
<div className="reader-summary-below-image">
<p className="reader-summary">{summary}</p>
</div>
)}
</>
)
}
return (
<>
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
</div>
)}
{title && (
<div className="reader-header">
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">

View File

@@ -0,0 +1,116 @@
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'
import { useIsMobile } from '../hooks/useMediaQuery'
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)
const [isExpanded, setIsExpanded] = useState(false)
const isMobile = useIsMobile()
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, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
const handleClick = () => {
if (isMobile) {
setIsExpanded(!isExpanded)
}
}
const showDetails = !isMobile || isExpanded
return (
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
title={
!isMobile ? (
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
) : undefined
}
onClick={handleClick}
style={{ cursor: isMobile ? 'pointer' : 'default' }}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
{showDetails && (
<>
<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

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
@@ -7,6 +8,10 @@ import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -15,7 +20,7 @@ const DEFAULT_SETTINGS: UserSettings = {
sidebarCollapsed: true,
highlightsCollapsed: true,
readingFont: 'source-serif-4',
fontSize: 18,
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColorNostrverse: '#9333ea',
@@ -27,15 +32,18 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
}
interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
onClose: () => void
relayPool: RelayPool | null
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
// Migrate old settings format to new weight-based format
const migrated = { ...settings }
@@ -52,6 +60,9 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const isInitialMount = useRef(true)
const saveTimeoutRef = useRef<number | null>(null)
const isLocallyUpdating = useRef(false)
// Poll for relay status updates
const relayStatuses = useRelayStatus({ relayPool })
useEffect(() => {
// Don't update from external settings if we're currently making local changes
@@ -152,6 +163,9 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
</div>
)

View File

@@ -14,7 +14,7 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<h3 className="section-title">Layout & Navigation</h3>
<div className="setting-group setting-inline">
<label>Default View Mode</label>
<label>Default Bookmark View</label>
<div className="setting-buttons">
<IconButton
icon={faList}

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

@@ -0,0 +1,84 @@
import React from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall'
const PWASettings: React.FC = () => {
const { isInstallable, isInstalled, installApp } = usePWAInstall()
const handleInstall = async () => {
const success = await installApp()
if (success) {
console.log('App installed successfully')
}
}
if (isInstalled) {
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
<span>Boris is installed as an app</span>
</div>
<p className="setting-description">
You can launch Boris from your home screen or app drawer.
</p>
</div>
</div>
)
}
if (!isInstallable) {
return null
}
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
<span>Install Boris as an app</span>
</div>
<p className="setting-description">
Install Boris on your device for a native app experience with offline support.
</p>
<button
onClick={handleInstall}
className="install-button"
style={{
marginTop: '12px',
padding: '8px 16px',
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
</div>
</div>
)
}
export default PWASettings

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { faHighlighter, faUnderline } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
@@ -19,42 +20,33 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="settings-section">
<h3 className="section-title">Reading & Display</h3>
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
<div className="setting-group setting-inline">
<label>Font Size</label>
<div className="setting-buttons">
{[14, 16, 18, 20, 22].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 18) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
<label>Font Size</label>
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
@@ -107,20 +99,66 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 18}px`,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
</div>
</div>

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faWifi, faClock, faPlane } from '@fortawesome/free-solid-svg-icons'
import { RelayStatus } from '../../services/relayStatusService'
import { formatDistanceToNow } from 'date-fns'
import { isLocalRelay } from '../../utils/helpers'
interface RelaySettingsProps {
relayStatuses: RelayStatus[]
onClose?: () => void
}
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
}
const formatLastSeen = (timestamp: number) => {
try {
return formatDistanceToNow(timestamp, { addSuffix: true })
} catch {
return 'just now'
}
}
// 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 (
<div className="settings-section">
<h3 className="section-title">Relays</h3>
{sortedRelays.length > 0 && (
<div className="relay-group">
<div className="relay-list">
{sortedRelays.map((relay) => {
const iconConfig = getRelayIcon(relay)
const isDisconnected = !relay.isInPool
return (
<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',
opacity: isDisconnected ? 0.7 : 1
}}
>
<FontAwesomeIcon
icon={iconConfig.icon}
style={{
color: iconConfig.color,
fontSize: iconConfig.size
}}
/>
<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>
{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>
)}
{relayStatuses.length === 0 && (
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
No relay connections found
</p>
)}
</div>
)
}
export default RelaySettings

View File

@@ -1,7 +1,5 @@
import React from 'react'
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface StartupPreferencesSettingsProps {
settings: UserSettings
@@ -11,7 +9,7 @@ interface StartupPreferencesSettingsProps {
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup Preferences</h3>
<h3 className="section-title">Startup & Behavior</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
@@ -39,31 +37,30 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
</label>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="setting-buttons">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant={(settings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
<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"
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant={(settings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
<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"
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant={(settings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
/>
</div>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div>
)

View File

@@ -17,6 +17,19 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
const presets = {
default: { highlighter: 50, boris: 2.1, author: 50 },
generous: { highlighter: 5, boris: 10, author: 75 },
selfless: { highlighter: 1, boris: 19, author: 80 },
boris: { highlighter: 10, boris: 80, author: 10 },
}
const isPresetActive = (preset: { highlighter: number; boris: number; author: number }) => {
return highlighterWeight === preset.highlighter &&
borisWeight === preset.boris &&
authorWeight === preset.author
}
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
onUpdate({
zapSplitHighlighterWeight: preset.highlighter,
@@ -33,29 +46,29 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset({ highlighter: 50, boris: 2.1, author: 50 })}
className="zap-preset-btn"
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset({ highlighter: 5, boris: 10, author: 75 })}
className="zap-preset-btn"
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset({ highlighter: 1, boris: 19, author: 80 })}
className="zap-preset-btn"
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset({ highlighter: 10, boris: 80, author: 10 })}
className="zap-preset-btn"
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡

View File

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

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 { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
import { HighlightButton } from './HighlightButton'
import { RelayStatusIndicator } from './RelayStatusIndicator'
import { ViewMode } from './Bookmarks'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
@@ -14,24 +18,33 @@ import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
interface ThreePaneLayoutProps {
// Layout state
isCollapsed: boolean
isHighlightsCollapsed: boolean
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
showMe?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
// Content pane
readerLoading: boolean
@@ -49,6 +62,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane
highlights: Highlight[]
@@ -68,17 +83,188 @@ interface ThreePaneLayoutProps {
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
// Optional Me content
me?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const isMobile = useIsMobile()
const sidebarRef = useRef<HTMLDivElement>(null)
const highlightsRef = useRef<HTMLDivElement>(null)
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// On mobile, scroll happens in the main pane, not on window
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
elementRef: mainPaneRef
})
const showMobileButtons = scrollDirection !== 'down'
// 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(() => {
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
if (!isMobile) return
if (!isSidebarOpen && isHighlightsCollapsed) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isSidebarOpen) {
onToggleSidebar()
} else if (!isHighlightsCollapsed) {
onToggleHighlightsPanel()
}
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isMobile, props])
// Trap focus in sidebar when open on mobile
useEffect(() => {
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
const sidebar = sidebarRef.current
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
sidebar.addEventListener('keydown', handleTab)
firstElement?.focus()
return () => {
sidebar.removeEventListener('keydown', handleTab)
}
}, [isMobile, props.isSidebarOpen])
// Trap focus in highlights panel when open on mobile
useEffect(() => {
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
const highlights = highlightsRef.current
const focusableElements = highlights.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
highlights.addEventListener('keydown', handleTab)
firstElement?.focus()
return () => {
highlights.removeEventListener('keydown', handleTab)
}
}, [isMobile, props.isHighlightsCollapsed])
const handleBackdropClick = () => {
if (isMobile) {
if (props.isSidebarOpen) {
props.onToggleSidebar()
} else if (!props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}
}
return (
<>
{/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
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 ${showMobileButtons ? 'visible' : 'hidden'}`}
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
>
<FontAwesomeIcon icon={faHighlighter} />
</button>
)}
{/* Mobile backdrop */}
{isMobile && (
<div
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
)}
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
>
<BookmarkList
bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed}
isCollapsed={isMobile ? false : props.isCollapsed}
onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout}
viewMode={props.viewMode}
@@ -87,17 +273,34 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
/>
</div>
<div className="pane main">
<div
ref={mainPaneRef}
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
>
{props.showSettings ? (
<Settings
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : props.showMe && props.me ? (
// Render Me inside the main pane to keep side panels
<>
{props.me}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -105,6 +308,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
showHighlights={props.showHighlights}
@@ -117,10 +322,18 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
/>
)}
</div>
<div className="pane highlights">
<div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
>
<HighlightsPanel
highlights={props.highlights}
loading={props.highlightsLoading}
@@ -136,6 +349,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightVisibility={props.highlightVisibility}
onHighlightVisibilityChange={props.onHighlightVisibilityChange}
followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
/>
</div>
</div>
@@ -146,6 +361,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightColor={props.settings.highlightColor || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />
{props.toastMessage && (
<Toast
message={props.toastMessage}

View File

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

View File

@@ -5,6 +5,7 @@ import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface UseArticleLoaderProps {
naddr: string | undefined
@@ -18,6 +19,7 @@ interface UseArticleLoaderProps {
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void
settings?: UserSettings
}
export function useArticleLoader({
@@ -31,7 +33,8 @@ export function useArticleLoader({
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
@@ -44,11 +47,13 @@ export function useArticleLoader({
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
@@ -71,19 +76,23 @@ export function useArticleLoader({
try {
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
const highlightsList: Highlight[] = []
const highlightsMap = new Map<string, Highlight>()
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Render each highlight immediately as it arrives
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
)
console.log(`📌 Found ${highlightsList.length} highlights`)
console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -101,5 +110,5 @@ export function useArticleLoader({
}
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 { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -15,6 +16,7 @@ interface UseBookmarksDataParams {
naddr?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
}
export const useBookmarksData = ({
@@ -23,7 +25,8 @@ export const useBookmarksData = ({
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -31,6 +34,7 @@ export const useBookmarksData = ({
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -43,11 +47,11 @@ export const useBookmarksData = ({
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
} finally {
setBookmarksLoading(false)
}
}, [relayPool, activeAccount, accountManager])
}, [relayPool, activeAccount, accountManager, settings])
const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return
@@ -55,19 +59,24 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
const highlightsList: Highlight[] = []
const highlightsMap = new Map<string, Highlight>()
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId,
(highlight) => {
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
)
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights)
}
} catch (err) {
@@ -75,7 +84,7 @@ export const useBookmarksData = ({
} finally {
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId])
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -85,6 +94,7 @@ export const useBookmarksData = ({
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
@@ -100,7 +110,7 @@ export const useBookmarksData = ({
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,
@@ -111,6 +121,7 @@ export const useBookmarksData = ({
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll

View File

@@ -3,18 +3,20 @@ import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService'
import { ViewMode } from '../components/Bookmarks'
import { useIsMobile } from './useMediaQuery'
interface UseBookmarksUIParams {
settings: UserSettings
}
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const isMobile = useIsMobile()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showHighlights, setShowHighlights] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined)
@@ -24,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
mine: true
})
// Auto-collapse sidebar on mobile based on settings
useEffect(() => {
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
if (isMobile && autoCollapse) {
setIsSidebarOpen(false)
} else if (!isMobile) {
setIsSidebarOpen(true)
}
}, [isMobile, settings.autoCollapseSidebarOnMobile])
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
@@ -35,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
})
}, [settings])
const toggleSidebar = () => {
setIsSidebarOpen(prev => !prev)
}
return {
isMobile,
isSidebarOpen,
setIsSidebarOpen,
toggleSidebar,
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
@@ -46,8 +66,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,

View File

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

View File

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

View File

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

@@ -1,34 +1,86 @@
import React, { useState, useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
* Also processes nostr: URIs in the markdown and resolves article titles
*/
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
export const useMarkdownToHTML = (
markdown?: string,
relayPool?: RelayPool | null
): {
renderedHtml: string
previewRef: React.RefObject<HTMLDivElement>
processedMarkdown: string
} => {
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
let isCancelled = false
const processMarkdown = async () => {
// Extract all naddr references
const naddrs = extractNaddrUris(markdown)
let processed: string
if (naddrs.length > 0 && relayPool) {
// Fetch article titles for all naddrs
try {
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
if (isCancelled) return
// Replace nostr URIs with resolved titles
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
console.log(`📚 Resolved ${articleTitles.size} article titles`)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
})
if (isCancelled) return
setProcessedMarkdown(processed)
return () => cancelAnimationFrame(rafId)
}, [markdown])
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
return { renderedHtml, previewRef }
return () => cancelAnimationFrame(rafId)
}
processMarkdown()
return () => {
isCancelled = true
}
}, [markdown, relayPool])
return { renderedHtml, previewRef, processedMarkdown }
}
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef

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

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online')
setIsOnline(true)
}
const handleOffline = () => {
console.log('📴 Gone offline')
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstallable, setIsInstallable] = useState(false)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
// Check if app is already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true)
return
}
// Listen for the beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
const installPromptEvent = e as BeforeInstallPromptEvent
setDeferredPrompt(installPromptEvent)
setIsInstallable(true)
}
// Listen for successful installation
const handleAppInstalled = () => {
setIsInstalled(true)
setIsInstallable(false)
setDeferredPrompt(null)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}, [])
const installApp = async () => {
if (!deferredPrompt) {
return false
}
try {
await deferredPrompt.prompt()
const choiceResult = await deferredPrompt.userChoice
if (choiceResult.outcome === 'accepted') {
console.log('✅ PWA installed')
setIsInstallable(false)
setDeferredPrompt(null)
return true
} else {
console.log('❌ PWA installation dismissed')
return false
}
} catch (error) {
console.error('Error installing PWA:', error)
return false
}
}
return {
isInstallable,
isInstalled,
installApp,
}
}

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
interface UseRelayStatusParams {
relayPool: RelayPool | null
pollingInterval?: number // in milliseconds
}
export function useRelayStatus({
relayPool,
pollingInterval = 20000
}: UseRelayStatusParams) {
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
useEffect(() => {
if (!relayPool) return
const updateStatuses = () => {
const statuses = updateAndGetRelayStatuses(relayPool)
setRelayStatuses(statuses)
}
// Initial update
updateStatuses()
// Poll for updates
const interval = setInterval(updateStatuses, pollingInterval)
return () => {
clearInterval(interval)
}
}, [relayPool, pollingInterval])
return relayStatuses
}

View File

@@ -0,0 +1,70 @@
import { useState, useEffect, RefObject } from 'react'
export type ScrollDirection = 'up' | 'down' | 'none'
interface UseScrollDirectionOptions {
threshold?: number
enabled?: boolean
elementRef?: RefObject<HTMLElement>
}
/**
* Hook to detect scroll direction on window or a specific element
* @param options Configuration options
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
* @param options.enabled Whether scroll detection is enabled (default: true)
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
* @returns Current scroll direction ('up', 'down', or 'none')
*/
export function useScrollDirection({
threshold = 10,
enabled = true,
elementRef
}: UseScrollDirectionOptions = {}): ScrollDirection {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
useEffect(() => {
if (!enabled) return
const scrollElement = elementRef?.current || window
const getScrollY = () => {
if (elementRef?.current) {
return elementRef.current.scrollTop
}
return window.scrollY
}
let lastScrollY = getScrollY()
let ticking = false
const updateScrollDirection = () => {
const scrollY = getScrollY()
// Only update if scroll distance exceeds threshold
if (Math.abs(scrollY - lastScrollY) < threshold) {
ticking = false
return
}
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
lastScrollY = scrollY > 0 ? scrollY : 0
ticking = false
}
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDirection)
ticking = true
}
}
scrollElement.addEventListener('scroll', onScroll)
return () => {
scrollElement.removeEventListener('scroll', onScroll)
}
}, [threshold, enabled, elementRef])
return scrollDirection
}

View File

@@ -58,7 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Apply font settings after font is loaded
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
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,42 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
// Handle service worker updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('🔄 New version available! Reload to update.')
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}
})
}
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

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

View File

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
const { getArticleTitle } = Helpers
/**
* Fetch article title for a single naddr
* Returns the title or null if not found/error
*/
export async function fetchArticleTitle(
relayPool: RelayPool,
naddr: string
): Promise<string | null> {
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
if (events.length === 0) {
return null
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
return getArticleTitle(article) || null
} catch (err) {
console.warn('Failed to fetch article title for', naddr, err)
return null
}
}
/**
* Fetch titles for multiple naddrs in parallel
* Returns a map of naddr -> title
*/
export async function fetchArticleTitles(
relayPool: RelayPool,
naddrs: string[]
): Promise<Map<string, string>> {
const titleMap = new Map<string, string>()
// Fetch all titles in parallel
const results = await Promise.allSettled(
naddrs.map(async (naddr) => {
const title = await fetchArticleTitle(relayPool, naddr)
return { naddr, title }
})
)
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value.title) {
titleMap.set(result.value.naddr, result.value.title)
}
})
return titleMap
}

View File

@@ -14,13 +14,16 @@ import {
} from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
export const fetchBookmarks = async (
relayPool: RelayPool,
activeAccount: unknown, // Full account object with extension capabilities
setBookmarks: (bookmarks: Bookmark[]) => void
setBookmarks: (bookmarks: Bookmark[]) => void,
settings?: UserSettings
) => {
try {
@@ -37,6 +40,9 @@ export const fetchBookmarks = async (
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
)
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
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)

View File

@@ -0,0 +1,48 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
/**
* Creates a kind:5 event deletion request (NIP-09)
* @param eventId The ID of the event to delete
* @param eventKind The kind of the event being deleted
* @param reason Optional reason for deletion
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed deletion request event
*/
export async function createDeletionRequest(
eventId: string,
eventKind: number,
reason: string | undefined,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 5, // Event Deletion Request
content: reason || '',
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
return signed
}

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 { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const {
getHighlightText,
@@ -26,17 +29,18 @@ const { HighlightBlueprint } = Blueprints
/**
* Creates and publishes a highlight event (NIP-84)
* 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(
selectedText: string,
source: NostrEvent | string,
account: IAccount,
relayPool: RelayPool,
eventStore: IEventStore,
contentForContext?: string,
comment?: string,
settings?: UserSettings
): Promise<NostrEvent> {
): Promise<Highlight> {
if (!selectedText || !source) {
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
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) {
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
} else {
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com'])
}
// Add p tag (author tag) for nostr-native content
// This tags the original author so they can see highlights of their work
if (typeof source === 'object' && 'kind' in source) {
// Only add p tag if it doesn't already exist
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
if (!hasPTag) {
highlightEvent.tags.push(['p', source.pubkey])
}
}
// Add zap tags for nostr-native content (NIP-57 Appendix G)
@@ -104,13 +118,60 @@ export async function createHighlight(
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Publish to relays (including local relay)
await relayPool.publish(RELAYS, signedEvent)
// Publish to all configured relays - let the relay pool handle connection state
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
return signedEvent
// Check current connection status - are we online or in flight mode?
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 { RELAYS } from '../config/relays'
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
* @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 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 (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
@@ -75,6 +80,9 @@ export const fetchHighlightsForArticle = async (
const rawEvents = [...aTagEvents, ...eTagEvents]
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) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else {
@@ -99,10 +107,12 @@ export const fetchHighlightsForArticle = async (
* Fetches highlights for a specific URL
* @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string
url: string,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
@@ -124,6 +134,9 @@ export const fetchHighlightsForUrl = async (
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 highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
@@ -138,11 +151,13 @@ export const fetchHighlightsForUrl = async (
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
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)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents)
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,103 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
const MARK_AS_READ_EMOJI = '📚'
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
* @param eventId The ID of the event being reacted to
* @param eventAuthor The pubkey of the event author
* @param eventKind The kind of the event being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createEventReaction(
eventId: string,
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['p', eventAuthor],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed
}
/**
* Creates a kind:17 reaction to a website (for external URLs)
* @param url The URL being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createWebsiteReaction(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
let normalizedUrl = url
try {
const parsed = new URL(url)
// Remove fragment
parsed.hash = ''
normalizedUrl = parsed.toString()
// Remove trailing slash if present
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const tags: string[][] = [
['r', normalizedUrl]
]
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
return signed
}

View File

@@ -7,6 +7,8 @@ export interface ReadableContent {
html?: string
markdown?: string
image?: string
summary?: string
published?: number
}
interface CachedContent {
@@ -57,7 +59,7 @@ function saveToCache(url: string, content: ReadableContent): void {
function toProxyUrl(url: string): string {
// Ensure the target URL has a protocol and build the proxy URL
const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`
return `https://r.jina.ai/http://${normalized.replace(/^https?:\/\//, '')}`
return `https://r.jina.ai/${normalized}`
}
export async function fetchReadableContent(

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

@@ -0,0 +1,85 @@
import { RelayPool } from 'applesauce-relay'
export interface RelayStatus {
url: string
isInPool: boolean
lastSeen: number // timestamp
}
// 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
const relayLastSeen = new Map<string, number>()
/**
* Updates and gets the current status of all relays
*/
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const statuses: RelayStatus[] = []
const now = Date.now()
const currentlyConnectedUrls = new Set<string>()
// Check all relays in the pool for their actual connection status
for (const relay of relayPool.relays.values()) {
const isConnected = relay.connected
if (isConnected) {
currentlyConnectedUrls.add(relay.url)
relayLastSeen.set(relay.url, now)
}
statuses.push({
url: relay.url,
isInPool: isConnected,
lastSeen: isConnected ? now : (relayLastSeen.get(relay.url) || now)
})
}
// 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
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (!currentlyConnectedUrls.has(url) && lastSeen >= cutoffTime) {
// Check if this relay is already in statuses (might be in pool but not connected)
const existingStatus = statuses.find(s => s.url === url)
if (!existingStatus) {
statuses.push({
url,
isInPool: false,
lastSeen
})
}
}
}
// Clean up old entries
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (lastSeen < cutoffTime) {
relayLastSeen.delete(url)
}
}
return statuses.sort((a, b) => {
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
return b.lastSeen - a.lastSeen
})
}
/**
* Gets count of currently active relays
*/
export function getActiveCount(statuses: RelayStatus[]): number {
return statuses.filter(r => r.isInPool).length
}

View File

@@ -39,6 +39,14 @@ export interface UserSettings {
zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1
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(

111
src/sw.ts Normal file
View File

@@ -0,0 +1,111 @@
/// <reference lib="webworker" />
/* eslint-env worker */
/* global ServiceWorkerGlobalScope, ExtendableMessageEvent, FetchEvent */
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
// Narrow the global service worker scope for proper typings
const sw = self as unknown as ServiceWorkerGlobalScope
// Precache all build assets (app shell)
// @ts-ignore - __WB_MANIFEST is injected by vite-plugin-pwa
precacheAndRoute(self.__WB_MANIFEST)
// Clean up old caches
cleanupOutdatedCaches()
// Take control immediately
sw.skipWaiting()
clientsClaim()
console.log('[SW] Boris service worker loaded')
// Runtime cache: Cross-origin images
// This preserves the existing image caching behavior
registerRoute(
({ request, url }) => {
const isImage = request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
return isImage && url.origin !== sw.location.origin
},
new StaleWhileRevalidate({
cacheName: 'boris-images',
plugins: [
new ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
)
// Runtime cache: Cross-origin article HTML
// Cache fetched articles for offline reading
registerRoute(
({ request, url }) => {
const accept = request.headers.get('accept') || ''
const isHTML = accept.includes('text/html')
const isCrossOrigin = url.origin !== sw.location.origin
// Exclude relay connections and local URLs
const isNotRelay = !url.protocol.includes('ws')
return isHTML && isCrossOrigin && isNotRelay
},
new StaleWhileRevalidate({
cacheName: 'boris-articles',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 14, // 14 days
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
)
// SPA navigation fallback - serve app shell for navigation requests
// This ensures the app loads offline
const navigationRoute = new NavigationRoute(
async ({ request }) => {
try {
// Try to fetch from network first
const response = await fetch(request)
return response
} catch (error) {
// If offline, serve the cached app shell
const cache = await caches.match('/index.html')
if (cache) {
return cache
}
throw error
}
}
)
registerRoute(navigationRoute)
// Listen for messages from the app
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
sw.skipWaiting()
}
})
// Log fetch errors for debugging (doesn't affect functionality)
sw.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
// Don't interfere with WebSocket connections (relay traffic)
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
return
}
})

View File

@@ -15,5 +15,11 @@ export interface Highlight {
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
// Relay tracking for offline/local-only highlights
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 { formatDistanceToNow } from 'date-fns'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const now = new Date()
const seconds = differenceInSeconds(now, date)
const minutes = differenceInMinutes(now, date)
const hours = differenceInHours(now, date)
const days = differenceInDays(now, date)
const months = differenceInMonths(now, date)
const years = differenceInYears(now, date)
if (seconds < 60) return 'now'
if (minutes < 60) return `${minutes}m`
if (hours < 24) return `${hours}h`
if (days < 30) return `${days}d`
if (months < 12) return `${months}mo`
return `${years}y`
}
// Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers

View File

@@ -32,6 +32,7 @@ export async function loadContent(
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
url: `nostr:${naddr}`
}
} else {

View File

@@ -40,3 +40,26 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
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))
}

View File

@@ -0,0 +1,188 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix
*/
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
/**
* Extract all nostr URIs from text
*/
export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
}
/**
* Extract all naddr (article) identifiers from text
*/
export function extractNaddrUris(text: string): string[] {
const allUris = extractNostrUris(text)
return allUris.filter(uri => {
try {
const decoded = decode(uri)
return decoded.type === 'naddr'
} catch {
return false
}
})
}
/**
* Decode a NIP-19 identifier and return a human-readable link
* For articles (naddr), returns an internal app link
* For other types, returns an external njump.me link
*/
export function createNostrLink(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'naddr':
// For articles, link to our internal app route
return `/a/${encoded}`
case 'npub':
case 'nprofile':
case 'note':
case 'nevent':
return `https://njump.me/${encoded}`
default:
return `https://njump.me/${encoded}`
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
}
}
/**
* Get a display label for a nostr URI
*/
export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile': {
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
}
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent': {
const note = noteEncode(decoded.data.id)
return `note:${note.slice(5, 12)}...`
}
case 'naddr': {
// For articles, show the identifier if available
const identifier = decoded.data.identifier
if (identifier && identifier.length > 0) {
// Truncate long identifiers but keep them readable
return identifier.length > 40 ? `${identifier.slice(0, 37)}...` : identifier
}
return 'nostr article'
}
default:
return encoded.slice(0, 16) + '...'
}
} catch (error) {
return encoded.slice(0, 16) + '...'
}
}
/**
* Replace nostr: URIs in markdown with proper markdown links
* This converts: nostr:npub1... to [label](link)
*/
export function replaceNostrUrisInMarkdown(markdown: string): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in markdown with proper markdown links, using resolved titles for articles
* This converts: nostr:naddr1... to [Article Title](link)
* @param markdown The markdown content to process
* @param articleTitles Map of naddr -> title for resolved articles
*/
export function replaceNostrUrisInMarkdownWithTitles(
markdown: string,
articleTitles: Map<string, string>
): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
// For articles, use the resolved title if available
try {
const decoded = decode(encoded)
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
const title = articleTitles.get(encoded)!
return `[${title}](${link})`
}
} catch (error) {
// Ignore decode errors, fall through to default label
}
// For other types or if title not resolved, use default label
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `<a href="${link}" class="nostr-uri-link" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}
/**
* Get decoded information from a nostr URI for detailed display
*/
export function getNostrUriInfo(encoded: string): {
type: string
decoded: ReturnType<typeof decode> | null
link: string
label: string
} {
let decoded: ReturnType<typeof decode> | null = null
try {
decoded = decode(encoded)
} catch (error) {
// ignore decoding errors
}
return {
type: decoded?.type || 'unknown',
decoded,
link: createNostrLink(encoded),
label: getNostrUriLabel(encoded)
}
}

9
vercel.json Normal file
View File

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

View File

@@ -1,8 +1,43 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: null,
manifest: {
name: 'Boris - Nostr Bookmarks',
short_name: 'Boris',
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
start_url: '/',
scope: '/',
display: 'standalone',
theme_color: '#0f172a',
background_color: '#0b1220',
orientation: 'any',
categories: ['productivity', 'social', 'utilities'],
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt']
},
devOptions: {
enabled: true,
type: 'module'
}
})
],
server: {
port: 9802
},