Compare commits
405 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c104f95d9 | ||
|
|
acbefae501 | ||
|
|
2ce83ef88a | ||
|
|
dab3412ecd | ||
|
|
988b3164d2 | ||
|
|
4161053821 | ||
|
|
60054c4865 | ||
|
|
f4e8aa576c | ||
|
|
30a495bcd1 | ||
|
|
6dde0eb220 | ||
|
|
90d8ef3423 | ||
|
|
f626a8ec9b | ||
|
|
a7c7535236 | ||
|
|
5b0f2821d6 | ||
|
|
be045557b8 | ||
|
|
a0c92182f9 | ||
|
|
f33d33556b | ||
|
|
9aff889835 | ||
|
|
420df1fbdd | ||
|
|
2946ede5ac | ||
|
|
6ec28e6a9d | ||
|
|
820daa489e | ||
|
|
b162596013 | ||
|
|
e581237e16 | ||
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff | ||
|
|
d5076ff53e | ||
|
|
41e452be1e | ||
|
|
f267df8f60 | ||
|
|
7426c9d1fc | ||
|
|
93d0c1052b | ||
|
|
6537650757 | ||
|
|
a95f9b522b | ||
|
|
47d1335842 | ||
|
|
168095e133 | ||
|
|
5c7b413a8d | ||
|
|
bca6458e44 | ||
|
|
ebdfa3b5a3 | ||
|
|
17480dddbf | ||
|
|
2a422fbeb9 | ||
|
|
22961ee479 | ||
|
|
18db905974 | ||
|
|
689963c041 | ||
|
|
3f8869fd75 | ||
|
|
129aced1a2 | ||
|
|
69febf4356 | ||
|
|
65051c9c1f | ||
|
|
ba8229d464 | ||
|
|
9251b017d4 | ||
|
|
1ae76031f3 | ||
|
|
994d834a0b | ||
|
|
67a4e17055 | ||
|
|
1e82e3f240 | ||
|
|
f973c75ff5 | ||
|
|
28316a71c5 | ||
|
|
cfc12e2d78 | ||
|
|
7464a8b505 | ||
|
|
938d79663b | ||
|
|
cc0ad69275 | ||
|
|
810ff060f8 | ||
|
|
5e03ef70a6 | ||
|
|
f05fb29c7b | ||
|
|
e737b1f7f0 | ||
|
|
21a7be2f98 | ||
|
|
4c720aa049 | ||
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f | ||
|
|
ffafc6f64d | ||
|
|
eadab9a37f | ||
|
|
13b1692931 | ||
|
|
3a78289fee | ||
|
|
12c70b06de | ||
|
|
c7c82954ad | ||
|
|
3b639e2783 | ||
|
|
946584236d | ||
|
|
aadbf2084f | ||
|
|
3d7b649cba | ||
|
|
caa07012a7 | ||
|
|
ad5cd875de | ||
|
|
0a4bc2cfbb | ||
|
|
605dd41939 | ||
|
|
8679ae7a37 | ||
|
|
3c1e4312c9 | ||
|
|
53ed6849af | ||
|
|
4b95e6c262 | ||
|
|
40ab215c4d | ||
|
|
823927525f | ||
|
|
6277824b32 | ||
|
|
f94e4ba900 | ||
|
|
acf14ccee9 | ||
|
|
f882b63359 | ||
|
|
7b1e3be39b | ||
|
|
ee17018076 | ||
|
|
1dd2e1dc38 | ||
|
|
4cd1aa89ad | ||
|
|
e667cf05c2 | ||
|
|
7512375728 | ||
|
|
f108e2e70a | ||
|
|
daa43ec4c4 | ||
|
|
ab2223e739 | ||
|
|
e8cbb3af4b | ||
|
|
f374a9af28 | ||
|
|
f2cbc66a97 | ||
|
|
1627d4f53e | ||
|
|
b93a4d072a | ||
|
|
3e4bc97684 | ||
|
|
3c0c20f61c | ||
|
|
dae63e210b | ||
|
|
dc500cc296 | ||
|
|
1fc1e4f102 | ||
|
|
524b5e1559 | ||
|
|
930de76d1f | ||
|
|
b85fc820d1 | ||
|
|
b145aee29d | ||
|
|
a0e65a48f1 | ||
|
|
ccdfc54cdc | ||
|
|
61ce338b8c | ||
|
|
47de9a75b7 | ||
|
|
607f3d46f0 | ||
|
|
bdbc08fdf1 | ||
|
|
3a28160ae8 | ||
|
|
e03696eed7 | ||
|
|
f80fa3de7f | ||
|
|
4518fc16a7 | ||
|
|
7f2b70779b | ||
|
|
cc9cc47b51 | ||
|
|
a19cb8b6dc | ||
|
|
c564d1608b | ||
|
|
c146a8f7ec | ||
|
|
48cde27a5b | ||
|
|
fdf0644bbb | ||
|
|
ec7371c43b | ||
|
|
35204ee400 | ||
|
|
d1031b3342 | ||
|
|
db67e94b9e | ||
|
|
a0e5ba3a63 | ||
|
|
f3f80449a6 | ||
|
|
bd0b4e848f | ||
|
|
4f5ba99214 | ||
|
|
aab67d8375 | ||
|
|
dbc0a48194 | ||
|
|
6a84646b0b | ||
|
|
e921967082 | ||
|
|
ec34bc3d04 | ||
|
|
96ce12b952 | ||
|
|
1066c43d6c | ||
|
|
914557a61d | ||
|
|
3df2f248ff | ||
|
|
d2770d58e2 | ||
|
|
933182567d | ||
|
|
f9fa2f05f0 | ||
|
|
919bb8151f | ||
|
|
6f82674c9b | ||
|
|
8caf9988fc | ||
|
|
036ee20d98 | ||
|
|
b86545dcc8 | ||
|
|
8bdccd9c9e | ||
|
|
9a14185fa5 | ||
|
|
53a6053464 | ||
|
|
e27d7ee26c | ||
|
|
98203e6b6f | ||
|
|
8469740141 | ||
|
|
8fff2bce52 | ||
|
|
30b98fc744 | ||
|
|
7a190b7d35 | ||
|
|
e3149c40c7 | ||
|
|
91743518bd | ||
|
|
fd2e4079ab | ||
|
|
ec423cad80 | ||
|
|
8f8441b0e0 | ||
|
|
3c20d45dba | ||
|
|
75c4e20dc9 | ||
|
|
9d27595d31 | ||
|
|
b7d90a790b | ||
|
|
c49d850f74 | ||
|
|
4c11c5fc54 | ||
|
|
44befab6d3 | ||
|
|
02a2f4b85e | ||
|
|
43d54b5734 | ||
|
|
b7896be507 | ||
|
|
eeb40306da | ||
|
|
749b47ac5c | ||
|
|
42f59f2b19 | ||
|
|
2bf6e742f1 | ||
|
|
2a2049e678 | ||
|
|
146aa85e76 | ||
|
|
a26c7497b5 | ||
|
|
da67135f5e | ||
|
|
aebb6d1762 | ||
|
|
8f5cf6a0b4 | ||
|
|
875017db96 | ||
|
|
c0f34b684d | ||
|
|
613956bbaf | ||
|
|
041ba5c05b | ||
|
|
05c21cfd6d | ||
|
|
4898f99ae1 | ||
|
|
be920e8c44 | ||
|
|
0fa5ac536b | ||
|
|
cef359af29 | ||
|
|
2de72b73c1 | ||
|
|
a794331c1a | ||
|
|
e09be543bc | ||
|
|
88085c48d2 | ||
|
|
e32010771b | ||
|
|
03e7484e71 | ||
|
|
d9fd4ec286 | ||
|
|
8f14f0347c | ||
|
|
9b5bb8496f | ||
|
|
9264a78c95 | ||
|
|
326d571871 | ||
|
|
744e86b290 | ||
|
|
e46b68da7e | ||
|
|
811a962632 | ||
|
|
eb82e8762a | ||
|
|
d919da153f | ||
|
|
8389d5811a | ||
|
|
0aa0c44441 | ||
|
|
49ea7504a1 | ||
|
|
6602fb9359 | ||
|
|
731eb6915a | ||
|
|
3459179310 | ||
|
|
b1f951daf5 | ||
|
|
caebcec0af | ||
|
|
5f50f4b8d6 | ||
|
|
3039208ba0 | ||
|
|
397c956e87 | ||
|
|
cf47ceb74b | ||
|
|
da7aa2c115 | ||
|
|
c0046bc04c | ||
|
|
2f8f6a0652 | ||
|
|
9a6f788b98 | ||
|
|
c1a628260c | ||
|
|
7b0bd7077c | ||
|
|
7d47f0a86e | ||
|
|
44fcd74cbe | ||
|
|
5ac0e7ed87 | ||
|
|
743968f7fb | ||
|
|
e1a3ae4b4d | ||
|
|
acf13448ae | ||
|
|
a5daa8b56c | ||
|
|
267169c5c1 | ||
|
|
89272dd9a3 | ||
|
|
d059212238 | ||
|
|
0d8a3576a6 | ||
|
|
8910c2750a | ||
|
|
12393d6df4 | ||
|
|
6c0a2439ad | ||
|
|
d83712127b | ||
|
|
55325cd7ad | ||
|
|
82e508fca6 | ||
|
|
8ff32e9363 | ||
|
|
477308632b | ||
|
|
9ffd06f5e3 | ||
|
|
a89c87819a | ||
|
|
b09ae3bae3 | ||
|
|
6ea8c0d40e | ||
|
|
079501337c | ||
|
|
5bf0382227 | ||
|
|
0199c59014 | ||
|
|
44fb63fc59 | ||
|
|
13a28d2dbd | ||
|
|
f87a7da32e | ||
|
|
8fdf9938c2 | ||
|
|
ee4d480961 | ||
|
|
bd866549a0 | ||
|
|
7c39f1d821 | ||
|
|
e6a7bb4c98 | ||
|
|
14cf3189b8 | ||
|
|
66b060627a | ||
|
|
d9bcf14baa | ||
|
|
c571e6ebf7 | ||
|
|
fb06a1aec3 | ||
|
|
5a0d08641b | ||
|
|
8a8419385e | ||
|
|
0d5dc6e785 | ||
|
|
1d90333803 | ||
|
|
91e6e62688 | ||
|
|
619a8a9753 | ||
|
|
0fe38e94d3 | ||
|
|
722e8adbdf | ||
|
|
886d5ac08c | ||
|
|
89d5ba4c37 | ||
|
|
b8b9f82d91 | ||
|
|
b3fc9bb5c3 | ||
|
|
d2ebcd8fbe | ||
|
|
68c9623c35 | ||
|
|
496d1df404 | ||
|
|
ea1046fe13 | ||
|
|
6d58d6e7f3 | ||
|
|
e1420140d1 | ||
|
|
484c2e0c2f | ||
|
|
31f7d53829 | ||
|
|
e3debfa5df | ||
|
|
a1305fba81 | ||
|
|
ca95d6c7f4 | ||
|
|
5513fc9850 | ||
|
|
86de98e644 | ||
|
|
fd374cd705 | ||
|
|
20b4658bef | ||
|
|
0850ba250c | ||
|
|
b71d188fd8 | ||
|
|
579f6b9a96 | ||
|
|
d9403a73c6 | ||
|
|
747811fa94 | ||
|
|
489e480394 | ||
|
|
418bcb0295 | ||
|
|
88f01554e7 | ||
|
|
c85092a644 | ||
|
|
096478bcec | ||
|
|
b8de4a85e0 | ||
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 | ||
|
|
d873718e88 | ||
|
|
706276839a | ||
|
|
d281ca5f87 | ||
|
|
6a9036bfef | ||
|
|
1b242f75c6 | ||
|
|
7ffd37289d | ||
|
|
cb859ae599 | ||
|
|
a17346c9c2 | ||
|
|
c17a39588d | ||
|
|
33cee9c0c2 | ||
|
|
e6d2920c27 | ||
|
|
d8195dbe2a | ||
|
|
4843f129c4 | ||
|
|
fcd1218dc4 | ||
|
|
eef0f971d7 | ||
|
|
ff09a8aba0 | ||
|
|
0c4b523d05 | ||
|
|
de7a435a01 | ||
|
|
124d399d1f | ||
|
|
e22cf71b15 | ||
|
|
670997ed36 | ||
|
|
1ccb6388e3 | ||
|
|
7d5be8d6aa | ||
|
|
133e4756b2 | ||
|
|
39ada734d5 | ||
|
|
19d88c5fba | ||
|
|
461b0936e2 | ||
|
|
e9ee5e87be | ||
|
|
5e66c5ef76 | ||
|
|
307dc3d726 |
@@ -5,4 +5,4 @@ alwaysApply: false
|
||||
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
|
||||
Never write "Loading" - always show a spinner, and just a spinner.
|
||||
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).
|
||||
|
||||
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
4
.gitignore
vendored
@@ -7,3 +7,7 @@ dist
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
applesauce
|
||||
|
||||
|
||||
767
CHANGELOG.md
188
TAILWIND_MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Tailwind CSS Migration Status
|
||||
|
||||
## ✅ Completed (Core Infrastructure)
|
||||
|
||||
### Phase 1: Setup & Foundation
|
||||
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||
- [x] Enable Tailwind preflight (CSS reset)
|
||||
|
||||
### Phase 2: Base Styles Reconciliation
|
||||
- [x] Add CSS variables for user-settable theme colors
|
||||
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||
- `--reading-font`, `--reading-font-size`
|
||||
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||
- [x] Remove redundant base styles handled by Tailwind
|
||||
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||
|
||||
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||
- [x] Switch from pane-scrolling to document-scrolling
|
||||
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||
- [x] Update `app.css` to remove fixed container heights
|
||||
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||
- [x] Fix reading position tracking to work with document scroll
|
||||
- [x] Maintain mobile overlay behavior
|
||||
|
||||
### Phase 4: Component Migrations
|
||||
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||
- Removed 80+ lines of CSS
|
||||
- Added shimmer animation to Tailwind config
|
||||
- Z-index layering maintained (1102)
|
||||
|
||||
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||
- Mobile hamburger button
|
||||
- Mobile highlights button
|
||||
- Mobile backdrop
|
||||
- Removed 60+ lines of CSS
|
||||
|
||||
- [x] **App Container**: Tailwind utilities
|
||||
- Responsive padding (p-0 md:p-4)
|
||||
- Min-height viewport support
|
||||
|
||||
## 📊 Impact & Metrics
|
||||
|
||||
### Lines of CSS Removed
|
||||
- `global.css`: ~50 lines removed
|
||||
- `reader.css`: ~80 lines removed (progress indicator)
|
||||
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||
- **Total**: ~190 lines removed
|
||||
|
||||
### Key Achievements
|
||||
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||
3. **No Breaking Changes**: All existing functionality preserved
|
||||
4. **Type Safety**: TypeScript checks passing
|
||||
5. **Lint Clean**: ESLint checks passing
|
||||
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||
|
||||
## 🔄 Remaining Work (Incremental)
|
||||
|
||||
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||
|
||||
### High-Value Components
|
||||
- [ ] **ContentPanel** - Large component, high impact
|
||||
- Reader header, meta info, loading states
|
||||
- Mark as read button
|
||||
- Article/video menus
|
||||
|
||||
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||
- Card layouts (compact/cards/large views)
|
||||
- Bookmark metadata display
|
||||
- Interactive states
|
||||
|
||||
- [ ] **HighlightsPanel** - Feature-rich
|
||||
- Header with toggles
|
||||
- Highlight items
|
||||
- Level-based styling
|
||||
|
||||
- [ ] **Settings Components** - Forms & controls
|
||||
- Color pickers
|
||||
- Font selectors
|
||||
- Toggle switches
|
||||
- Sliders
|
||||
|
||||
### CSS Files to Prune
|
||||
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||
- `src/styles/components/cards.css` - Bookmark card styles
|
||||
- `src/styles/components/modals.css` - Modal dialogs
|
||||
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||
|
||||
## 🎯 Migration Strategy
|
||||
|
||||
### For New Components
|
||||
Use Tailwind utilities from the start. Reference:
|
||||
```tsx
|
||||
// Good: Tailwind utilities
|
||||
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||
|
||||
// Avoid: New CSS classes
|
||||
<div className="custom-component">
|
||||
```
|
||||
|
||||
### For Existing Components
|
||||
Migrate incrementally when touching files:
|
||||
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||
2. Replace color/background utilities
|
||||
3. Replace typography utilities
|
||||
4. Replace responsive variants
|
||||
5. Remove old CSS rules
|
||||
6. Keep file under 210 lines
|
||||
|
||||
### CSS Variable Usage
|
||||
Dynamic values should still use CSS variables or inline styles:
|
||||
```tsx
|
||||
// User-settable colors
|
||||
style={{ backgroundColor: settings.highlightColorMine }}
|
||||
|
||||
// Or reference CSS variable
|
||||
className="bg-[var(--highlight-color-mine)]"
|
||||
```
|
||||
|
||||
## 📝 Technical Notes
|
||||
|
||||
### Z-Index Layering
|
||||
- Mobile sidepanes: `z-[1001]`
|
||||
- Mobile backdrop: `z-[999]`
|
||||
- Progress indicator: `z-[1102]`
|
||||
- Mobile buttons: `z-[900]`
|
||||
- Relay status: `z-[999]`
|
||||
- Modals: `z-[10000]`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Mobile: `< 768px`
|
||||
- Tablet: `768px - 1024px`
|
||||
- Desktop: `> 1024px`
|
||||
|
||||
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||
|
||||
### Safe Area Insets
|
||||
Mobile notch support:
|
||||
```tsx
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||
}}
|
||||
```
|
||||
|
||||
### Custom Animations
|
||||
Add to `tailwind.config.js`:
|
||||
```js
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
- [x] Tailwind CSS fully integrated and functional
|
||||
- [x] Document scrolling working correctly
|
||||
- [x] Reading position tracking accurate
|
||||
- [x] Progress indicator always visible
|
||||
- [x] No TypeScript errors
|
||||
- [x] No linting errors
|
||||
- [x] Mobile responsiveness maintained
|
||||
- [x] Theme colors (user settings) working
|
||||
- [x] All existing features functional
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Ship It**: Current state is production-ready
|
||||
2. **Incremental Migration**: Convert components as you touch them
|
||||
3. **Monitor**: Watch for any CSS conflicts
|
||||
4. **Cleanup**: Eventually remove unused CSS files
|
||||
5. **Document**: Update component docs with Tailwind patterns
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||
**Date**: 2025-01-14
|
||||
**Commits**: 8 conventional commits
|
||||
**Lines Removed**: ~190 lines of CSS
|
||||
**Breaking Changes**: None
|
||||
|
||||
201
api/video-meta.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||
|
||||
type Caption = { start: number; dur: number; text: string }
|
||||
|
||||
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
|
||||
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
|
||||
// YouTube patterns
|
||||
const youtubePatterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/v\/([^&\n?#]+)/
|
||||
]
|
||||
|
||||
for (const pattern of youtubePatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'youtube' }
|
||||
}
|
||||
}
|
||||
|
||||
// Vimeo patterns
|
||||
const vimeoPatterns = [
|
||||
/vimeo\.com\/(\d+)/,
|
||||
/player\.vimeo\.com\/video\/(\d+)/
|
||||
]
|
||||
|
||||
for (const pattern of vimeoPatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'vimeo' }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||
for (const lang of preferredLangs) {
|
||||
try {
|
||||
const caps = await getSubtitles({ videoID, lang })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
// Convert the returned subtitles to our Caption format
|
||||
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||
text: cap.text
|
||||
}))
|
||||
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const url = (req.query.url as string | undefined)?.trim()
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
|
||||
if (!url && !videoId) {
|
||||
return bad(res, 400, 'Missing url or videoId parameter')
|
||||
}
|
||||
|
||||
// Extract video info from URL or use provided videoId
|
||||
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
|
||||
|
||||
if (url) {
|
||||
const extracted = extractVideoId(url)
|
||||
if (!extracted) {
|
||||
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
|
||||
}
|
||||
videoInfo = extracted
|
||||
} else {
|
||||
// If only videoId is provided, assume YouTube for backward compatibility
|
||||
videoInfo = { id: videoId!, source: 'youtube' }
|
||||
}
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
if (videoInfo.source === 'youtube') {
|
||||
// YouTube handling
|
||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||
const title = ''
|
||||
const description = ''
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoInfo.id, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoInfo.id, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else {
|
||||
return bad(res, 400, 'Unsupported video source')
|
||||
}
|
||||
} catch (e) {
|
||||
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
|
||||
}
|
||||
}
|
||||
93
api/vimeo-meta.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string) {
|
||||
return `vimeo|${videoId}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
// Validate that videoId is a number
|
||||
if (!/^\d+$/.test(videoId)) {
|
||||
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
|
||||
}
|
||||
|
||||
const cacheKey = buildKey(videoId)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, description } = await getVimeoMetadata(videoId)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch Vimeo metadata')
|
||||
}
|
||||
}
|
||||
101
api/youtube-meta.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||
|
||||
type Caption = { start: number; dur: number; text: string }
|
||||
|
||||
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
|
||||
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||
for (const lang of preferredLangs) {
|
||||
try {
|
||||
const caps = await getSubtitles({ videoID, lang })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
// Convert the returned subtitles to our Caption format
|
||||
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||
text: cap.text
|
||||
}))
|
||||
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||
// In a real implementation, you might want to use YouTube's API or other methods
|
||||
const title = '' // Will be populated from captions or other sources
|
||||
const description = ''
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoId, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoId, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch YouTube metadata')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
index.html
@@ -2,8 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
@@ -20,6 +25,11 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
document.documentElement.className = 'theme-system';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
6936
package-lock.json
generated
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.3.6",
|
||||
"version": "0.6.7",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -12,8 +12,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
"@vercel/node": "^5.3.26",
|
||||
"applesauce-accounts": "^4.0.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
"applesauce-core": "^4.0.0",
|
||||
@@ -23,24 +26,35 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
@@ -59,7 +73,8 @@
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"react-refresh"
|
||||
"react-refresh",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
@@ -68,6 +83,7 @@
|
||||
"allowConstantExport": true
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 465 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
37
public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
56
public/sw.js
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
126
src/App.tsx
@@ -11,7 +11,9 @@ 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'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -27,8 +29,7 @@ function AppRoutes({
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
accountManager.clearActive()
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -70,6 +71,73 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -80,6 +148,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 () => {
|
||||
@@ -175,6 +244,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">
|
||||
@@ -184,22 +272,24 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
<SkeletonThemeProvider>
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
</SkeletonThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
@@ -139,7 +140,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [url]) // Only depend on url
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -182,7 +184,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
@@ -279,7 +281,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
58
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface AuthorCardProps {
|
||||
authorPubkey: string
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
|
||||
const navigate = useNavigate()
|
||||
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
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
const npub = nip19.npubEncode(authorPubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
|
||||
onClick={handleClick}
|
||||
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
@@ -28,15 +28,19 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
className="blog-post-card"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<div className="blog-post-card-image">
|
||||
{post.image ? (
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="blog-post-image-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
|
||||
@@ -110,8 +110,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -127,8 +125,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -10,6 +10,9 @@ import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -27,6 +30,7 @@ interface BookmarkListProps {
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -44,8 +48,22 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
settings
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
@@ -106,19 +124,36 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
relayPool={relayPool}
|
||||
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>
|
||||
</div>
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
</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
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
@@ -131,37 +166,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<div className="refresh-section" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
{lastFetchTime && (
|
||||
<span>
|
||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
@@ -8,7 +9,9 @@ import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -17,7 +20,6 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
@@ -34,7 +36,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -43,17 +44,49 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
|
||||
// Fetch OG image if we don't have any other image
|
||||
React.useEffect(() => {
|
||||
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
|
||||
fetchOgImage(firstUrl).then(setOgImage)
|
||||
}
|
||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
@@ -79,11 +112,12 @@ 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"
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
@@ -95,23 +129,22 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
const classification = classifyUrl(url)
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={() => onSelectUrl?.(url)}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel={classification.buttonText}
|
||||
title={classification.buttonText}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -119,7 +152,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 1 && (
|
||||
<button
|
||||
className="expand-toggle-urls"
|
||||
onClick={() => setUrlsExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
>
|
||||
@@ -148,7 +181,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
@@ -158,21 +191,16 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author in search"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-ico
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,10 +13,9 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -24,14 +24,17 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
articleSummary
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined, settings)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -55,6 +58,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
@@ -76,22 +86,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
</button>
|
||||
)}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
@@ -6,6 +7,7 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -14,7 +16,6 @@ interface LargeViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
@@ -31,7 +32,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
@@ -43,12 +43,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
} else {
|
||||
@@ -78,33 +94,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="large-author">
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{eventNevent && (
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
||||
<button className="large-read-button" onClick={handleReadNow}>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||
@@ -14,6 +15,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'
|
||||
@@ -24,24 +26,55 @@ interface BookmarksProps {
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
// Check for highlight navigation state
|
||||
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
|
||||
// Track previous location for going back from settings
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/archive' ? 'archive' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings) {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings])
|
||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -67,6 +100,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
})
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
@@ -87,6 +123,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlightVisibility
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
// Handle highlight navigation from explore page
|
||||
useEffect(() => {
|
||||
if (navigationState?.highlightId && navigationState?.openHighlights) {
|
||||
// Open the highlights sidebar
|
||||
setIsHighlightsCollapsed(false)
|
||||
// Select the highlight (scroll happens automatically in useHighlightInteractions)
|
||||
setSelectedHighlightId(navigationState.highlightId)
|
||||
|
||||
// Clear the state after handling to avoid re-triggering
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
}
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
@@ -116,7 +173,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setReaderLoading,
|
||||
readerContent,
|
||||
setReaderContent,
|
||||
handleSelectUrl
|
||||
handleSelectUrl: baseHandleSelectUrl
|
||||
} = useContentSelection({
|
||||
relayPool,
|
||||
settings,
|
||||
@@ -125,6 +182,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Wrap handleSelectUrl to close mobile sidebar when selecting content
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
baseHandleSelectUrl(url, bookmark)
|
||||
}
|
||||
|
||||
const {
|
||||
highlightButtonRef,
|
||||
handleTextSelection,
|
||||
@@ -180,19 +245,26 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
<ThreePaneLayout
|
||||
isCollapsed={isCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
isRefreshing={isRefreshing}
|
||||
lastFetchTime={lastFetchTime}
|
||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
||||
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
|
||||
onLogout={onLogout}
|
||||
onViewModeChange={setViewMode}
|
||||
onOpenSettings={() => {
|
||||
navigate('/settings')
|
||||
setIsCollapsed(true)
|
||||
if (isMobile) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
setIsCollapsed(true)
|
||||
}
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshAll}
|
||||
@@ -220,6 +292,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)}
|
||||
@@ -231,7 +305,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
|
||||
41
src/components/CompactButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
interface CompactButtonProps {
|
||||
icon?: IconDefinition
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
title?: string
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const CompactButton: React.FC<CompactButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = '',
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`compact-button ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompactButton
|
||||
|
||||
56
src/components/ConfirmDialog.tsx
Normal 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
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ContentSkeleton } from './Skeletons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
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'
|
||||
@@ -12,6 +23,19 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import {
|
||||
createEventReaction,
|
||||
createWebsiteReaction,
|
||||
hasMarkedEventAsRead,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -32,9 +56,15 @@ interface ContentPanelProps {
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -51,15 +81,30 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
currentArticle,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set(),
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
}) => {
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
html,
|
||||
@@ -74,13 +119,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
followedPubkeys
|
||||
})
|
||||
|
||||
const { contentRef, handleMouseUp } = useHighlightInteractions({
|
||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -90,6 +170,233 @@ 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 isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
// Load YouTube metadata/captions when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(selectedUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [selectedUrl])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
|
||||
// Get article links for menu
|
||||
const getArticleLinks = () => {
|
||||
if (!currentArticle) return null
|
||||
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3)
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: currentArticle.pubkey,
|
||||
identifier: dTag,
|
||||
relays: relayHints
|
||||
})
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(naddr),
|
||||
native: `nostr:${naddr}`
|
||||
}
|
||||
}
|
||||
|
||||
const articleLinks = getArticleLinks()
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = () => {
|
||||
if (articleLinks) {
|
||||
window.location.href = articleLinks.native
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
if (!selectedUrl) return
|
||||
const native = buildNativeVideoUrl(selectedUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = selectedUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
|
||||
const handleOpenExternalUrl = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
const checkReadStatus = async () => {
|
||||
if (!activeAccount || !relayPool || !selectedUrl) {
|
||||
setIsMarkedAsRead(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCheckingReadStatus(true)
|
||||
|
||||
try {
|
||||
let hasRead = false
|
||||
if (isNostrArticle && currentArticle) {
|
||||
hasRead = await hasMarkedEventAsRead(
|
||||
currentArticle.id,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
} else {
|
||||
hasRead = await hasMarkedWebsiteAsRead(
|
||||
selectedUrl,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
console.error('Failed to check read status:', error)
|
||||
} finally {
|
||||
setIsCheckingReadStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkReadStatus()
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
return
|
||||
}
|
||||
|
||||
// Instantly update UI with checkmark animation
|
||||
setIsMarkedAsRead(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
// Reset animation after it completes
|
||||
setTimeout(() => {
|
||||
setShowCheckAnimation(false)
|
||||
}, 600)
|
||||
|
||||
// Fire-and-forget: publish in background without blocking UI
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
// Revert UI state on error
|
||||
setIsMarkedAsRead(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -100,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="reader loading">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,56 +416,268 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
return (
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
<>
|
||||
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
|
||||
{isTextContent && (
|
||||
<ReadingProgressIndicator
|
||||
progress={progressPercentage}
|
||||
isComplete={isReadingComplete}
|
||||
showPercentage={true}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{processedMarkdown || markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
title={ytMeta?.title || title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
{isExternalVideo ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={selectedUrl as string}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
{ytMeta?.description && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{ytMeta.description}
|
||||
</div>
|
||||
)}
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className="article-menu">
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{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-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleExternalMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article menu for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && articleLinks && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showArticleMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,65 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
settings?: UserSettings
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
type TabType = 'writings' | 'highlights'
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exploreContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Visibility filters (defaults from settings)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse !== false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends !== false,
|
||||
mine: settings?.defaultHighlightVisibilityMine !== false
|
||||
})
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
@@ -27,43 +67,168 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// show spinner but keep existing data
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cachedPosts)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
|
||||
setHighlights(cachedHighlights)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
const contacts = await fetchContacts(
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
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) => {
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
// Fetch highlights
|
||||
fetchHighlightsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
(highlight) => {
|
||||
setHighlights((prev) => {
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setError('You are not following anyone yet. Follow some people to see their content!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get relay URLs from pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch blog posts from friends
|
||||
const posts = await fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(contacts),
|
||||
relayUrls
|
||||
)
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
// Fetch both friends content and nostrverse content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const contactsArray = Array.from(contacts)
|
||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||
fetchNostrverseHighlights(relayPool, 100)
|
||||
])
|
||||
|
||||
// Merge and deduplicate all posts
|
||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||
const postsByKey = new Map<string, BlogPostPreview>()
|
||||
for (const post of allPosts) {
|
||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||
const existing = postsByKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
postsByKey.set(key, post)
|
||||
}
|
||||
}
|
||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// Merge and deduplicate all highlights
|
||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||
const highlightsByKey = new Map<string, Highlight>()
|
||||
for (const highlight of allHighlights) {
|
||||
highlightsByKey.set(highlight.id, highlight)
|
||||
}
|
||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Fetch profiles for all blog post authors to cache them
|
||||
if (uniquePosts.length > 0) {
|
||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
||||
console.error('Failed to fetch author profiles:', err)
|
||||
})
|
||||
}
|
||||
|
||||
setBlogPosts(posts)
|
||||
if (uniquePosts.length === 0 && uniqueHighlights.length === 0) {
|
||||
setError('No content found yet')
|
||||
}
|
||||
|
||||
setBlogPosts(uniquePosts)
|
||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||
|
||||
setHighlights(uniqueHighlights)
|
||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load content. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
@@ -79,12 +244,132 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
return classified.filter(h => {
|
||||
if (h.level === 'mine' && !visibility.mine) return false
|
||||
if (h.level === 'friends' && !visibility.friends) return false
|
||||
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
|
||||
return true
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||
|
||||
// Filter blog posts by future dates and visibility
|
||||
const filteredBlogPosts = useMemo(() => {
|
||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||
return blogPosts.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const isNostrverse = !isMine && !isFriend
|
||||
|
||||
if (isMine && !visibility.mine) return false
|
||||
if (isFriend && !visibility.friends) return false
|
||||
if (isNostrverse && !visibility.nostrverse) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'writings':
|
||||
return filteredBlogPosts.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 className="explore-grid">
|
||||
{filteredBlogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'highlights':
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No highlights yet. Your friends should start highlighting content!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<p>Loading blog posts from your friends...</p>
|
||||
<div className="explore-container" aria-busy="true">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
</div>
|
||||
<div className="explore-grid">
|
||||
{activeTab === 'writings' ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))
|
||||
) : (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -102,25 +387,85 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div
|
||||
ref={exploreContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
Discover highlights and blog posts from your friends and others
|
||||
</p>
|
||||
</div>
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/explore')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/explore/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visibility filters */}
|
||||
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||
title="Toggle nostrverse content"
|
||||
ariaLabel="Toggle nostrverse content"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: visibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||
ariaLabel="Toggle friends content"
|
||||
variant="ghost"
|
||||
disabled={!activeAccount}
|
||||
style={{
|
||||
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: visibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||
ariaLabel="Toggle my content"
|
||||
variant="ghost"
|
||||
disabled={!activeAccount}
|
||||
style={{
|
||||
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: visibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/HighlightCitation.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface HighlightCitationProps {
|
||||
highlight: Highlight
|
||||
relayPool?: RelayPool | null
|
||||
}
|
||||
|
||||
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
highlight,
|
||||
relayPool
|
||||
}) => {
|
||||
const [articleTitle, setArticleTitle] = useState<string>()
|
||||
|
||||
// Extract author pubkey from p tag directly
|
||||
const authorPubkey = useMemo(() => {
|
||||
// First try the extracted author from highlight.author
|
||||
if (highlight.author) {
|
||||
return highlight.author
|
||||
}
|
||||
|
||||
// Fallback: extract directly from p tag
|
||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||
if (pTag && pTag[1]) {
|
||||
console.log('📝 Found author from p tag:', pTag[1])
|
||||
return pTag[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [highlight.author, highlight.tags])
|
||||
|
||||
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight.eventReference || !relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadTitle = async () => {
|
||||
try {
|
||||
if (!highlight.eventReference) return
|
||||
|
||||
// Convert eventReference to naddr if needed
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
} else {
|
||||
naddr = highlight.eventReference
|
||||
}
|
||||
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
if (title) {
|
||||
setArticleTitle(title)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load article title:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadTitle()
|
||||
}, [highlight.eventReference, relayPool])
|
||||
|
||||
const authorName = authorProfile?.name || authorProfile?.display_name
|
||||
|
||||
// For nostr-native content with article reference
|
||||
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For web URLs
|
||||
if (highlight.urlReference) {
|
||||
try {
|
||||
const url = new URL(highlight.urlReference)
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {url.hostname}
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,174 @@
|
||||
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, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faComments } from '@fortawesome/free-regular-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'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname.toLowerCase()
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to render a nostr identifier
|
||||
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
||||
try {
|
||||
// Remove nostr: prefix
|
||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||
const decoded = nip19.decode(identifier)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/p/${nip19.npubEncode(pubkey)}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{pubkey.slice(0, 8)}...
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'nprofile': {
|
||||
const { pubkey } = decoded.data
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/p/${npub}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{pubkey.slice(0, 8)}...
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data
|
||||
// Check if it's a blog post (kind:30023)
|
||||
if (kind === 30023) {
|
||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/a/${naddr}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{identifier || 'Article'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
// For other kinds, show shortened identifier
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
nostr:{identifier.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'note': {
|
||||
const eventId = decoded.data
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
note:{eventId.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'nevent': {
|
||||
const { id } = decoded.data
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
event:{id.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
default:
|
||||
// Fallback for unrecognized types
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
{identifier.slice(0, 20)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, show shortened identifier
|
||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
{identifier.slice(0, 20)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Component to render comment with links, inline images, and nostr identifiers
|
||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||
// Pattern to match both http(s) URLs and nostr: URIs
|
||||
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
|
||||
const parts = text.split(urlPattern)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// Handle nostr: URIs
|
||||
if (part.startsWith('nostr:')) {
|
||||
return renderNostrId(part, index)
|
||||
}
|
||||
|
||||
// Handle http(s) URLs
|
||||
if (part.match(/^https?:\/\//)) {
|
||||
if (isImageUrl(part)) {
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={part}
|
||||
alt="Comment attachment"
|
||||
className="highlight-comment-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -23,21 +182,31 @@ interface HighlightItemProps {
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
onHighlightUpdate?: (highlight: Highlight) => void
|
||||
onHighlightDelete?: (highlightId: string) => void
|
||||
showCitation?: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
showCitation = true
|
||||
}) => {
|
||||
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 +257,49 @@ 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 getHighlightLinks = () => {
|
||||
// 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 {
|
||||
portal: getNostrUrl(nevent),
|
||||
native: `nostr:${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 highlightLinks = getHighlightLinks()
|
||||
|
||||
// Handle rebroadcast to all relays
|
||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||
@@ -207,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
// Always show relay list, use plane icon for local-only
|
||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||
|
||||
// Show server icon with relay info if available
|
||||
// Show highlighter icon with relay info if available
|
||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||
const relayNames = highlight.publishedRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: isLocalOrOffline ? faPlane : faServer,
|
||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -224,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -235,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -243,7 +400,69 @@ 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 handleOpenPortal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
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}` : ''}`}
|
||||
@@ -251,56 +470,119 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-quote-icon">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||
{relayIndicator && (
|
||||
<div
|
||||
className="highlight-relay-indicator"
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
||||
</div>
|
||||
)}
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
className="highlight-timestamp"
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</CompactButton>
|
||||
</div>
|
||||
|
||||
<CompactButton
|
||||
className="highlight-quote-button"
|
||||
icon={faQuoteLeft}
|
||||
title="Quote"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||
|
||||
<div className="highlight-content">
|
||||
<blockquote className="highlight-text">
|
||||
{highlight.content}
|
||||
</blockquote>
|
||||
|
||||
{showCitation && (
|
||||
<HighlightCitation
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
/>
|
||||
)}
|
||||
|
||||
{highlight.comment && (
|
||||
<div className="highlight-comment">
|
||||
{highlight.comment}
|
||||
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||
<div className="highlight-comment-text">
|
||||
<CommentContent text={highlight.comment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</span>
|
||||
<div className="highlight-footer">
|
||||
<div className="highlight-footer-left">
|
||||
{relayIndicator && (
|
||||
<CompactButton
|
||||
className="highlight-relay-indicator"
|
||||
icon={relayIndicator.icon}
|
||||
spin={relayIndicator.spin}
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
disabled={!relayPool || !eventStore}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
)}
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
<CompactButton
|
||||
icon={faEllipsisH}
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
/>
|
||||
|
||||
{showMenu && (
|
||||
<div className="highlight-menu">
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightSkeleton } from './Skeletons'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -32,6 +36,7 @@ interface HighlightsPanelProps {
|
||||
followedPubkeys?: Set<string>
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -50,16 +55,29 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore
|
||||
eventStore,
|
||||
settings
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
const highlightsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
React.useEffect(() => {
|
||||
@@ -72,6 +90,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 +108,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
<HighlightsPanelCollapsed
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
settings={settings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -104,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-loading">
|
||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||
<div className="highlights-list" aria-busy="true">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-empty">
|
||||
@@ -118,7 +144,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list">
|
||||
<div
|
||||
ref={highlightsListRef}
|
||||
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
@@ -129,6 +164,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
onHighlightUpdate={handleHighlightUpdate}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
showCitation={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
onClick={onToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
||||
</button>
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ interface IconButtonProps {
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -21,15 +23,17 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
variant = 'ghost',
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
spin = false,
|
||||
className = '',
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`icon-button ${variant}`}
|
||||
className={`icon-button ${variant} ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
style={{ width: size, height: size, ...style }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
|
||||
437
src/components/Me.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { getProfileUrl } from '../config/nostrGateways'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const meContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights and writings (public data)
|
||||
const [userHighlights, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, viewingPubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setWritings(userWritings)
|
||||
|
||||
// Only fetch private data for own profile
|
||||
if (isOwnProfile && activeAccount) {
|
||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} else {
|
||||
setBookmarks([])
|
||||
setReadArticles([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted (own profile only)
|
||||
if (isOwnProfile && viewingPubkey) {
|
||||
updateCachedHighlights(viewingPubkey, updated)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
let hasUrl = false
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
if (ib.kind === 30023) return true
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For regular URLs, navigate to the reader route
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container" aria-busy="true">
|
||||
{viewingPubkey && (
|
||||
<div className="explore-header">
|
||||
<AuthorCard authorPubkey={viewingPubkey} />
|
||||
</div>
|
||||
)}
|
||||
<div className="explore-grid">
|
||||
{activeTab === 'writings' ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))
|
||||
) : activeTab === 'highlights' ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))
|
||||
) : (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Start highlighting content to see them here!'
|
||||
: 'No highlights yet. You should shame them on nostr!'}
|
||||
</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>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => setViewMode('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => setViewMode('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
return writings.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No articles written yet. Publish your first article to see it here!'
|
||||
: (
|
||||
<>
|
||||
No articles written. You can find other stuff from this user using{' '}
|
||||
<a
|
||||
href={viewingPubkey ? getProfileUrl(nip19.npubEncode(viewingPubkey)) : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'rgb(99 102 241)', textDecoration: 'underline' }}
|
||||
>
|
||||
ants
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{writings.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={meContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="me-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Me
|
||||
|
||||
52
src/components/PullToRefreshIndicator.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PullToRefreshIndicatorProps {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
isRefreshing: boolean
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
|
||||
isPulling,
|
||||
pullDistance,
|
||||
canRefresh,
|
||||
threshold = 80
|
||||
}) => {
|
||||
// Only show when actively pulling, not when refreshing
|
||||
if (!isPulling) return null
|
||||
|
||||
const opacity = Math.min(pullDistance / threshold, 1)
|
||||
const rotation = (pullDistance / threshold) * 180
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pull-to-refresh-indicator"
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${-20 + pullDistance / 2}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pull-to-refresh-icon"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDown}
|
||||
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="pull-to-refresh-text">
|
||||
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PullToRefreshIndicator
|
||||
|
||||
@@ -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,40 +29,87 @@ 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 getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
|
||||
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)`,
|
||||
// Only force white color in overlay context, otherwise let CSS handle it
|
||||
...(isOverlay && { color: '#fff' })
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
if (cachedImage) {
|
||||
return (
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className="reader-summary">{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLongSummary && (
|
||||
<div className="reader-summary-below-image">
|
||||
<p className="reader-summary">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +132,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
70
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ReadingProgressIndicatorProps {
|
||||
progress: number // 0 to 100
|
||||
isComplete?: boolean
|
||||
showPercentage?: boolean
|
||||
className?: string
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
}
|
||||
|
||||
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||
progress,
|
||||
isComplete = false,
|
||||
showPercentage = true,
|
||||
className = '',
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
}) => {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||
|
||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||
const leftOffset = isSidebarCollapsed
|
||||
? 'var(--sidebar-collapsed-width)'
|
||||
: 'var(--sidebar-width)'
|
||||
const rightOffset = isHighlightsCollapsed
|
||||
? 'var(--highlights-collapsed-width)'
|
||||
: 'var(--highlights-width)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
|
||||
style={{
|
||||
'--left-offset': leftOffset,
|
||||
'--right-offset': rightOffset,
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
opacity: 0.95
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
width: `${clampedProgress}%`,
|
||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<div
|
||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : ''
|
||||
}`}
|
||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
||||
>
|
||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,22 @@ 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
|
||||
showOnMobile?: boolean // Control visibility based on scroll
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
relayPool,
|
||||
showOnMobile = true
|
||||
}) => {
|
||||
// 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
|
||||
|
||||
@@ -52,41 +59,114 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, default to collapsed (icon only). On desktop, always show details.
|
||||
const showDetails = !isMobile || isExpanded
|
||||
|
||||
// On mobile when collapsed, make it circular like the highlight button
|
||||
const isCollapsedOnMobile = 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' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||
title={
|
||||
!isMobile ? (
|
||||
isConnecting
|
||||
? 'Connecting to relays...'
|
||||
: offlineMode
|
||||
? 'Offline - No relays connected'
|
||||
: 'Local Relays Only - Highlights will be marked as local'
|
||||
) : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
left: '32px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: showDetails ? '0.5rem' : '0',
|
||||
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
|
||||
width: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
height: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
backgroundColor: 'rgba(39, 39, 42, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgb(82, 82, 91)',
|
||||
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
|
||||
color: 'rgb(228, 228, 231)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
cursor: isMobile ? 'pointer' : 'default',
|
||||
opacity: isMobile && !showOnMobile ? 0 : 1,
|
||||
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
|
||||
transition: 'all 0.3s ease',
|
||||
userSelect: 'none',
|
||||
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.125rem'
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
No relays connected
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, Helpers } from 'applesauce-core'
|
||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||
@@ -24,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
||||
|
||||
if (npub) {
|
||||
return (
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${npub}`}
|
||||
<Link
|
||||
to={`/p/${npub}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@{display}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
@@ -57,7 +59,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
// Poll for relay status updates
|
||||
@@ -158,12 +160,14 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
<PWASettings />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
84
src/components/Settings/PWASettings.tsx
Normal 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
@@ -73,7 +72,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#ffff00'}
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
||||
title="Nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
||||
title="Friends highlights"
|
||||
aria-label="Toggle friends highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Toggle my highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
<div
|
||||
className="relay-url"
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{formatRelayUrl(relay.url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
107
src/components/Settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
|
||||
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
|
||||
|
||||
interface ThemeSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const currentTheme = settings.theme ?? 'system'
|
||||
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
|
||||
const currentLightColor = settings.lightColorTheme ?? 'sepia'
|
||||
|
||||
// Determine which color picker to show based on current theme
|
||||
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
|
||||
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
|
||||
|
||||
// Color definitions for swatches
|
||||
const darkColors = {
|
||||
black: '#000000',
|
||||
midnight: '#18181b',
|
||||
charcoal: '#1c1c1e'
|
||||
}
|
||||
|
||||
const lightColors = {
|
||||
'paper-white': '#ffffff',
|
||||
sepia: '#f4f1ea',
|
||||
ivory: '#fffff0'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Theme</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Appearance</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faSun}
|
||||
onClick={() => onUpdate({ theme: 'light' })}
|
||||
title="Light theme"
|
||||
ariaLabel="Light theme"
|
||||
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faMoon}
|
||||
onClick={() => onUpdate({ theme: 'dark' })}
|
||||
title="Dark theme"
|
||||
ariaLabel="Dark theme"
|
||||
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faDesktop}
|
||||
onClick={() => onUpdate({ theme: 'system' })}
|
||||
title="Use system preference"
|
||||
ariaLabel="Use system preference"
|
||||
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDarkColors && (
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Dark Theme</label>
|
||||
<div className="color-picker">
|
||||
{Object.entries(darkColors).map(([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
|
||||
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLightColors && (
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Light Theme</label>
|
||||
<div className="color-picker">
|
||||
{Object.entries(lightColors).map(([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
|
||||
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSettings
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -17,9 +17,10 @@ interface SidebarHeaderProps {
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
@@ -35,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
@@ -66,20 +67,35 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
variant="ghost"
|
||||
className="mobile-close-btn"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
|
||||
42
src/components/Skeletons/BlogPostSkeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const BlogPostSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="blog-post-card"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="blog-post-card-image">
|
||||
<Skeleton height={200} style={{ display: 'block' }} />
|
||||
</div>
|
||||
<div className="blog-post-card-content">
|
||||
<Skeleton
|
||||
height={24}
|
||||
width="85%"
|
||||
style={{ marginBottom: '0.75rem' }}
|
||||
className="blog-post-card-title"
|
||||
/>
|
||||
<Skeleton
|
||||
count={2}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
className="blog-post-card-summary"
|
||||
/>
|
||||
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
|
||||
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton width={100} height={14} />
|
||||
</span>
|
||||
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton width={80} height={14} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/components/Skeletons/BookmarkSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
import { ViewMode } from '../Bookmarks'
|
||||
|
||||
interface BookmarkSkeletonProps {
|
||||
viewMode: ViewMode
|
||||
}
|
||||
|
||||
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
|
||||
if (viewMode === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className="bookmark-item-compact"
|
||||
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
|
||||
<Skeleton width={40} height={40} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width="60%" height={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'cards') {
|
||||
return (
|
||||
<div
|
||||
className="bookmark-card"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton height={160} style={{ display: 'block' }} />
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
<Skeleton width={80} height={14} />
|
||||
<Skeleton width={60} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// large view
|
||||
return (
|
||||
<div
|
||||
className="bookmark-large"
|
||||
style={{
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton height={240} style={{ display: 'block' }} />
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<Skeleton circle width={32} height={32} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width={100} height={12} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
src/components/Skeletons/ContentSkeleton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const ContentSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="reader-content"
|
||||
style={{
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
padding: '2rem 1rem'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Title */}
|
||||
<Skeleton
|
||||
height={48}
|
||||
width="90%"
|
||||
style={{ marginBottom: '1rem' }}
|
||||
/>
|
||||
|
||||
{/* Byline / Meta */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
|
||||
<Skeleton circle width={40} height={40} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width={200} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<Skeleton
|
||||
height={400}
|
||||
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||
/>
|
||||
|
||||
{/* Paragraphs */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="80%" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="65%" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="90%" />
|
||||
</div>
|
||||
|
||||
{/* Another image placeholder */}
|
||||
<Skeleton
|
||||
height={300}
|
||||
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="75%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/components/Skeletons/HighlightSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const HighlightSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="highlight-item"
|
||||
style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Author line with avatar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<Skeleton circle width={24} height={24} />
|
||||
<Skeleton width={120} height={14} />
|
||||
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
|
||||
{/* Highlight content */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width="70%" />
|
||||
</div>
|
||||
|
||||
{/* Citation/context */}
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<Skeleton width="90%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/components/Skeletons/SkeletonThemeProvider.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { SkeletonTheme } from 'react-loading-skeleton'
|
||||
|
||||
interface SkeletonThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
|
||||
const [colors, setColors] = useState({
|
||||
baseColor: '#27272a',
|
||||
highlightColor: '#52525b'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const updateColors = () => {
|
||||
const rootStyles = getComputedStyle(document.documentElement)
|
||||
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
|
||||
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
|
||||
|
||||
setColors({ baseColor, highlightColor })
|
||||
}
|
||||
|
||||
// Initial update
|
||||
updateColors()
|
||||
|
||||
// Watch for theme changes via MutationObserver
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
updateColors()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/components/Skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
|
||||
export { BookmarkSkeleton } from './BookmarkSkeleton'
|
||||
export { BlogPostSkeleton } from './BlogPostSkeleton'
|
||||
export { HighlightSkeleton } from './HighlightSkeleton'
|
||||
export { ContentSkeleton } from './ContentSkeleton'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
@@ -16,13 +18,20 @@ import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface ThreePaneLayoutProps {
|
||||
// Layout state
|
||||
isCollapsed: boolean
|
||||
isHighlightsCollapsed: boolean
|
||||
isSidebarOpen: boolean
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
showProfile?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -54,6 +63,8 @@ interface ThreePaneLayoutProps {
|
||||
onClearSelection: () => void
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys: Set<string>
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
|
||||
// Highlights pane
|
||||
highlights: Highlight[]
|
||||
@@ -76,17 +87,207 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Explore content
|
||||
explore?: React.ReactNode
|
||||
|
||||
// Optional Me content
|
||||
me?: React.ReactNode
|
||||
|
||||
// Optional Profile content
|
||||
profile?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
// Handle ESC key to close sidebar or highlights
|
||||
useEffect(() => {
|
||||
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||
|
||||
if (!isMobile) return
|
||||
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isSidebarOpen) {
|
||||
onToggleSidebar()
|
||||
} else if (!isHighlightsCollapsed) {
|
||||
onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isMobile, props])
|
||||
|
||||
// Trap focus in sidebar when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
|
||||
|
||||
const sidebar = sidebarRef.current
|
||||
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sidebar.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
sidebar.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen])
|
||||
|
||||
// Trap focus in highlights panel when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
|
||||
|
||||
const highlights = highlightsRef.current
|
||||
const focusableElements = highlights.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlights.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
highlights.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isHighlightsCollapsed])
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (isMobile) {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))',
|
||||
width: '40px',
|
||||
height: '40px'
|
||||
}}
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
right: 'calc(1rem + env(safe-area-inset-right))',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
|
||||
color: '#000'
|
||||
}}
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
|
||||
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
|
||||
}`}
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
<div className="pane sidebar">
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
onSelectUrl={props.onSelectUrl}
|
||||
isCollapsed={props.isCollapsed}
|
||||
isCollapsed={isMobile ? false : props.isCollapsed}
|
||||
onToggleCollapse={props.onToggleSidebar}
|
||||
onLogout={props.onLogout}
|
||||
viewMode={props.viewMode}
|
||||
@@ -99,9 +300,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
<div
|
||||
ref={mainPaneRef}
|
||||
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||
>
|
||||
{props.showSettings ? (
|
||||
<Settings
|
||||
settings={props.settings}
|
||||
@@ -114,6 +319,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.explore}
|
||||
</>
|
||||
) : props.showMe && props.me ? (
|
||||
// Render Me inside the main pane to keep side panels
|
||||
<>
|
||||
{props.me}
|
||||
</>
|
||||
) : props.showProfile && props.profile ? (
|
||||
// Render Profile inside the main pane to keep side panels
|
||||
<>
|
||||
{props.profile}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
@@ -136,10 +351,19 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pane highlights">
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
loading={props.highlightsLoading}
|
||||
@@ -157,6 +381,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,10 +389,13 @@ 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} />
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
message={props.toastMessage}
|
||||
|
||||
34
src/config/nostrGateways.ts
Normal 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}`
|
||||
}
|
||||
|
||||
@@ -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?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -3,12 +3,15 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { ViewMode } from '../components/Bookmarks'
|
||||
import { useIsMobile } from './useMediaQuery'
|
||||
|
||||
interface UseBookmarksUIParams {
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
@@ -23,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
mine: true
|
||||
})
|
||||
|
||||
// Auto-collapse sidebar on mobile based on settings
|
||||
useEffect(() => {
|
||||
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
|
||||
if (isMobile && autoCollapse) {
|
||||
setIsSidebarOpen(false)
|
||||
} else if (!isMobile) {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}, [isMobile, settings.autoCollapseSidebarOnMobile])
|
||||
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
@@ -34,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(prev => !prev)
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
|
||||
@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
// Helper to extract filename from URL
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
|
||||
// Decode URI component to handle special characters
|
||||
return decodeURIComponent(filename) || url
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
@@ -11,7 +24,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 +70,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 {
|
||||
@@ -70,8 +97,10 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
// For videos and other media files, use the filename as the title
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: 'Error Loading Content',
|
||||
title: filename,
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
@@ -77,8 +78,18 @@ export const useHighlightCreation = ({
|
||||
publishedRelays: newHighlight.publishedRelays
|
||||
})
|
||||
|
||||
// Clear the browser's text selection immediately to allow DOM update
|
||||
const selection = window.getSelection()
|
||||
if (selection) {
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
onHighlightCreated(newHighlight)
|
||||
|
||||
// Force synchronous render to show highlight immediately
|
||||
flushSync(() => {
|
||||
onHighlightCreated(newHighlight)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create highlight:', error)
|
||||
// Re-throw to allow parent to handle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface UseHighlightInteractionsParams {
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
|
||||
onClearSelection
|
||||
}: UseHighlightInteractionsParams) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [contentVersion, setContentVersion] = useState(0)
|
||||
|
||||
// Watch for DOM changes (highlights being added/removed)
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
// Increment version to trigger re-attachment of handlers
|
||||
setContentVersion(prev => prev + 1)
|
||||
})
|
||||
|
||||
observer.observe(contentRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: false
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
@@ -37,27 +56,45 @@ export const useHighlightInteractions = ({
|
||||
mark.removeEventListener('click', handler)
|
||||
})
|
||||
}
|
||||
}, [onHighlightClick])
|
||||
}, [onHighlightClick, contentVersion])
|
||||
|
||||
// Scroll to selected highlight
|
||||
// Scroll to selected highlight with retry mechanism
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // Try for up to 2 seconds
|
||||
const retryDelay = 100
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
const tryScroll = () => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++
|
||||
setTimeout(tryScroll, retryDelay)
|
||||
} else {
|
||||
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
|
||||
}
|
||||
}
|
||||
}, [selectedHighlightId])
|
||||
|
||||
// Start trying after a small initial delay
|
||||
const timeoutId = setTimeout(tryScroll, 100)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [selectedHighlightId, contentVersion])
|
||||
|
||||
// Handle text selection
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Handle text selection (works for both mouse and touch)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
@@ -76,6 +113,6 @@ export const useHighlightInteractions = ({
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
return { contentRef, handleMouseUp }
|
||||
return { contentRef, handleSelectionEnd }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,86 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||
|
||||
/**
|
||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||
*/
|
||||
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
|
||||
export const useMarkdownToHTML = (
|
||||
markdown?: string,
|
||||
relayPool?: RelayPool | null
|
||||
): {
|
||||
renderedHtml: string
|
||||
previewRef: React.RefObject<HTMLDivElement>
|
||||
processedMarkdown: string
|
||||
} => {
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
let isCancelled = false
|
||||
|
||||
const processMarkdown = async () => {
|
||||
// Extract all naddr references
|
||||
const naddrs = extractNaddrUris(markdown)
|
||||
|
||||
let processed: string
|
||||
|
||||
if (naddrs.length > 0 && relayPool) {
|
||||
// Fetch article titles for all naddrs
|
||||
try {
|
||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
// No articles to resolve, use basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [markdown])
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
return { renderedHtml, previewRef }
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
processMarkdown()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [markdown, relayPool])
|
||||
|
||||
return { renderedHtml, previewRef, processedMarkdown }
|
||||
}
|
||||
|
||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||
|
||||
62
src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to detect if a media query matches
|
||||
* @param query The media query string (e.g., '(max-width: 768px)')
|
||||
* @returns true if the media query matches, false otherwise
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.matchMedia(query).matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
|
||||
// Update state if the media query changes
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
// Legacy browsers
|
||||
else {
|
||||
mediaQuery.addListener(handleChange)
|
||||
return () => mediaQuery.removeListener(handleChange)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the user is on a coarse pointer device (touch)
|
||||
* @returns true if the user is using a coarse pointer (touch), false otherwise
|
||||
*/
|
||||
export function useIsCoarsePointer(): boolean {
|
||||
return useMediaQuery('(pointer: coarse)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is mobile-sized
|
||||
* @returns true if viewport width is <= 768px, false otherwise
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 768px)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is tablet-sized
|
||||
* @returns true if viewport width is <= 1024px, false otherwise
|
||||
*/
|
||||
export function useIsTablet(): boolean {
|
||||
return useMediaQuery('(max-width: 1024px)')
|
||||
}
|
||||
|
||||
28
src/hooks/useOnlineStatus.ts
Normal 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
|
||||
}
|
||||
|
||||
74
src/hooks/usePWAInstall.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
153
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, RefObject } from 'react'
|
||||
import { useIsCoarsePointer } from './useMediaQuery'
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing?: boolean
|
||||
disabled?: boolean
|
||||
threshold?: number // Distance in pixels to trigger refresh
|
||||
resistance?: number // Resistance factor (higher = harder to pull)
|
||||
}
|
||||
|
||||
interface PullToRefreshState {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable pull-to-refresh gesture on touch devices
|
||||
* @param containerRef - Ref to the scrollable container element
|
||||
* @param options - Configuration options
|
||||
* @returns State of the pull gesture
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UsePullToRefreshOptions
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
disabled = false,
|
||||
threshold = 80,
|
||||
resistance = 2.5
|
||||
} = options
|
||||
|
||||
const isTouch = useIsCoarsePointer()
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
|
||||
const touchStartY = useRef<number>(0)
|
||||
const startScrollTop = useRef<number>(0)
|
||||
const isDragging = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !isTouch || disabled || isRefreshing) return
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
const scrollTop = container.scrollTop
|
||||
if (scrollTop <= 0) {
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
startScrollTop.current = scrollTop
|
||||
isDragging.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const deltaY = currentY - touchStartY.current
|
||||
const scrollTop = container.scrollTop
|
||||
|
||||
// Only pull down when at top and pulling down
|
||||
if (scrollTop <= 0 && deltaY > 0) {
|
||||
// Prevent default scroll behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Apply resistance to make pulling feel natural
|
||||
const distance = Math.min(deltaY / resistance, threshold * 1.5)
|
||||
const canRefresh = distance >= threshold
|
||||
|
||||
setPullState({
|
||||
isPulling: true,
|
||||
pullDistance: distance,
|
||||
canRefresh
|
||||
})
|
||||
} else {
|
||||
// Reset if scrolled or pulling up
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
isDragging.current = false
|
||||
|
||||
if (pullState.canRefresh && !isRefreshing) {
|
||||
// Keep the indicator visible while refreshing
|
||||
setPullState(prev => ({
|
||||
...prev,
|
||||
isPulling: false
|
||||
}))
|
||||
|
||||
// Trigger refresh
|
||||
await onRefresh()
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners with passive: false to allow preventDefault
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
|
||||
|
||||
// Reset pull state when refresh completes
|
||||
useEffect(() => {
|
||||
if (!isRefreshing && pullState.isPulling) {
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}, [isRefreshing, pullState.isPulling])
|
||||
|
||||
return pullState
|
||||
}
|
||||
|
||||
73
src/hooks/useReadingPosition.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Get the main content area (reader content)
|
||||
const readerContent = document.querySelector('.reader-html, .reader-markdown')
|
||||
if (!readerContent) return
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
handleScroll()
|
||||
|
||||
// Add scroll listener
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
}
|
||||
}
|
||||
70
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
|
||||
export type ScrollDirection = 'up' | 'down' | 'none'
|
||||
|
||||
interface UseScrollDirectionOptions {
|
||||
threshold?: number
|
||||
enabled?: boolean
|
||||
elementRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect scroll direction on window or a specific element
|
||||
* @param options Configuration options
|
||||
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
|
||||
* @param options.enabled Whether scroll detection is enabled (default: true)
|
||||
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
|
||||
* @returns Current scroll direction ('up', 'down', or 'none')
|
||||
*/
|
||||
export function useScrollDirection({
|
||||
threshold = 10,
|
||||
enabled = true,
|
||||
elementRef
|
||||
}: UseScrollDirectionOptions = {}): ScrollDirection {
|
||||
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const scrollElement = elementRef?.current || window
|
||||
const getScrollY = () => {
|
||||
if (elementRef?.current) {
|
||||
return elementRef.current.scrollTop
|
||||
}
|
||||
return window.scrollY
|
||||
}
|
||||
|
||||
let lastScrollY = getScrollY()
|
||||
let ticking = false
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = getScrollY()
|
||||
|
||||
// Only update if scroll distance exceeds threshold
|
||||
if (Math.abs(scrollY - lastScrollY) < threshold) {
|
||||
ticking = false
|
||||
return
|
||||
}
|
||||
|
||||
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0
|
||||
ticking = false
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(updateScrollDirection)
|
||||
ticking = true
|
||||
}
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', onScroll)
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [threshold, enabled, elementRef])
|
||||
|
||||
return scrollDirection
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface UseSettingsParams {
|
||||
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
||||
|
||||
// Apply theme with color variants (defaults to 'system' if not set)
|
||||
applyTheme(
|
||||
settings.theme ?? 'system',
|
||||
settings.darkColorTheme ?? 'midnight',
|
||||
settings.lightColorTheme ?? 'sepia'
|
||||
)
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
@@ -61,7 +69,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#fde047')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
|
||||
1
src/icons/books.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
52
src/icons/customIcons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
|
||||
import booksSvg from './books.svg?raw'
|
||||
|
||||
/**
|
||||
* Custom icon definitions for FontAwesome Pro icons
|
||||
* or any custom SVG icons that aren't in the free tier
|
||||
*/
|
||||
|
||||
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
|
||||
const { prefix, iconName, unicode = 'e002' } = options
|
||||
|
||||
// Extract viewBox first; fallback to width/height
|
||||
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
|
||||
let width = 512
|
||||
let height = 512
|
||||
if (viewBoxMatch) {
|
||||
const parts = viewBoxMatch[1].trim().split(/\s+/)
|
||||
if (parts.length === 4) {
|
||||
const w = Number(parts[2])
|
||||
const h = Number(parts[3])
|
||||
if (!Number.isNaN(w)) width = Math.round(w)
|
||||
if (!Number.isNaN(h)) height = Math.round(h)
|
||||
}
|
||||
} else {
|
||||
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
if (widthMatch) width = Math.round(Number(widthMatch[1]))
|
||||
if (heightMatch) height = Math.round(Number(heightMatch[1]))
|
||||
}
|
||||
|
||||
// Collect all path d attributes
|
||||
const pathDs: string[] = []
|
||||
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = pathRegex.exec(svg)) !== null) {
|
||||
pathDs.push(m[1])
|
||||
}
|
||||
|
||||
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
|
||||
|
||||
return {
|
||||
prefix,
|
||||
iconName,
|
||||
icon: [width, height, [], unicode, pathData]
|
||||
}
|
||||
}
|
||||
|
||||
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
|
||||
prefix: 'far',
|
||||
iconName: 'books'
|
||||
})
|
||||
|
||||
2773
src/index.css
22
src/main.tsx
@@ -1,23 +1,35 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
91
src/services/articleTitleResolver.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Fetch article title for a single naddr
|
||||
* Returns the title or null if not found/error
|
||||
*/
|
||||
export async function fetchArticleTitle(
|
||||
relayPool: RelayPool,
|
||||
naddr: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const decoded = nip19.decode(naddr)
|
||||
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
const 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 = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#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(
|
||||
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||
) as unknown as { created_at: number }[]
|
||||
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort by created_at and take the most recent
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
|
||||
|
||||
return getArticleTitle(article) || null
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch article title for', naddr, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch titles for multiple naddrs in parallel
|
||||
* Returns a map of naddr -> title
|
||||
*/
|
||||
export async function fetchArticleTitles(
|
||||
relayPool: RelayPool,
|
||||
naddrs: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const titleMap = new Map<string, string>()
|
||||
|
||||
// Fetch all titles in parallel
|
||||
const results = await Promise.allSettled(
|
||||
naddrs.map(async (naddr) => {
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
return { naddr, title }
|
||||
})
|
||||
)
|
||||
|
||||
// Process results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value.title) {
|
||||
titleMap.set(result.value.naddr, result.value.title)
|
||||
}
|
||||
})
|
||||
|
||||
return titleMap
|
||||
}
|
||||
|
||||
@@ -60,8 +60,27 @@ export const processApplesauceBookmarks = (
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
return allItems.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
@@ -70,23 +89,8 @@ export const processApplesauceBookmarks = (
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
48
src/services/deletionService.ts
Normal 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
|
||||
}
|
||||
|
||||
73
src/services/exploreCache.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
export interface CachedBlogPostPreview {
|
||||
event: NostrEvent
|
||||
title: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author: string
|
||||
}
|
||||
|
||||
type CacheValue = {
|
||||
posts: CachedBlogPostPreview[]
|
||||
highlights: Highlight[]
|
||||
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 getCachedHighlights(pubkey: string): Highlight[] | null {
|
||||
const entry = exploreCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry.highlights
|
||||
}
|
||||
|
||||
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts,
|
||||
highlights: current?.highlights || [],
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts: current?.posts || [],
|
||||
highlights,
|
||||
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
|
||||
}
|
||||
|
||||
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
|
||||
const current = exploreCache.get(pubkey)?.highlights || []
|
||||
const byId = new Map(current.map(h => [h.id, h]))
|
||||
byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(pubkey, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* 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 []
|
||||
}
|
||||
}
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
export * from './highlights/fetchFromAuthors'
|
||||
|
||||
|
||||
63
src/services/highlights/fetchByAuthor.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
src/services/highlights/fetchForArticle.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
src/services/highlights/fetchForUrl.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
src/services/highlights/fetchFromAuthors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchHighlightsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.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: pubkeys, limit: 200 })
|
||||
.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()))
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights from authors:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
222
src/services/libraryService.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface ReadArticle {
|
||||
id: string
|
||||
url?: string
|
||||
eventId?: string
|
||||
eventAuthor?: string
|
||||
eventKind?: number
|
||||
markedAt: number
|
||||
reactionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all articles that the user has marked as read
|
||||
* Returns both nostr-native articles (kind:7) and external URLs (kind:17)
|
||||
*/
|
||||
export async function fetchReadArticles(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<ReadArticle[]> {
|
||||
try {
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch kind:7 reactions (nostr-native articles)
|
||||
const kind7Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind7Local$, kind7Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Fetch kind:17 reactions (external URLs)
|
||||
const kind17Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind17Local$, kind17Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
|
||||
// Process kind:7 reactions (nostr-native articles)
|
||||
for (const event of kind7Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||
|
||||
if (eTag && eTag[1]) {
|
||||
readArticles.push({
|
||||
id: eTag[1],
|
||||
eventId: eTag[1],
|
||||
eventAuthor: pTag?.[1],
|
||||
eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined,
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process kind:17 reactions (external URLs)
|
||||
for (const event of kind17Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||
|
||||
if (rTag && rTag[1]) {
|
||||
readArticles.push({
|
||||
id: rTag[1],
|
||||
url: rTag[1],
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by markedAt (most recent first) and dedupe
|
||||
const deduped = new Map<string, ReadArticle>()
|
||||
readArticles
|
||||
.sort((a, b) => b.markedAt - a.markedAt)
|
||||
.forEach((article) => {
|
||||
if (!deduped.has(article.id)) {
|
||||
deduped.set(article.id, article)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(deduped.values())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches full article data for read nostr-native articles
|
||||
* and converts them to BlogPostPreview format for rendering
|
||||
*/
|
||||
export async function fetchReadArticlesWithData(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<BlogPostPreview[]> {
|
||||
try {
|
||||
// First get all read articles
|
||||
const readArticles = await fetchReadArticles(relayPool, userPubkey)
|
||||
|
||||
// Filter to only nostr-native articles (kind 30023)
|
||||
const nostrArticles = readArticles.filter(
|
||||
article => article.eventKind === 30023 && article.eventId
|
||||
)
|
||||
|
||||
if (nostrArticles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the actual article events
|
||||
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const articleEvents: NostrEvent[] = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
const uniqueArticleEvents = new Map<string, NostrEvent>()
|
||||
articleEvents.forEach(event => {
|
||||
if (!uniqueArticleEvents.has(event.id)) {
|
||||
uniqueArticleEvents.set(event.id, event)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to BlogPostPreview format
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueArticleEvents.values()).map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
|
||||
// Sort by when they were marked as read (most recent first)
|
||||
const articlesMap = new Map(nostrArticles.map(a => [a.eventId, a]))
|
||||
blogPosts.sort((a, b) => {
|
||||
const markedAtA = articlesMap.get(a.event.id)?.markedAt || 0
|
||||
const markedAtB = articlesMap.get(b.event.id)?.markedAt || 0
|
||||
return markedAtB - markedAtA
|
||||
})
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles with data:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
54
src/services/meCache.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
readArticles: BlogPostPreview[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const meCache = new Map<string, MeCache>() // key: pubkey
|
||||
|
||||
export function getCachedMeData(pubkey: string): MeCache | null {
|
||||
const entry = meCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry
|
||||
}
|
||||
|
||||
export function setCachedMeData(
|
||||
pubkey: string,
|
||||
highlights: Highlight[],
|
||||
bookmarks: Bookmark[],
|
||||
readArticles: BlogPostPreview[]
|
||||
): void {
|
||||
meCache.set(pubkey, {
|
||||
highlights,
|
||||
bookmarks,
|
||||
readArticles,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, highlights, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, bookmarks, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
126
src/services/nostrverseService.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
/**
|
||||
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @param limit - Maximum number of posts to fetch (default: 50)
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchNostrverseBlogPosts = async (
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
limit = 50
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
|
||||
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], limit })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], limit })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
processEvents(events)
|
||||
|
||||
console.log('📊 Nostrverse 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
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param limit - Maximum number of highlights to fetch (default: 100)
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchNostrverseHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
limit = 100
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], limit })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], limit })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse highlights:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
81
src/services/profileService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { UserSettings } from './settingsService'
|
||||
|
||||
/**
|
||||
* Fetches profile metadata (kind:0) for a list of pubkeys
|
||||
* Stores profiles in the event store and optionally to local relays
|
||||
*/
|
||||
export const fetchProfiles = async (
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkeys: string[],
|
||||
settings?: UserSettings
|
||||
): Promise<NostrEvent[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys))
|
||||
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Keep only the most recent profile for each pubkey
|
||||
const profilesByPubkey = new Map<string, NostrEvent>()
|
||||
|
||||
const processEvent = (event: NostrEvent) => {
|
||||
const existing = profilesByPubkey.get(event.pubkey)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
profilesByPubkey.set(event.pubkey, event)
|
||||
// Store in event store immediately
|
||||
eventStore.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => processEvent(event)),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => processEvent(event)),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
console.log('✅ Fetched', profiles.length, 'unique profiles')
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
await rebroadcastEvents(profiles, relayPool, settings)
|
||||
}
|
||||
|
||||
return profiles
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
199
src/services/reactionService.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const MARK_AS_READ_EMOJI = '📚'
|
||||
|
||||
export { 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a nostr event as read
|
||||
* @param eventId The ID of the event to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedEventAsRead(
|
||||
eventId: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [7],
|
||||
authors: [userPubkey],
|
||||
'#e': [eventId]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a website as read
|
||||
* @param url The URL to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedWebsiteAsRead(
|
||||
url: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Normalize URL the same way as when creating reactions
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
if (normalizedUrl.endsWith('/')) {
|
||||
normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize URL:', error)
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [17],
|
||||
authors: [userPubkey],
|
||||
'#r': [normalizedUrl]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface UserSettings {
|
||||
// Image cache settings
|
||||
enableImageCache?: boolean // Enable caching images in localStorage
|
||||
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||
// Mobile settings
|
||||
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
|
||||
// Theme preference
|
||||
theme?: 'dark' | 'light' | 'system' // default: system
|
||||
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
77
src/services/youtubeMetaService.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type Caption = { start: number; dur: number; text: string }
|
||||
export type YouTubeMeta = {
|
||||
title: string
|
||||
description?: string
|
||||
captions: Caption[]
|
||||
transcript?: string
|
||||
lang: string
|
||||
isAuto?: boolean
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
type CachedMeta = {
|
||||
data: YouTubeMeta
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
function cacheKey(videoId: string, lang: string) {
|
||||
return `yt_meta_${videoId}_${lang}`
|
||||
}
|
||||
|
||||
function load(videoId: string, lang: string): YouTubeMeta | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(cacheKey(videoId, lang))
|
||||
if (!raw) return null
|
||||
const { data, timestamp } = JSON.parse(raw) as CachedMeta
|
||||
if (Date.now() - timestamp > TTL_MS) {
|
||||
localStorage.removeItem(cacheKey(videoId, lang))
|
||||
return null
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(videoId: string, lang: string, data: YouTubeMeta) {
|
||||
try {
|
||||
const value: CachedMeta = { data, timestamp: Date.now() }
|
||||
localStorage.setItem(cacheKey(videoId, lang), JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function extractYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1)
|
||||
}
|
||||
if (u.searchParams.get('v')) return u.searchParams.get('v')
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
// /shorts/:id or /embed/:id
|
||||
if ((parts[0] === 'shorts' || parts[0] === 'embed') && parts[1]) return parts[1]
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getYouTubeMeta(videoId: string, lang = 'en'): Promise<YouTubeMeta | null> {
|
||||
const cached = load(videoId, lang)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`/api/youtube-meta?videoId=${encodeURIComponent(videoId)}&lang=${encodeURIComponent(lang)}`, {
|
||||
headers: {
|
||||
'x-ui-locale': lang
|
||||
}
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as YouTubeMeta
|
||||
save(videoId, lang, data)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||