Compare commits

...

58 Commits

Author SHA1 Message Date
Gigi
0199c59014 chore: bump version to 0.5.2 2025-10-13 00:12:51 +02:00
Gigi
44fb63fc59 fix: resolve linting errors in HighlightItem
- Remove unused handleDeleteClick function (replaced by handleMenuDeleteClick)
- Remove onSelectUrl from destructuring (not used after menu refactor)
- Add comment explaining onSelectUrl is kept in props for API compatibility
2025-10-13 00:12:25 +02:00
Gigi
13a28d2dbd fix: make getNostrUrl detect identifier type for ants.sh
- ants.sh requires /p/ for profiles (npub/nprofile) and /e/ for events (note/nevent/naddr)
- Updated getNostrUrl to automatically detect identifier type and use correct path
2025-10-13 00:07:46 +02:00
Gigi
f87a7da32e feat: switch Nostr gateway to ants.sh
- Replace njump.me and search.dergigi.com with ants.sh
- Use ants.sh/p/ for profiles and ants.sh/e/ for events
- All existing helper functions continue to work with new gateway
2025-10-13 00:05:44 +02:00
Gigi
8fdf9938c2 refactor: centralize Nostr gateway URLs in config
- Create nostrGateways.ts config file with PRIMARY (njump.me) and SEARCH (search.dergigi.com) gateways
- Add helper functions: getProfileUrl, getEventUrl, getNostrUrl
- Update all hardcoded gateway URLs across the codebase to use the config
- Updated files: HighlightItem, nostrUriResolver, BookmarkViews (Card/Large), ResolvedMention
2025-10-13 00:05:11 +02:00
Gigi
ee4d480961 refactor: remove 'Loading your highlights' text from Me page 2025-10-13 00:01:41 +02:00
Gigi
bd866549a0 fix: open on nostr now opens the highlight event itself
- Changed 'Open on Nostr' to link to the highlight event (kind 9802)
- Previously it was linking to the article being highlighted
- Menu item is now always shown since every highlight has an event ID
- Removed unused handleLinkClick function
2025-10-13 00:01:11 +02:00
Gigi
7c39f1d821 style: change three-dot menu icon to horizontal 2025-10-13 00:00:05 +02:00
Gigi
e6a7bb4c98 feat: add three-dot menu to highlight cards
- Replace external link button with three-dot menu in highlight cards
- Move 'Open source/Nostr' and 'Delete' actions into dropdown menu
- Add click-outside functionality to close menu
- Style menu for both dark and light themes
2025-10-12 23:59:28 +02:00
Gigi
14cf3189b8 chore: remove 'Refreshing posts…' text from Explore
Only show spinner icon without text for cleaner UI
2025-10-12 23:56:23 +02:00
Gigi
66b060627a chore: bump version to 0.5.1 2025-10-12 23:54:17 +02:00
Gigi
d9bcf14baa fix: match highlight count indicator style to reading-time element
- Use rgba() with 0.1 opacity for background (subtle, not bright)
- Use rgba() with 0.3 opacity for border
- Set text and icon color to white for better visibility
- Properly converts hex colors to rgba using hexToRgb helper
2025-10-12 23:53:10 +02:00
Gigi
c571e6ebf7 fix: reduce brightness and add colored border to highlight count indicator
- Set background color to 75% opacity (bf hex) to reduce brightness
- Add border color matching the highlight group color
- Makes the indicator less overwhelming while maintaining visibility
2025-10-12 23:51:05 +02:00
Gigi
fb06a1aec3 fix: apply user highlight color to both marker and arrow icons
- Apply color style directly to both icons in collapsed highlights button
- Update CSS to use color-agnostic pulse animation (opacity/scale)
- Remove hardcoded yellow color and drop-shadow from glow effect
2025-10-12 23:50:34 +02:00
Gigi
5a0d08641b chore: remove MOBILE_IMPLEMENTATION.md
No longer needed as mobile features are now integrated
2025-10-12 23:49:12 +02:00
Gigi
8a8419385e fix: apply highlight group color to background of count indicator
- Change highlight count indicator to use backgroundColor instead of color
- Set text color to black for contrast against colored backgrounds
- Maintains priority: nostrverse > friends > mine
2025-10-12 23:48:56 +02:00
Gigi
0d5dc6e785 feat: apply 'my highlights' color to collapsed highlights button
- Update HighlightsPanelCollapsed to accept settings prop
- Apply highlightColorMine to the expand button icon and text
- Pass settings through HighlightsPanel and ThreePaneLayout
2025-10-12 23:48:18 +02:00
Gigi
1d90333803 feat: apply highlight group color to highlight count indicator
- Update ReaderHeader to receive highlights and highlightVisibility props
- Calculate dominant color based on visible highlight groups
- Apply color priority: nostrverse > friends > mine
- Highlight count indicator now reflects the active group colors
2025-10-12 23:47:05 +02:00
Gigi
91e6e62688 feat: apply 'my highlights' color to highlight buttons
- Update floating highlight button (FAB) to use highlightColorMine from settings
- Update mobile highlights button to use highlightColorMine from settings
- Both buttons now reflect the user's chosen 'my highlights' color
2025-10-12 23:44:51 +02:00
Gigi
619a8a9753 docs(changelog): add v0.5.0 changes and compare links 2025-10-12 23:42:44 +02:00
Gigi
0fe38e94d3 chore: bump version to 0.5.0 2025-10-12 23:38:51 +02:00
Gigi
722e8adbdf Merge pull request #3 from dergigi/local-first
perf: parallel local-first fetching, instant render, and non-wiping refreshes
2025-10-12 22:37:38 +01:00
Gigi
886d5ac08c chore(lint): satisfy react-hooks dependency in Explore; lints clean 2025-10-12 23:35:23 +02:00
Gigi
89d5ba4c37 ui(explore): shrink refresh spinner footprint; inline-sized loading row 2025-10-12 23:33:28 +02:00
Gigi
b8b9f82d91 ux(explore): preserve posts across navigations; seed from cache; merge streamed + final 2025-10-12 23:30:56 +02:00
Gigi
b3fc9bb5c3 ux(bookmarks): avoid clearing list when no new events; decouple refetch from route changes 2025-10-12 23:26:18 +02:00
Gigi
d2ebcd8fbe ux(explore): keep posts visible during refresh; inline spinner; no list wipe 2025-10-12 23:25:05 +02:00
Gigi
68c9623c35 ux(bookmarks): keep list visible during refresh; show spinner only; no wipe 2025-10-12 23:22:53 +02:00
Gigi
496d1df404 perf(parallel): run local+remote fetches concurrently across services; stream+dedupe 2025-10-12 23:13:26 +02:00
Gigi
ea1046fe13 perf(local-first): always follow up with remote for articles and titles 2025-10-12 23:00:23 +02:00
Gigi
6d58d6e7f3 fix(highlights): ensure nostrverse fetch merges remote results after local for article/url 2025-10-12 22:58:25 +02:00
Gigi
e1420140d1 chore(lint): fix eslint and typescript issues; no rule changes 2025-10-12 22:55:46 +02:00
Gigi
484c2e0c2f refactor(highlights): split service into smaller modules; keep files <210 lines 2025-10-12 22:54:11 +02:00
Gigi
31f7d53829 perf(explore): stream contacts + early posts from local; merge remote later 2025-10-12 22:43:35 +02:00
Gigi
e3debfa5df perf(local-first): apply local-first then remote pattern across services (titles, bookmarks, highlights) 2025-10-12 22:42:24 +02:00
Gigi
a1305fba81 fix(explore): always query remote relays after local; stream merge into UI 2025-10-12 22:38:29 +02:00
Gigi
ca95d6c7f4 perf(ui): stream results to UI; show cached/local immediately (articles, highlights, explore) 2025-10-12 22:33:46 +02:00
Gigi
5513fc9850 perf(relays): local-first queries with short timeouts; fallback to remote if needed 2025-10-12 22:06:49 +02:00
Gigi
86de98e644 Merge pull request #2 from dergigi/pwa
Upgrade to full Progressive Web App
2025-10-12 07:54:37 +01:00
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
58 changed files with 6588 additions and 695 deletions

View File

@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.0] - 2025-10-12
### Added
- Upgrade to full PWA with `vite-plugin-pwa`
- Replace placeholder icons with branded favicons
- Author info card for nostr-native articles
### Changed
- Explore: shrink refresh spinner footprint; inline-sized loading row
- Explore: preserve posts across navigations; seed from cache; merge streamed and final results
- Explore: keep posts visible during refresh; inline spinner; no list wipe
- Bookmarks: keep list visible during refresh; show spinner only; no wipe
- Bookmarks: avoid clearing list when no new events; decouple refetch from route changes
- Highlights: split service into smaller modules to keep files under 210 lines
- Lint/TypeScript: satisfy react-hooks dependencies; fix worker typings; clear ESLint/TS issues
### Fixed
- Highlights: merge remote results after local for article/url
- Explore: always query remote relays after local; stream merge into UI
- Improve mobile touch targets for highlight icons
- Color `/me` highlights with "my highlights" color setting
### Performance
- Local-first then remote follow-up across services (titles, bookmarks, highlights)
- Run local and remote fetches concurrently; stream and dedupe results
- Stream contacts and early posts from local; merge remote later
- Relay queries use local-first with short timeouts; fallback to remote when needed
- Stream results to UI; display cached/local immediately (articles, highlights, explore)
### Documentation
- PWA implementation summary and launch checklist updates
- Update docs to reflect branded icons and final steps
- Remove temporary PWA launch checklist and implementation summary
## [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
@@ -590,6 +692,8 @@ 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
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/dergigi/boris/compare/v0.4.3...v0.5.0
[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

View File

@@ -1,156 +0,0 @@
# 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

@@ -2,8 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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://read.withboris.com/" />

4181
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.4.2",
"version": "0.5.2",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -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,

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

View File

@@ -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 ||
@@ -69,6 +70,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/me"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -79,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 () => {
@@ -174,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

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

@@ -111,16 +111,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isMobile={isMobile}
/>
{loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : allIndividualBookmarks.length === 0 ? (
<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>
{allIndividualBookmarks.length === 0 ? (
loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : (
<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">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>

View File

@@ -9,6 +9,7 @@ import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -79,7 +80,7 @@ export const CardView: React.FC<CardViewProps> = ({
{eventNevent ? (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
@@ -159,7 +160,7 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"

View File

@@ -6,6 +6,7 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -79,7 +80,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-footer">
<span className="large-author">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"
@@ -90,7 +91,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
{eventNevent && (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"

View File

@@ -14,6 +14,7 @@ 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'
@@ -35,13 +36,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showMe = location.pathname === '/me'
// Track previous location for going back from settings
// Track previous location for going back from settings/me/explore
useEffect(() => {
if (!showSettings) {
if (!showSettings && !showMe && !showExplore) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings])
}, [location.pathname, showSettings, showMe, showExplore])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -202,6 +204,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -244,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)}
@@ -257,6 +262,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
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,9 +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'
@@ -13,6 +15,8 @@ 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
@@ -34,6 +38,8 @@ interface ContentPanelProps {
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -54,6 +60,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightColor = '#ffff00',
settings,
relayPool,
activeAccount,
currentArticle,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -62,6 +70,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -93,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">
@@ -133,33 +180,59 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
/>
{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 }}
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={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)
)}
{/* 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>

View File

@@ -7,6 +7,7 @@ import { nip19 } from 'nostr-tools'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
interface ExploreProps {
relayPool: RelayPool
@@ -27,11 +28,59 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
try {
// show spinner but keep existing posts
setLoading(true)
setError(null)
// Seed from in-memory cache if available to avoid empty flash
const cached = getCachedPosts(activeAccount.pubkey)
if (cached && cached.length > 0 && blogPosts.length === 0) {
setBlogPosts(cached)
}
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
const contacts = await fetchContacts(
relayPool,
activeAccount.pubkey,
(partial) => {
// When local contacts are available, kick off early posts fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
fetchBlogPostsFromAuthors(
relayPool,
Array.from(partial),
relayUrls,
(post) => {
// merge into UI and cache as we stream
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
// Ensure union of streamed + final is displayed
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
@@ -39,21 +88,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return
}
// Get relay URLs from pool
// After full contacts, do a final pass for completeness
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
)
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
}
setBlogPosts(posts)
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of posts) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
@@ -63,7 +116,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
loadBlogPosts()
}, [relayPool, activeAccount])
}, [relayPool, activeAccount, blogPosts.length])
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -79,17 +132,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
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">
@@ -112,6 +154,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
Discover blog posts from your friends on Nostr
</p>
</div>
{loading && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
@@ -120,6 +167,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
href={getPostUrl(post)}
/>
))}
{!loading && blogPosts.length === 0 && (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No blog posts found yet.</p>
</div>
)}
</div>
</div>
)

View File

@@ -1,15 +1,19 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { 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'
import { getNostrUrl } from '../config/nostrGateways'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -23,21 +27,29 @@ interface HighlightItemProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
// onSelectUrl is not used but kept in props for API compatibility
isSelected,
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
onHighlightUpdate,
onHighlightDelete
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showMenu, setShowMenu] = useState(false)
const activeAccount = Hooks.useActiveAccount()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -88,61 +100,45 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [isSelected])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false)
}
}
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showMenu])
const handleItemClick = () => {
if (onHighlightClick) {
onHighlightClick(highlight.id)
}
}
const handleLinkClick = (url: string, e: React.MouseEvent) => {
if (onSelectUrl) {
e.preventDefault()
onSelectUrl(url)
}
const getHighlightLink = () => {
// Encode the highlight event itself (kind 9802) as a 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.id,
relays: relayHints,
author: highlight.pubkey,
kind: 9802
})
return getNostrUrl(nevent)
}
const getSourceLink = () => {
if (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()
const highlightLink = getHighlightLink()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
@@ -243,7 +239,63 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const relayIndicator = getRelayIndicatorInfo()
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
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)
}
const handleMenuToggle = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(!showMenu)
}
const handleOpenExternal = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(highlightLink, '_blank', 'noopener,noreferrer')
setShowMenu(false)
}
const handleMenuDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
setShowDeleteConfirm(true)
}
return (
<>
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
@@ -286,21 +338,52 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
className="highlight-source"
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
<div className="highlight-menu-wrapper" ref={menuRef}>
<button
className="highlight-menu-btn"
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</a>
)}
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleOpenExternal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
{canDelete && (
<button
className="highlight-menu-item highlight-menu-item-danger"
onClick={handleMenuDeleteClick}
disabled={isDeleting}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
<span>Delete</span>
</button>
)}
</div>
)}
</div>
</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

@@ -8,6 +8,7 @@ import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
export interface HighlightVisibility {
nostrverse: boolean
@@ -32,6 +33,7 @@ interface HighlightsPanelProps {
followedPubkeys?: Set<string>
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -50,7 +52,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onHighlightVisibilityChange,
followedPubkeys = new Set(),
relayPool,
eventStore
eventStore,
settings
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -72,6 +75,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
)
}
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
}
const filteredHighlights = useFilteredHighlights({
highlights: localHighlights,
selectedUrl,
@@ -85,6 +93,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
<HighlightsPanelCollapsed
hasHighlights={filteredHighlights.length > 0}
onToggleCollapse={onToggleCollapse}
settings={settings}
/>
)
}
@@ -129,6 +138,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>

View File

@@ -1,16 +1,21 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
interface HighlightsPanelCollapsedProps {
hasHighlights: boolean
onToggleCollapse: () => void
settings?: UserSettings
}
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
hasHighlights,
onToggleCollapse
onToggleCollapse,
settings
}) => {
const highlightColor = settings?.highlightColorMine || '#ffff00'
return (
<div className="highlights-container collapsed">
<button
@@ -19,8 +24,12 @@ const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
title="Expand highlights panel"
aria-label="Expand highlights panel"
>
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
<FontAwesomeIcon icon={faChevronRight} />
<FontAwesomeIcon
icon={faHighlighter}
className={hasHighlights ? 'glow' : ''}
style={{ color: highlightColor }}
/>
<FontAwesomeIcon icon={faChevronRight} style={{ color: highlightColor }} />
</button>
</div>
)

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

@@ -0,0 +1,118 @@
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" />
</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,9 +1,12 @@
import React from 'react'
import React, { useMemo } 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'
import { Highlight, HighlightLevel } from '../types/highlights'
import { HighlightVisibility } from './HighlightsPanel'
import { hexToRgb } from '../utils/colorHelpers'
interface ReaderHeaderProps {
title?: string
@@ -14,6 +17,8 @@ interface ReaderHeaderProps {
hasHighlights: boolean
highlightCount: number
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -24,12 +29,46 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
readingTimeText,
hasHighlights,
highlightCount,
settings
settings,
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
// Determine the dominant highlight color based on visibility and priority
const highlightIndicatorStyles = useMemo(() => {
if (!highlights.length) return undefined
// Count highlights by level that are visible
const visibleLevels = new Set<HighlightLevel>()
highlights.forEach(h => {
if (h.level && highlightVisibility[h.level]) {
visibleLevels.add(h.level)
}
})
let hexColor: string | undefined
// Priority: nostrverse > friends > mine
if (visibleLevels.has('nostrverse') && highlightVisibility.nostrverse) {
hexColor = settings?.highlightColorNostrverse || '#9333ea'
} else if (visibleLevels.has('friends') && highlightVisibility.friends) {
hexColor = settings?.highlightColorFriends || '#f97316'
} else if (visibleLevels.has('mine') && highlightVisibility.mine) {
hexColor = settings?.highlightColorMine || '#ffff00'
}
if (!hexColor) return undefined
const rgb = hexToRgb(hexColor)
return {
backgroundColor: `rgba(${rgb}, 0.1)`,
borderColor: `rgba(${rgb}, 0.3)`,
color: '#fff'
}
}, [highlights, highlightVisibility, settings])
if (cachedImage) {
return (
<>
@@ -52,7 +91,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
@@ -89,7 +131,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>

View File

@@ -4,6 +4,7 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
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
@@ -13,6 +14,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// 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
@@ -57,36 +60,55 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// 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' : ''}`} title={
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<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>
<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>
{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

@@ -2,6 +2,7 @@ import React from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
import { getProfileUrl } from '../config/nostrGateways'
const { getPubkeyFromDecodeResult } = Helpers
@@ -25,7 +26,7 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
if (npub) {
return (
<a
href={`https://search.dergigi.com/p/${npub}`}
href={getProfileUrl(npub)}
className="nostr-mention"
target="_blank"
rel="noopener noreferrer"

View File

@@ -10,6 +10,7 @@ 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 = {
@@ -164,6 +165,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
</div>
)

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

@@ -90,8 +90,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<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()} />

View File

@@ -20,6 +20,8 @@ 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
@@ -28,6 +30,7 @@ interface ThreePaneLayoutProps {
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
showMe?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -59,6 +62,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane
highlights: Highlight[]
@@ -81,6 +86,9 @@ interface ThreePaneLayoutProps {
// Optional Explore content
explore?: React.ReactNode
// Optional Me content
me?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -233,6 +241,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
style={{
backgroundColor: props.settings.highlightColorMine || '#ffff00',
color: '#000'
}}
>
<FontAwesomeIcon icon={faHighlighter} />
</button>
@@ -288,6 +300,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.explore}
</>
) : props.showMe && props.me ? (
// Render Me inside the main pane to keep side panels
<>
{props.me}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -311,6 +328,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
/>
)}
</div>
@@ -336,6 +355,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
/>
</div>
</div>
@@ -343,7 +363,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<HighlightButton
ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight}
highlightColor={props.settings.highlightColor || '#ffff00'}
highlightColor={props.settings.highlightColorMine || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />

View File

@@ -0,0 +1,34 @@
/**
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
}
/**
* Get a general nostr link on the gateway
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// Check the prefix to determine if it's a profile or event
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
return `${NOSTR_GATEWAY}/p/${identifier}`
}
// Everything else (note, nevent, naddr) goes to /e/
return `${NOSTR_GATEWAY}/e/${identifier}`
}

View File

@@ -44,10 +44,14 @@ export const useBookmarksData = ({
const handleFetchBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) return
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
// merge-friendly: updater form that preserves visible list until replacement
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
setBookmarks(() => next)
}, settings)
} finally {
setBookmarksLoading(false)
}
@@ -102,15 +106,21 @@ export const useBookmarksData = ({
}
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
// Load initial data
// Load initial data (avoid clearing on route-only changes)
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
handleFetchBookmarks()
}, [relayPool, activeAccount, handleFetchBookmarks])
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -11,7 +11,7 @@ interface UseExternalUrlLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -57,7 +57,21 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
}
)
// Ensure final list is sorted and contains all items
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
} else {

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

@@ -961,6 +961,156 @@ body.mobile-sidebar-open {
padding: 0.1rem 0.3rem;
}
/* Mark as Read button */
.mark-as-read-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem 1rem;
margin-top: 2rem;
border-top: 1px solid #333;
}
.mark-as-read-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 160px;
justify-content: center;
}
.mark-as-read-btn:hover:not(:disabled) {
background: #333;
border-color: #555;
transform: translateY(-1px);
}
.mark-as-read-btn:active:not(:disabled) {
transform: translateY(0);
}
.mark-as-read-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mark-as-read-btn svg {
font-size: 1.1rem;
}
@media (max-width: 768px) {
.mark-as-read-container {
padding: 1.5rem 1rem;
}
.mark-as-read-btn {
width: 100%;
max-width: 300px;
}
}
/* Author Card */
.author-card-container {
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
.author-card {
display: flex;
gap: 1rem;
padding: 1.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
max-width: 600px;
width: 100%;
}
.author-card-avatar {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
background: #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.author-card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-card-avatar svg {
font-size: 2.5rem;
}
.author-card-content {
flex: 1;
min-width: 0;
}
.author-card-name {
font-size: 1rem;
font-weight: 600;
color: #ddd;
margin-bottom: 0.5rem;
}
.author-card-bio {
font-size: 0.9rem;
color: #999;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.author-card-container {
padding: 1.5rem 1rem;
}
.author-card {
padding: 1rem;
}
.author-card-avatar {
width: 48px;
height: 48px;
}
.author-card-avatar svg {
font-size: 2rem;
}
.author-card-name {
font-size: 0.95rem;
}
.author-card-bio {
font-size: 0.85rem;
-webkit-line-clamp: 2;
}
}
.bookmark-item {
background: #1a1a1a;
padding: 1.5rem;
@@ -1633,6 +1783,35 @@ body.mobile-sidebar-open {
.highlight-text {
color: #213547;
}
.highlight-menu-btn {
color: #666;
}
.highlight-menu-btn:hover {
color: #646cff;
background: rgba(100, 108, 255, 0.08);
}
.highlight-menu {
background: #fff;
border-color: #ddd;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.highlight-menu-item {
color: #213547;
}
.highlight-menu-item:hover {
background: rgba(100, 108, 255, 0.08);
color: #000;
}
.highlight-menu-item-danger:hover {
background: rgba(255, 68, 68, 0.08);
color: #cc0000;
}
}
/* Highlights Panel Styles */
@@ -1686,17 +1865,17 @@ body.mobile-sidebar-open {
}
.highlights-container.collapsed .toggle-highlights-btn .glow {
color: #ffff00;
filter: drop-shadow(0 0 4px rgba(255, 255, 0, 0.6));
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
filter: drop-shadow(0 0 4px rgba(255, 255, 0, 0.6));
opacity: 0.8;
transform: scale(1);
}
50% {
filter: drop-shadow(0 0 8px rgba(255, 255, 0, 0.9));
opacity: 1;
transform: scale(1.1);
}
}
@@ -1934,13 +2113,19 @@ body.mobile-sidebar-open {
.highlight-relay-indicator {
position: absolute;
bottom: -2px;
left: 0;
bottom: -4px;
left: -6px;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
min-width: 20px;
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.highlight-relay-indicator:hover {
@@ -1953,6 +2138,58 @@ body.mobile-sidebar-open {
transform: scale(0.95);
}
.highlight-delete-btn {
position: absolute;
bottom: -4px;
right: -6px;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
min-width: 20px;
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.highlight-delete-btn:hover {
opacity: 1;
color: #ff4444;
transform: scale(1.1);
}
.highlight-delete-btn:active {
transform: scale(0.95);
}
/* Mobile: Larger touch targets and better spacing */
@media (max-width: 768px) {
.highlight-quote-icon {
min-width: 100px; /* Ensure enough space for both touch targets */
}
.highlight-relay-indicator {
bottom: -8px;
left: -8px;
padding: 8px;
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
font-size: 0.85rem;
}
.highlight-delete-btn {
bottom: -8px;
right: -8px;
padding: 8px;
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
font-size: 0.85rem;
}
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon {
color: var(--highlight-color-mine, #ffff00);
@@ -2028,25 +2265,77 @@ body.mobile-sidebar-open {
line-height: 1;
}
.highlight-source {
.highlight-menu-wrapper {
position: relative;
margin-left: auto;
flex-shrink: 0;
}
.highlight-menu-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.375rem;
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
border-radius: 4px;
}
.highlight-source:hover {
color: #535bf2;
text-decoration: underline;
.highlight-menu-btn:hover {
color: #646cff;
background: rgba(100, 108, 255, 0.1);
}
.highlight-source svg {
.highlight-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 160px;
overflow: hidden;
}
.highlight-menu-item {
width: 100%;
background: none;
border: none;
color: #ddd;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
white-space: nowrap;
}
.highlight-menu-item:hover {
background: rgba(100, 108, 255, 0.15);
color: #fff;
}
.highlight-menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-menu-item-danger:hover {
background: rgba(255, 68, 68, 0.15);
color: #ff4444;
}
.highlight-menu-item svg {
font-size: 0.875rem;
flex-shrink: 0;
}
/* Inline content highlights - fluorescent marker style */
@@ -2909,6 +3198,34 @@ body.mobile-sidebar-open {
cursor: default;
}
/* Mobile compact mode - just show icon */
@media (max-width: 768px) {
.relay-status-indicator.mobile {
padding: 0.5rem;
width: var(--min-touch-target);
height: var(--min-touch-target);
display: flex;
align-items: center;
justify-content: center;
bottom: 1rem;
left: 1rem;
}
.relay-status-indicator.mobile.expanded {
width: auto;
padding: 0.75rem 1rem;
gap: 0.75rem;
}
.relay-status-indicator.mobile .relay-status-icon {
font-size: 1.1rem;
}
.relay-status-indicator.mobile:active {
transform: scale(0.95);
}
}
.relay-status-indicator.connecting {
background: rgba(100, 108, 255, 0.15);
border: 1px solid rgba(100, 108, 255, 0.25);
@@ -3024,10 +3341,15 @@ body.mobile-sidebar-open {
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 50vh;
color: rgba(255, 255, 255, 0.7);
}
/* Compact, inline loading row for Explore refresh */
.explore-loading {
min-height: 0;
padding: 0.25rem 0;
}
.explore-error {
color: #ff6b6b;
}
@@ -3156,3 +3478,160 @@ body.mobile-sidebar-open {
padding: 1rem;
}
}
/* Confirmation Dialog */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
}
.confirm-dialog {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.confirm-dialog-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
}
.confirm-dialog-icon.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 2px solid rgba(245, 158, 11, 0.3);
}
.confirm-dialog-icon.danger {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 2px solid rgba(239, 68, 68, 0.3);
}
.confirm-dialog-icon.info {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 2px solid rgba(59, 130, 246, 0.3);
}
.confirm-dialog-title {
font-size: 1.25rem;
font-weight: 600;
color: #ddd;
margin: 0;
text-align: center;
}
.confirm-dialog-message {
font-size: 0.95rem;
color: #999;
margin: 0;
text-align: center;
line-height: 1.5;
}
.confirm-dialog-actions {
display: flex;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
}
.confirm-dialog-btn {
flex: 1;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.confirm-dialog-btn.cancel {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
}
.confirm-dialog-btn.cancel:hover {
background: #333;
border-color: #555;
}
.confirm-dialog-btn.confirm {
color: #1a1a1a;
}
.confirm-dialog-btn.confirm.warning {
background: #f59e0b;
}
.confirm-dialog-btn.confirm.warning:hover {
background: #d97706;
}
.confirm-dialog-btn.confirm.danger {
background: #ef4444;
color: white;
}
.confirm-dialog-btn.confirm.danger:hover {
background: #dc2626;
}
.confirm-dialog-btn.confirm.info {
background: #3b82f6;
color: white;
}
.confirm-dialog-btn.confirm.info:hover {
background: #2563eb;
}
.confirm-dialog-btn:active {
transform: scale(0.98);
}
@media (max-width: 768px) {
.confirm-dialog {
padding: 1.5rem;
max-width: 90%;
}
.confirm-dialog-icon {
width: 50px;
height: 50px;
font-size: 1.5rem;
}
.confirm-dialog-title {
font-size: 1.1rem;
}
.confirm-dialog-message {
font-size: 0.9rem;
}
}

View File

@@ -3,21 +3,31 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for offline image caching
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.register('/sw.js', { type: 'module' })
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
// 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 === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
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)
}
})
}

View File

@@ -1,10 +1,12 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
@@ -98,9 +100,11 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -109,12 +113,10 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -1,9 +1,11 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
const { getArticleTitle } = Helpers
@@ -25,9 +27,11 @@ export async function fetchArticleTitle(
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -36,11 +40,11 @@ export async function fetchArticleTitle(
'#d': [pointer.identifier]
}
// Parallel local+remote: collect up to one event from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000)
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
) as unknown as { created_at: number }[]
if (events.length === 0) {
return null
@@ -48,7 +52,7 @@ export async function fetchArticleTitle(
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
return getArticleTitle(article) || null
} catch (err) {

View File

@@ -1,5 +1,5 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import {
AccountWithExtension,
NostrEvent,
@@ -16,6 +16,7 @@ import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
@@ -31,14 +32,22 @@ export const fetchBookmarks = async (
throw new Error('Invalid account object provided')
}
// Get relay URLs from the pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
)
// Try local-first quickly, then full set fallback
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
@@ -64,7 +73,7 @@ export const fetchBookmarks = async (
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
if (bookmarkListEvents.length === 0) {
setBookmarks([])
// Keep existing bookmarks visible; do not clear list if nothing new found
return
}
// Aggregate across events
@@ -102,9 +111,14 @@ export const fetchBookmarks = async (
let idToEvent: Map<string, NostrEvent> = new Map()
if (noteIds.length > 0) {
try {
const events = await lastValueFrom(
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
const localHydrate$ = localHydrate.length > 0
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remoteHydrate$ = remoteHydrate.length > 0
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
} catch (error) {
console.warn('Failed to fetch events for hydration:', error)

View File

@@ -1,5 +1,6 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { prioritizeLocalRelays } from '../utils/helpers'
/**
* Fetches the contact list (follows) for a specific user
@@ -9,40 +10,49 @@ import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
*/
export const fetchContacts = async (
relayPool: RelayPool,
pubkey: string
pubkey: string,
onPartial?: (contacts: Set<string>) => void
): Promise<Set<string>> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
// Local-first quick attempt
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const events = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
merge(local$, remote$).pipe(toArray())
)
const followed = new Set<string>()
if (events.length > 0) {
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followed.add(tag[1])
}
}
if (onPartial) onPartial(new Set(followed))
}
// merged already via streams
console.log('📊 Contact events fetched:', events.length)
if (events.length === 0) {
return new Set()
}
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
const followedPubkeys = new Set<string>()
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1])
}
}
console.log('👥 Followed contacts:', followedPubkeys.size)
return followedPubkeys
console.log('👥 Followed contacts:', followed.size)
return followed
} catch (error) {
console.error('Failed to fetch contacts:', error)
return new Set()

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,42 @@
import { NostrEvent } from 'nostr-tools'
export interface CachedBlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
type CacheValue = {
posts: CachedBlogPostPreview[]
timestamp: number
}
const exploreCache = new Map<string, CacheValue>() // key: pubkey
export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.posts
}
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
}
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
const current = exploreCache.get(pubkey)?.posts || []
const byId = new Map(current.map(p => [p.event.id, p]))
byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const ta = a.published || a.event.created_at
const tb = b.published || b.event.created_at
return tb - ta
})
setCachedPosts(pubkey, merged)
return merged
}

View File

@@ -1,5 +1,6 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
@@ -24,7 +25,8 @@ export interface BlogPostPreview {
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -34,42 +36,65 @@ export const fetchBlogPostsFromAuthors = async (
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)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// 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)
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
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)
// Emit as we incorporate
if (onPost) {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
onPost(post)
}
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
// 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
}))
.map(event => {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
if (onPost) onPost(post)
return post
})
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at

View File

@@ -1,204 +1,5 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
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'
export * from './highlights/fetchForArticle'
export * from './highlights/fetchForUrl'
export * from './highlights/fetchByAuthor'
/**
* 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,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays (including local):', RELAYS)
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
// Query for highlights that reference this article via the 'a' tag
const aTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via a-tag:', aTagEvents.length)
// If we have an event ID, also query for highlights that reference via the 'e' tag
let eTagEvents: NostrEvent[] = []
if (eventId) {
eTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Highlights via e-tag:', eTagEvents.length)
}
// Combine results from both queries
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 {
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
console.log('❌ Event ID:', eventId || 'none')
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
}
// Deduplicate events by ID
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for article:', error)
return []
}
}
/**
* 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,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
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)
} catch (error) {
console.error('Failed to fetch highlights for URL:', error)
return []
}
}
/**
* Fetches highlights created by a specific user
* @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,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
const highlight = eventToHighlight(event)
if (onHighlight) {
onHighlight(highlight)
}
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
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)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights by author:', error)
return []
}
}

View File

@@ -0,0 +1,63 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const ordered = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,98 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
const aLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
let eTagEvents: NostrEvent[] = []
if (eventId) {
const eLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const eRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
}
const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,57 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelaysUrl.length > 0
? relayPool
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

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
}

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

@@ -63,3 +63,58 @@ export const hasRemoteRelay = (relayUrls: string[]): boolean => {
return relayUrls.some(url => !isLocalRelay(url))
}
/**
* Splits relay URLs into local and remote groups
*/
export const partitionRelays = (
relayUrls: string[]
): { local: string[]; remote: string[] } => {
const local: string[] = []
const remote: string[] = []
for (const url of relayUrls) {
if (isLocalRelay(url)) local.push(url)
else remote.push(url)
}
return { local, remote }
}
/**
* Returns relays ordered with local first while keeping uniqueness
*/
export const prioritizeLocalRelays = (relayUrls: string[]): string[] => {
const { local, remote } = partitionRelays(relayUrls)
const seen = new Set<string>()
const out: string[] = []
for (const url of [...local, ...remote]) {
if (!seen.has(url)) {
seen.add(url)
out.push(url)
}
}
return out
}
// Parallel request helper
import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay'
import { Observable, takeUntil, timer } from 'rxjs'
export function createParallelReqStreams(
relayPool: RelayPool,
localRelays: string[],
remoteRelays: string[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filter: any,
localTimeoutMs = 1200,
remoteTimeoutMs = 6000
): { local$: Observable<unknown>; remote$: Observable<unknown> } {
const local$ = (localRelays.length > 0)
? relayPool.req(localRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(localTimeoutMs)))
: new Observable<unknown>((sub) => { sub.complete() })
const remote$ = (remoteRelays.length > 0)
? relayPool.req(remoteRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(remoteTimeoutMs)))
: new Observable<unknown>((sub) => { sub.complete() })
return { local$, remote$ }
}

View File

@@ -1,4 +1,5 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { getNostrUrl } from '../config/nostrGateways'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
@@ -39,7 +40,7 @@ export function extractNaddrUris(text: string): string[] {
/**
* 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
* For other types, returns an external gateway link
*/
export function createNostrLink(encoded: string): string {
try {
@@ -53,13 +54,13 @@ export function createNostrLink(encoded: string): string {
case 'nprofile':
case 'note':
case 'nevent':
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
default:
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
}
}

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