mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 21:15:02 +01:00
Compare commits
342 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ec89458c | ||
|
|
d8849b2d81 | ||
|
|
a431bbea6c | ||
|
|
3cbad434d6 | ||
|
|
4d3047476d | ||
|
|
bf81cd51b7 | ||
|
|
d50276adca | ||
|
|
785be6aa9e | ||
|
|
934bee2d62 | ||
|
|
00eb9ae55b | ||
|
|
61968c8892 | ||
|
|
bd0dcbb7f2 | ||
|
|
645e1f2b18 | ||
|
|
02de0e7011 | ||
|
|
e491f7e385 | ||
|
|
62e5b2b0af | ||
|
|
be03b9c9cc | ||
|
|
3da6a70f77 | ||
|
|
a2dc928681 | ||
|
|
1f88201c18 | ||
|
|
85e93b69aa | ||
|
|
5cede24650 | ||
|
|
2348361d1d | ||
|
|
c134c3db57 | ||
|
|
18dbc521ee | ||
|
|
8600c09344 | ||
|
|
efb6b56c3b | ||
|
|
cc22524466 | ||
|
|
bca1ee2b2e | ||
|
|
4d18c84243 | ||
|
|
c1b171d188 | ||
|
|
fdb22491a2 | ||
|
|
ff2cb41a3c | ||
|
|
5a5cfb7edd | ||
|
|
63a820faf8 | ||
|
|
0bfa0a2e7b | ||
|
|
6445445e5d | ||
|
|
85d256b47b | ||
|
|
55d14d9e77 | ||
|
|
f41cb4b17e | ||
|
|
286d5df5b8 | ||
|
|
36659ad2cc | ||
|
|
ee7e88bc62 | ||
|
|
120409dc7b | ||
|
|
b2aa9c4179 | ||
|
|
0dc9e37ff4 | ||
|
|
5181176260 | ||
|
|
3b4f3e8161 | ||
|
|
2323427dbd | ||
|
|
43e6455668 | ||
|
|
7b3f36b0bb | ||
|
|
feafe4a07b | ||
|
|
ed1a4e489e | ||
|
|
4ab34456d1 | ||
|
|
54ed0c547f | ||
|
|
98291f0904 | ||
|
|
f0b3ad239c | ||
|
|
7d7e60c226 | ||
|
|
55ea43e103 | ||
|
|
631d65be21 | ||
|
|
76b9797c41 | ||
|
|
4fc4971345 | ||
|
|
f2bc0c1da1 | ||
|
|
f486de1597 | ||
|
|
b0e43ccee7 | ||
|
|
66db9cd23f | ||
|
|
c2552d2e34 | ||
|
|
56547b3526 | ||
|
|
70ac7dce95 | ||
|
|
f982781dd8 | ||
|
|
a73c7db9d3 | ||
|
|
c81b7b89d1 | ||
|
|
971b672591 | ||
|
|
8b30ffd5e7 | ||
|
|
3975ef15dd | ||
|
|
61e8517137 | ||
|
|
b0d30946eb | ||
|
|
c0cfd41e76 | ||
|
|
be7b6c2cfb | ||
|
|
afd27032e0 | ||
|
|
696fe42bee | ||
|
|
1a0370aef9 | ||
|
|
ed3e8e9799 | ||
|
|
f590ff56ec | ||
|
|
cc68980cdb | ||
|
|
d83708ceb3 | ||
|
|
507aa27d29 | ||
|
|
1d4c5a7393 | ||
|
|
64fd2cc0d3 | ||
|
|
b6182b3c11 | ||
|
|
e7e02dd129 | ||
|
|
d76bfb66bb | ||
|
|
024e62118b | ||
|
|
ed93675d8d | ||
|
|
2089208448 | ||
|
|
4fd8a0b18f | ||
|
|
48213fa584 | ||
|
|
eaabad98c2 | ||
|
|
31bcd61aae | ||
|
|
f6c00f4c20 | ||
|
|
0ce9f76f3b | ||
|
|
781cade78b | ||
|
|
15e91414da | ||
|
|
453a4f48ca | ||
|
|
a91aa87ef9 | ||
|
|
52be65e382 | ||
|
|
142995e83c | ||
|
|
03a7f91961 | ||
|
|
496b329e82 | ||
|
|
a4c8a7d68b | ||
|
|
8f90de01fd | ||
|
|
341fbd8c2a | ||
|
|
01722cff38 | ||
|
|
a7a7857219 | ||
|
|
104332fd94 | ||
|
|
e736c9f5b9 | ||
|
|
103e104cb2 | ||
|
|
5389397e9b | ||
|
|
54cba2beed | ||
|
|
da76cb247c | ||
|
|
9b4a7b6263 | ||
|
|
e6f98d69e7 | ||
|
|
3785d34e8f | ||
|
|
a30943686e | ||
|
|
d4b78d9484 | ||
|
|
66de230f66 | ||
|
|
8cb77864bc | ||
|
|
ea3c130cc3 | ||
|
|
f417ed8210 | ||
|
|
945b9502bc | ||
|
|
4a432bac8d | ||
|
|
541d30764e | ||
|
|
7c2b373254 | ||
|
|
0bf33f1a7d | ||
|
|
1eca19154d | ||
|
|
fd2d4d106f | ||
|
|
d41cbb5305 | ||
|
|
f57a4d4f1b | ||
|
|
4b03f32d21 | ||
|
|
8f1288b1a2 | ||
|
|
7ec87b66d8 | ||
|
|
27dde5afa2 | ||
|
|
3b2732681d | ||
|
|
51a4b545e9 | ||
|
|
7e5972a6e2 | ||
|
|
156cf31625 | ||
|
|
ee7df54d87 | ||
|
|
15c016ad5e | ||
|
|
b0574d3f8e | ||
|
|
4fd6605666 | ||
|
|
76a117cdda | ||
|
|
d4c6747d98 | ||
|
|
6b221e4d13 | ||
|
|
7ec2ddcceb | ||
|
|
5ce13c667d | ||
|
|
c1877a40e9 | ||
|
|
18a38d054f | ||
|
|
500cec88d0 | ||
|
|
affd80ca2e | ||
|
|
5e1ed6b8de | ||
|
|
5d36d6de4f | ||
|
|
93eb8a63de | ||
|
|
6074caaae3 | ||
|
|
d206ff228e | ||
|
|
074af764ed | ||
|
|
e814aadb5b | ||
|
|
aaddd0ef6b | ||
|
|
8a39258d8e | ||
|
|
3136b198d5 | ||
|
|
8a431d962e | ||
|
|
50ab59ebcd | ||
|
|
3ba5bce437 | ||
|
|
9ed56b213e | ||
|
|
34804540c5 | ||
|
|
30c2ca5b85 | ||
|
|
68e6fcd3ac | ||
|
|
da385cd037 | ||
|
|
3b30bc98c7 | ||
|
|
056da1ad23 | ||
|
|
b7cda7a351 | ||
|
|
5896a5d6db | ||
|
|
af91e52555 | ||
|
|
b4ebb6334f | ||
|
|
27178bc3d1 | ||
|
|
76fefc88ca | ||
|
|
98c006939b | ||
|
|
80ed646dd4 | ||
|
|
7ea868d0b2 | ||
|
|
88e1bc3419 | ||
|
|
4ec34a0379 | ||
|
|
aec2dcb75c | ||
|
|
5bdc435f5d | ||
|
|
db46edd39e | ||
|
|
c9739f804d | ||
|
|
eeb44e344f | ||
|
|
a6674610b8 | ||
|
|
6ae3decafb | ||
|
|
00da638e81 | ||
|
|
f04c0a401e | ||
|
|
f5e9f164f5 | ||
|
|
589ac17114 | ||
|
|
8d3510947c | ||
|
|
08a8f5623a | ||
|
|
e85ccdc7da | ||
|
|
d0e7f146fb | ||
|
|
efdb33eb31 | ||
|
|
0abbe62515 | ||
|
|
ab0972dd29 | ||
|
|
83fbb26e03 | ||
|
|
e8ce928ec6 | ||
|
|
1a01e14702 | ||
|
|
aab8176987 | ||
|
|
5a8b885d25 | ||
|
|
c129b24352 | ||
|
|
d98d750268 | ||
|
|
8262b2bf24 | ||
|
|
b99f36c0c5 | ||
|
|
dfe37a260e | ||
|
|
2a42f1de53 | ||
|
|
cea2d0eda2 | ||
|
|
ef05974a72 | ||
|
|
5a6ac628d2 | ||
|
|
826f07544e | ||
|
|
911215c0fb | ||
|
|
43ed41bfae | ||
|
|
81597fbb6d | ||
|
|
cc722c2599 | ||
|
|
c20682fbe8 | ||
|
|
cfa6dc4400 | ||
|
|
851cecf18c | ||
|
|
d4c67485a2 | ||
|
|
381fd05c90 | ||
|
|
60c4ef55c0 | ||
|
|
0b7891419b | ||
|
|
aeedc622b1 | ||
|
|
6f5b87136b | ||
|
|
1ac0c719a2 | ||
|
|
c9ce5442e0 | ||
|
|
c28052720e | ||
|
|
d0f942c495 | ||
|
|
907ef82efb | ||
|
|
415ff04345 | ||
|
|
683ea27526 | ||
|
|
fa052483b2 | ||
|
|
0ae9e6321e | ||
|
|
5623f2e595 | ||
|
|
2c94c1e3f0 | ||
|
|
19dc2f70f2 | ||
|
|
5013ccc552 | ||
|
|
29eed3395f | ||
|
|
d6da27c634 | ||
|
|
5551b52bce | ||
|
|
af7eb48893 | ||
|
|
51ce79f13a | ||
|
|
bcfc04c35c | ||
|
|
d6911b2acb | ||
|
|
2a869f11e0 | ||
|
|
deab9974fa | ||
|
|
49872337f3 | ||
|
|
389b4de0eb | ||
|
|
959ccac857 | ||
|
|
78c58693a5 | ||
|
|
ab81fe5030 | ||
|
|
6bae30070e | ||
|
|
1f6a904717 | ||
|
|
9379475d1c | ||
|
|
77a5f4bd2a | ||
|
|
4fa01231cd | ||
|
|
1cd85507a7 | ||
|
|
b6f151c711 | ||
|
|
e3d924f3fc | ||
|
|
5914df23d3 | ||
|
|
2083c2b8c6 | ||
|
|
35a8411d9b | ||
|
|
15b3b5b990 | ||
|
|
ad56acb712 | ||
|
|
2f5fe87fc8 | ||
|
|
d313c71e24 | ||
|
|
903b4a4ec1 | ||
|
|
a511b25b87 | ||
|
|
e920cf9477 | ||
|
|
708a1bfd54 | ||
|
|
51842f55bf | ||
|
|
52991f8e20 | ||
|
|
e3cd4454b4 | ||
|
|
78bc1f46dd | ||
|
|
c8cd1e6e66 | ||
|
|
5254697fe2 | ||
|
|
13462efaed | ||
|
|
1df00fbfda | ||
|
|
c2e220a1f2 | ||
|
|
00740aab6d | ||
|
|
e12d67cc5f | ||
|
|
e12aaa2b6c | ||
|
|
9880a9ae34 | ||
|
|
603db680f2 | ||
|
|
ae0471946e | ||
|
|
a48308d57d | ||
|
|
f67b358148 | ||
|
|
46a0a3da1f | ||
|
|
c92a620ea8 | ||
|
|
34de372509 | ||
|
|
a422084949 | ||
|
|
bd0e075984 | ||
|
|
38f4b69d48 | ||
|
|
9d1d944daf | ||
|
|
e56461cb12 | ||
|
|
f6b6747f09 | ||
|
|
180c26c47a | ||
|
|
78da0cb3e4 | ||
|
|
3d74c25c7d | ||
|
|
f46f55705b | ||
|
|
205591602d | ||
|
|
c6a42e0304 | ||
|
|
688d4285e3 | ||
|
|
9f806afc45 | ||
|
|
1282e778c6 | ||
|
|
6fd40f2ff6 | ||
|
|
6ac40c8a17 | ||
|
|
92145af2bb | ||
|
|
1ebaf7ccd2 | ||
|
|
5d22819ae3 | ||
|
|
6761b1861e | ||
|
|
1d989eae76 | ||
|
|
33d6e5882d | ||
|
|
0a62924b78 | ||
|
|
e2472606dd | ||
|
|
6f04b8f513 | ||
|
|
a8ad346c5d | ||
|
|
465c24ed3a | ||
|
|
04dea350a4 | ||
|
|
29c4bcb69b | ||
|
|
23ea7f352b | ||
|
|
36b35367f1 | ||
|
|
183463c817 | ||
|
|
7fb91e71f1 | ||
|
|
717f094984 | ||
|
|
c69e50d3bb | ||
|
|
4e4d719d94 | ||
|
|
d453a6439c | ||
|
|
5dfa6ba3ae | ||
|
|
f2d2883eee |
2
.env
2
.env
@@ -1,2 +0,0 @@
|
|||||||
# Default article to display on app load
|
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
17
.env.example
17
.env.example
@@ -1,3 +1,14 @@
|
|||||||
# Default article to display on app load
|
# Nostr configuration for publish-markdown.sh script
|
||||||
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
|
# Copy this file to .env and fill in your values
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
|
# Your Nostr secret key (nsec, ncryptsec, or hex format)
|
||||||
|
# You can also set this via environment variable: export NOSTR_SECRET_KEY=your_key
|
||||||
|
NOSTR_SECRET_KEY=
|
||||||
|
|
||||||
|
# Space-separated list of relay URLs to publish to
|
||||||
|
# If not provided, events will be created but not published
|
||||||
|
RELAYS="ws://localhost:10547 ws://localhost:4869 wss://relay.primal.net wss://wot.dergigi.com wss://relay.dergigi.com wss://nostr.einundzwanzig.space wss://relay.damus.io wss://relay.nostr.bg wss://nos.lol wss://eden.nostr.land"
|
||||||
|
|
||||||
|
# Test account used for publishing markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ applesauce
|
|||||||
primal-web-app
|
primal-web-app
|
||||||
Amber
|
Amber
|
||||||
|
|
||||||
|
.env
|
||||||
|
scripts/.env
|
||||||
|
.vercel
|
||||||
|
|||||||
1461
CHANGELOG.md
1461
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
41
api/article-og-refresh.ts
Normal file
41
api/article-og-refresh.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { setArticleMeta } from './services/ogStore.js'
|
||||||
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// Validate refresh secret
|
||||||
|
const providedSecret = req.headers['x-refresh-key']
|
||||||
|
const expectedSecret = process.env.OG_REFRESH_SECRET || ''
|
||||||
|
|
||||||
|
if (providedSecret !== expectedSecret) {
|
||||||
|
console.error('Background refresh unauthorized: secret mismatch')
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Background refresh started for ${naddr}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata via relays (WebSockets) - no timeout, let it take as long as needed
|
||||||
|
const meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
console.log(`Background refresh found metadata for ${naddr}:`, { title: meta.title, summary: meta.summary?.substring(0, 50) })
|
||||||
|
// Store in Redis
|
||||||
|
await setArticleMeta(naddr, meta)
|
||||||
|
console.log(`Background refresh cached metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: true })
|
||||||
|
} else {
|
||||||
|
console.log(`Background refresh found no metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: false })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error refreshing article metadata for ${naddr}:`, err)
|
||||||
|
return res.status(500).json({ error: 'Internal server error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,207 +1,13 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { generateHtml } from './services/ogHtml.js'
|
||||||
import { NostrEvent, Filter } from 'nostr-tools'
|
|
||||||
import { Helpers } from 'applesauce-core'
|
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
|
||||||
|
|
||||||
// Relay configuration (from src/config/relays.ts)
|
|
||||||
const RELAYS = [
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.dergigi.com',
|
|
||||||
'wss://wot.dergigi.com',
|
|
||||||
'wss://relay.snort.social',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
|
||||||
'wss://purplepag.es',
|
|
||||||
'wss://relay.primal.net'
|
|
||||||
]
|
|
||||||
|
|
||||||
type CacheEntry = {
|
|
||||||
html: string
|
|
||||||
expires: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
||||||
const memoryCache = new Map<string, CacheEntry>()
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArticleMetadata {
|
|
||||||
title: string
|
|
||||||
summary: string
|
|
||||||
image: string
|
|
||||||
author: string
|
|
||||||
published?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEventsFromRelays(
|
|
||||||
relayPool: RelayPool,
|
|
||||||
relayUrls: string[],
|
|
||||||
filter: Filter,
|
|
||||||
timeoutMs: number
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const events: NostrEvent[] = []
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => resolve(), timeoutMs)
|
|
||||||
|
|
||||||
// `request` emits NostrEvent objects directly
|
|
||||||
relayPool.request(relayUrls, filter).subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
events.push(event)
|
|
||||||
},
|
|
||||||
error: () => resolve(),
|
|
||||||
complete: () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by created_at and return most recent first
|
|
||||||
return events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
|
||||||
const relayPool = new RelayPool()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode naddr
|
|
||||||
const decoded = nip19.decode(naddr)
|
|
||||||
if (decoded.type !== 'naddr') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
|
||||||
|
|
||||||
// Determine relay URLs
|
|
||||||
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
|
||||||
|
|
||||||
// Fetch article and profile in parallel
|
|
||||||
const [articleEvents, profileEvents] = await Promise.all([
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [pointer.kind],
|
|
||||||
authors: [pointer.pubkey],
|
|
||||||
'#d': [pointer.identifier || '']
|
|
||||||
}, 5000),
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [pointer.pubkey]
|
|
||||||
}, 3000)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (articleEvents.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const article = articleEvents[0]
|
|
||||||
|
|
||||||
// Extract article metadata
|
|
||||||
const title = getArticleTitle(article) || 'Untitled Article'
|
|
||||||
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
|
||||||
const image = getArticleImage(article) || '/boris-social-1200.png'
|
|
||||||
|
|
||||||
// Extract author name from profile
|
|
||||||
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
|
||||||
if (profileEvents.length > 0) {
|
|
||||||
try {
|
|
||||||
const profileData = JSON.parse(profileEvents[0].content)
|
|
||||||
authorName = profileData.display_name || profileData.name || authorName
|
|
||||||
} catch {
|
|
||||||
// Use fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
image,
|
|
||||||
author: authorName,
|
|
||||||
published: article.created_at
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch article metadata:', err)
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
// No explicit close needed; pool manages connections internally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
|
||||||
const baseUrl = 'https://read.withboris.com'
|
|
||||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
|
||||||
|
|
||||||
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
|
||||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
|
||||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
|
||||||
const author = meta?.author || 'Boris'
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<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>${escapeHtml(title)}</title>
|
|
||||||
<meta name="description" content="${escapeHtml(description)}" />
|
|
||||||
<link rel="canonical" href="${articleUrl}" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:url" content="${articleUrl}" />
|
|
||||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
|
||||||
<meta property="og:site_name" content="Boris" />
|
|
||||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
|
||||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:url" content="${articleUrl}" />
|
|
||||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCrawler(userAgent: string | undefined): boolean {
|
|
||||||
if (!userAgent) return false
|
|
||||||
const crawlers = [
|
|
||||||
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
|
||||||
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
|
||||||
]
|
|
||||||
const ua = userAgent.toLowerCase()
|
|
||||||
return crawlers.some(crawler => ua.includes(crawler))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
@@ -209,89 +15,46 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
return res.status(400).json({ error: 'Missing naddr parameter' })
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const userAgent = req.headers['user-agent'] as string | undefined
|
|
||||||
const isCrawlerRequest = isCrawler(userAgent)
|
|
||||||
|
|
||||||
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
res.setHeader('X-Boris-Debug', '1')
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
// Try Redis cache first
|
||||||
// Use history.replaceState to set the URL before the SPA boots
|
let meta = await getArticleMeta(naddr).catch((err) => {
|
||||||
if (!isCrawlerRequest) {
|
console.error('Failed to get article meta from Redis:', err)
|
||||||
const articlePath = `/a/${naddr}`
|
return null
|
||||||
// Serve a minimal HTML that sets up the URL and loads the SPA
|
})
|
||||||
const html = `<!DOCTYPE html>
|
let cacheMaxAge = 86400
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Boris - Loading Article...</title>
|
|
||||||
<script>
|
|
||||||
// Set the URL to the article path before SPA loads
|
|
||||||
if (window.location.pathname !== '${articlePath}') {
|
|
||||||
history.replaceState(null, '', '${articlePath}');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
|
|
||||||
<script>
|
|
||||||
// Redirect to index.html which will load the SPA
|
|
||||||
// The history state is already set, so SPA will see the correct URL
|
|
||||||
window.location.replace('/');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
if (!meta) {
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
// Cache miss: fetch from relays (let it use its natural timeouts)
|
||||||
if (debugEnabled) {
|
try {
|
||||||
// Debug mode enabled
|
meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
// Store in Redis and use it
|
||||||
|
await setArticleMeta(naddr, meta).catch((err) => {
|
||||||
|
console.error('Failed to cache relay metadata:', err)
|
||||||
|
})
|
||||||
|
cacheMaxAge = 86400
|
||||||
|
} else {
|
||||||
|
// Relay fetch failed: use default fallback
|
||||||
|
cacheMaxAge = 300
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching from relays for ${naddr}:`, err)
|
||||||
|
cacheMaxAge = 300
|
||||||
}
|
}
|
||||||
return res.status(200).send(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache for bots/crawlers
|
// Generate and send HTML
|
||||||
const now = Date.now()
|
const html = generateHtml(naddr, meta)
|
||||||
const cached = memoryCache.get(naddr)
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
if (cached && cached.expires > now) {
|
|
||||||
setCacheHeaders(res)
|
if (debugEnabled) {
|
||||||
if (debugEnabled) {
|
// Debug mode enabled
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(cached.html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return res.status(200).send(html)
|
||||||
// Fetch metadata
|
|
||||||
const meta = await fetchArticleMetadata(naddr)
|
|
||||||
|
|
||||||
// Generate HTML
|
|
||||||
const html = generateHtml(naddr, meta)
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
|
||||||
|
|
||||||
// Send response
|
|
||||||
setCacheHeaders(res)
|
|
||||||
if (debugEnabled) {
|
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(html)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error generating article OG HTML:', err)
|
|
||||||
|
|
||||||
// Fallback to basic HTML with SPA boot
|
|
||||||
const html = generateHtml(naddr, null)
|
|
||||||
setCacheHeaders(res, 3600)
|
|
||||||
if (debugEnabled) {
|
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(html)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
api/services/articleMeta.ts
Normal file
224
api/services/articleMeta.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import WebSocket from 'ws'
|
||||||
|
;(globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ??= WebSocket
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { extractProfileDisplayName } from '../../lib/profile.js'
|
||||||
|
import { RELAYS } from '../../src/config/relays.js'
|
||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
async function fetchEventsFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||||
|
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFirstEvent(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent | null> {
|
||||||
|
return new Promise<NostrEvent | null>((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const subscription = relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorProfile(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
pubkey: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
const profileEvents = await fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey]
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
if (profileEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = extractProfileDisplayName(profileEvents[0])
|
||||||
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
|
return displayName
|
||||||
|
} else if (displayName) {
|
||||||
|
return displayName.substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaRelays(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Step A: Fetch article - return as soon as first event arrives
|
||||||
|
const article = await fetchFirstEvent(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 7000)
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step B: Extract article metadata immediately
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract 't' tags (topic tags) from article event
|
||||||
|
const tags = article.tags
|
||||||
|
?.filter((tag) => tag[0] === 't' && tag[1])
|
||||||
|
.map((tag) => tag[1])
|
||||||
|
.filter((tag) => tag.length > 0) || []
|
||||||
|
|
||||||
|
// Generate image alt text (use title as fallback)
|
||||||
|
const imageAlt = title || 'Article cover image'
|
||||||
|
|
||||||
|
// Step C: Fetch author profile with micro-wait (connections already warm)
|
||||||
|
let authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 400)
|
||||||
|
|
||||||
|
// Step D: Optional hedge - try again with slightly longer timeout if first attempt failed
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
imageAlt
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via relays:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaGateway(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||||
|
|
||||||
|
const url = `https://njump.to/${naddr}`
|
||||||
|
console.log(`Fetching from gateway: ${url}`)
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`Gateway fetch failed: ${resp.status} ${resp.statusText} for ${url}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await resp.text()
|
||||||
|
console.log(`Gateway response length: ${html.length} chars`)
|
||||||
|
|
||||||
|
const pick = (re: RegExp) => {
|
||||||
|
const match = html.match(re)
|
||||||
|
return match?.[1] ? match[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
|
||||||
|
pick(/<title[^>]*>([^<]+)<\/title>/i)
|
||||||
|
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
const image = pick(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
|
||||||
|
console.log(`Parsed from gateway - title: ${title ? 'found' : 'missing'}, summary: ${summary ? 'found' : 'missing'}, image: ${image ? 'found' : 'missing'}`)
|
||||||
|
|
||||||
|
if (!title && !summary && !image) {
|
||||||
|
console.log('No OG metadata found in gateway response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || 'Read on Boris',
|
||||||
|
summary: summary || 'Read this article on Boris',
|
||||||
|
image: image || '/boris-social-1200.png',
|
||||||
|
author: 'Boris'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via gateway:', err)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('Error details:', err.message, err.stack)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
80
api/services/ogHtml.ts
Normal file
80
api/services/ogHtml.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||||
|
const baseUrl = 'https://read.withboris.com'
|
||||||
|
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||||
|
|
||||||
|
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
||||||
|
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||||
|
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||||
|
const author = meta?.author || 'Boris'
|
||||||
|
const imageAlt = meta?.imageAlt || title
|
||||||
|
|
||||||
|
// Generate article:tag meta tags
|
||||||
|
const articleTags = meta?.tags && meta.tags.length > 0
|
||||||
|
? meta.tags.map((tag) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:image:alt" content="${escapeHtml(imageAlt)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? ` <meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
${articleTags}
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/a/${naddr}">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var p = '/a/${naddr}';
|
||||||
|
if (window.location.pathname !== p) {
|
||||||
|
history.replaceState(null, '', p);
|
||||||
|
}
|
||||||
|
var sep = window.location.search ? '&' : '?';
|
||||||
|
window.location.replace(p + sep + '_spa=1');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
39
api/services/ogStore.ts
Normal file
39
api/services/ogStore.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Redis } from '@upstash/redis'
|
||||||
|
|
||||||
|
// Support both KV_* and UPSTASH_* env var names
|
||||||
|
const redisUrl = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL
|
||||||
|
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN
|
||||||
|
const readOnlyToken = process.env.KV_REST_API_READ_ONLY_TOKEN
|
||||||
|
|
||||||
|
if (!redisUrl || !redisToken) {
|
||||||
|
console.error('Missing Redis credentials: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN or KV_REST_API_URL/KV_REST_API_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisWrite = redisUrl && redisToken
|
||||||
|
? new Redis({ url: redisUrl, token: redisToken })
|
||||||
|
: Redis.fromEnv() // Fallback to fromEnv() if explicit vars not set
|
||||||
|
|
||||||
|
const redisRead = readOnlyToken && redisUrl
|
||||||
|
? new Redis({ url: redisUrl, token: readOnlyToken })
|
||||||
|
: redisWrite
|
||||||
|
|
||||||
|
const keyOf = (naddr: string) => `og:${naddr}`
|
||||||
|
|
||||||
|
export type ArticleMetadata = {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
tags?: string[]
|
||||||
|
imageAlt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticleMeta(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
|
||||||
|
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
|
||||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||||
|
|
||||||
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || ''
|
description: data.description || '',
|
||||||
|
thumbnail_url: data.thumbnail_url || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
try {
|
try {
|
||||||
if (videoInfo.source === 'youtube') {
|
if (videoInfo.source === 'youtube') {
|
||||||
// YouTube handling
|
// YouTube handling
|
||||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
// Fetch basic metadata from YouTube page
|
||||||
const title = ''
|
let title = ''
|
||||||
const description = ''
|
let description = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const html = await response.text()
|
||||||
|
// Extract title from HTML
|
||||||
|
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||||
|
if (titleMatch) {
|
||||||
|
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||||
|
}
|
||||||
|
// Extract description from meta tag
|
||||||
|
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||||
|
if (descMatch) {
|
||||||
|
description = descMatch[1].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch YouTube metadata:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
// 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[]))
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
return ok(res, response)
|
return ok(res, response)
|
||||||
} else if (videoInfo.source === 'vimeo') {
|
} else if (videoInfo.source === 'vimeo') {
|
||||||
// Vimeo handling
|
// Vimeo handling
|
||||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
thumbnail_url,
|
||||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
transcript: '', // No transcript available
|
transcript: '', // No transcript available
|
||||||
lang: 'en', // Default language
|
lang: 'en', // Default language
|
||||||
|
|||||||
@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
// Fetch basic metadata from YouTube page
|
||||||
// In a real implementation, you might want to use YouTube's API or other methods
|
let title = ''
|
||||||
const title = '' // Will be populated from captions or other sources
|
let description = ''
|
||||||
const description = ''
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const html = await response.text()
|
||||||
|
// Extract title from HTML
|
||||||
|
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||||
|
if (titleMatch) {
|
||||||
|
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||||
|
}
|
||||||
|
// Extract description from meta tag
|
||||||
|
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||||
|
if (descMatch) {
|
||||||
|
description = descMatch[1].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch YouTube metadata:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
// 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[]))
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
|
<!-- Fathom -->
|
||||||
|
<script src="https://cdn.usefathom.com/script.js" data-site="LLSGRVAP" defer></script>
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
document.documentElement.className = 'theme-system';
|
document.documentElement.className = 'theme-system';
|
||||||
|
|||||||
39
lib/profile.ts
Normal file
39
lib/profile.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
export function getNpubFallbackDisplay(pubkey: string): string {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
return `${npub.slice(5, 12)}...`
|
||||||
|
} catch {
|
||||||
|
return `${pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
|
||||||
|
if (!profileEvent || profileEvent.kind !== 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvent.content || '{}') as {
|
||||||
|
name?: string
|
||||||
|
display_name?: string
|
||||||
|
nip05?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileData.name) return profileData.name
|
||||||
|
if (profileData.display_name) return profileData.display_name
|
||||||
|
if (profileData.nip05) return profileData.nip05
|
||||||
|
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
138
package-lock.json
generated
138
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.19",
|
"version": "0.10.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.19",
|
"version": "0.10.33",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -36,12 +38,14 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -55,6 +59,9 @@
|
|||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
"vite-plugin-pwa": "^1.0.3",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -101,6 +108,7 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2261,6 +2269,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||||
},
|
},
|
||||||
@@ -3552,6 +3561,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -3594,6 +3604,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -3636,6 +3656,7 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -3798,6 +3819,15 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@upstash/redis": {
|
||||||
|
"version": "1.35.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz",
|
||||||
|
"integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uncrypto": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vercel/build-utils": {
|
"node_modules/@vercel/build-utils": {
|
||||||
"version": "12.1.2",
|
"version": "12.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
||||||
@@ -3926,7 +3956,8 @@
|
|||||||
"version": "16.18.11",
|
"version": "16.18.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
||||||
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@vercel/node/node_modules/esbuild": {
|
"node_modules/@vercel/node/node_modules/esbuild": {
|
||||||
"version": "0.14.47",
|
"version": "0.14.47",
|
||||||
@@ -4011,6 +4042,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4087,6 +4119,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4502,6 +4535,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "0.21.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||||
|
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||||
@@ -4630,6 +4672,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -5866,6 +5909,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -6171,6 +6215,16 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-opengraph": {
|
||||||
|
"version": "1.0.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
|
||||||
|
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"html-entities": "^2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -6264,6 +6318,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -6896,6 +6970,22 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-entities": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/mdevils"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://patreon.com/mdevils"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -9655,6 +9745,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9801,6 +9892,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -9813,6 +9905,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -10378,6 +10471,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -11133,6 +11227,7 @@
|
|||||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -11209,6 +11304,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11461,6 +11557,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11488,6 +11585,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uncrypto": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "5.28.4",
|
"version": "5.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||||
@@ -11504,8 +11607,7 @@
|
|||||||
"version": "7.14.0",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -11786,6 +11888,7 @@
|
|||||||
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -12171,6 +12274,7 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -12215,6 +12319,7 @@
|
|||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@@ -12463,6 +12568,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.22",
|
"version": "0.11.1",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"publish:test:markdown": "./scripts/publish-markdown.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -39,12 +45,14 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -96,6 +104,15 @@
|
|||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-var": "error"
|
"no-var": "error"
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["api/**/*.ts"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"browser": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
public/sw-dev.js
Normal file
47
public/sw-dev.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Development Service Worker - simplified version for testing image caching
|
||||||
|
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image caching - simple version for dev testing
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
const isImage = event.request.destination === 'image' ||
|
||||||
|
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open('boris-images-dev').then((cache) => {
|
||||||
|
return cache.match(event.request).then((cachedResponse) => {
|
||||||
|
// Try to fetch from network
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
// If fetch succeeds, cache it and return
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(event.request, response.clone()).catch(() => {
|
||||||
|
// Ignore cache put errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}).catch((error) => {
|
||||||
|
// If fetch fails (network error, CORS, etc.), return cached response if available
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
// No cache available, reject the promise so browser handles it
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
// If cache operations fail, try to fetch directly without caching
|
||||||
|
return fetch(event.request)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
202
scripts/publish-markdown.sh
Executable file
202
scripts/publish-markdown.sh
Executable file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to publish markdown files from test/markdown/ to Nostr using nak
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/publish-markdown.sh [filename] [relay1] [relay2] ...
|
||||||
|
# ./scripts/publish-markdown.sh # Interactive mode
|
||||||
|
# ./scripts/publish-markdown.sh tables.md # Publish specific file
|
||||||
|
# ./scripts/publish-markdown.sh tables.md wss://relay.example.com # With relay
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# The script reads .env from the project root directory ($PROJECT_ROOT/.env)
|
||||||
|
# Required: NOSTR_SECRET_KEY (your nsec, ncryptsec, or hex format key)
|
||||||
|
# Optional: RELAYS (space-separated list of relay URLs)
|
||||||
|
#
|
||||||
|
# Test account for markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
MARKDOWN_DIR="$PROJECT_ROOT/test/markdown"
|
||||||
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
|
||||||
|
# Load .env file if it exists
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# Source the .env file, handling quoted values properly
|
||||||
|
set -a # Automatically export all variables
|
||||||
|
# Use eval to properly handle quoted values (safe since we control the file)
|
||||||
|
# This handles both unquoted and quoted values correctly
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
# Skip comments and empty lines
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
# Remove leading/trailing whitespace
|
||||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
# Export the variable (handles quoted values)
|
||||||
|
eval "export $line"
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
set +a # Stop automatically exporting
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if nak is installed
|
||||||
|
if ! command -v nak &> /dev/null; then
|
||||||
|
echo "Error: nak is not installed or not in PATH"
|
||||||
|
echo "Install from: https://github.com/fiatjaf/nak"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to publish a markdown file
|
||||||
|
publish_file() {
|
||||||
|
local file_path="$1"
|
||||||
|
shift # Remove first argument, rest are relay URLs
|
||||||
|
local relays=("$@")
|
||||||
|
local filename=$(basename "$file_path")
|
||||||
|
local identifier="${filename%.md}" # Remove .md extension
|
||||||
|
|
||||||
|
echo "📝 Publishing: $filename"
|
||||||
|
echo " Identifier: $identifier"
|
||||||
|
|
||||||
|
# Extract title from first H1 if available, otherwise use filename
|
||||||
|
local title=$(grep -m 1 "^# " "$file_path" | sed 's/^# //' || echo "$identifier")
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
echo " Relays: ${relays[*]}"
|
||||||
|
else
|
||||||
|
echo " Note: No relays specified. Event will be created but not published."
|
||||||
|
echo " Add relay URLs as arguments to publish, e.g.: wss://relay.example.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Publish as kind 30023 (NIP-23 blog post)
|
||||||
|
# The "d" tag is required for replaceable events (kind 30023)
|
||||||
|
# Using the filename (without extension) as the identifier
|
||||||
|
# Build command array to avoid eval issues
|
||||||
|
# Use @filename syntax to read content from file (nak supports this)
|
||||||
|
local cmd_args=(
|
||||||
|
"event"
|
||||||
|
"-k" "30023"
|
||||||
|
"-d" "$identifier"
|
||||||
|
"-t" "title=$title"
|
||||||
|
"--content" "@$file_path"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
cmd_args+=("${relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
nak "${cmd_args[@]}"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Successfully published: $filename"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to publish: $filename"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for NOSTR_SECRET_KEY
|
||||||
|
if [ -z "$NOSTR_SECRET_KEY" ]; then
|
||||||
|
echo "⚠️ Warning: NOSTR_SECRET_KEY environment variable not set"
|
||||||
|
echo " Set it in .env file or with: export NOSTR_SECRET_KEY=your_key_here"
|
||||||
|
echo " Or use --prompt-sec flag (nak will prompt for key)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse RELAYS from environment if set
|
||||||
|
default_relays=()
|
||||||
|
if [ -n "$RELAYS" ]; then
|
||||||
|
# Split RELAYS string into array
|
||||||
|
read -ra default_relays <<< "$RELAYS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Main logic
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
# No arguments: list all markdown files and let user choose
|
||||||
|
echo "Available markdown files:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
files=("$MARKDOWN_DIR"/*.md)
|
||||||
|
if [ ! -e "${files[0]}" ]; then
|
||||||
|
echo "No markdown files found in $MARKDOWN_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display files with numbers
|
||||||
|
declare -a file_array
|
||||||
|
i=1
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo " $i) $filename"
|
||||||
|
file_array[$i]="$file"
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Enter file number(s) to publish (space-separated), or 'all' for all files:"
|
||||||
|
read -r selection
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to use defaults from .env):"
|
||||||
|
echo " Defaults: ${default_relays[*]}"
|
||||||
|
else
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to skip):"
|
||||||
|
fi
|
||||||
|
read -r relay_input
|
||||||
|
|
||||||
|
# Parse relay URLs
|
||||||
|
relays=()
|
||||||
|
if [ -n "$relay_input" ]; then
|
||||||
|
read -ra relays <<< "$relay_input"
|
||||||
|
elif [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
# Use defaults from .env
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$selection" = "all" ]; then
|
||||||
|
# Publish all files
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
publish_file "$file" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Publish selected files
|
||||||
|
for num in $selection; do
|
||||||
|
if [ -n "${file_array[$num]}" ]; then
|
||||||
|
publish_file "${file_array[$num]}" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "⚠️ Invalid selection: $num"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Argument provided: publish specific file
|
||||||
|
filename="$1"
|
||||||
|
shift # Remove filename, rest are relay URLs
|
||||||
|
relays=("$@")
|
||||||
|
|
||||||
|
# If no relays provided as arguments, use defaults from .env
|
||||||
|
if [ ${#relays[@]} -eq 0 ] && [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If filename doesn't end with .md, add it
|
||||||
|
if [[ ! "$filename" =~ \.md$ ]]; then
|
||||||
|
filename="${filename}.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_path="$MARKDOWN_DIR/$filename"
|
||||||
|
|
||||||
|
if [ ! -f "$file_path" ]; then
|
||||||
|
echo "Error: File not found: $file_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish_file "$file_path" "${relays[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
82
src/App.tsx
82
src/App.tsx
@@ -576,6 +576,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to update keep-alive subscription based on current active relays
|
||||||
|
const updateKeepAlive = (relayUrls?: string[]) => {
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update address loader based on current active relays
|
||||||
|
const updateAddressLoader = (relayUrls?: string[]) => {
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: targetRelays
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}
|
||||||
|
|
||||||
// Handle user relay list and blocked relays when account changes
|
// Handle user relay list and blocked relays when account changes
|
||||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||||
if (account) {
|
if (account) {
|
||||||
@@ -604,20 +629,6 @@ function App() {
|
|||||||
// Apply initial set immediately
|
// Apply initial set immediately
|
||||||
applyRelaySetToPool(pool, initialRelays)
|
applyRelaySetToPool(pool, initialRelays)
|
||||||
|
|
||||||
// Prepare keep-alive helper
|
|
||||||
const updateKeepAlive = () => {
|
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
|
||||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
|
||||||
const activeRelays = getActiveRelayUrls(pool)
|
|
||||||
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
|
|
||||||
next: () => {},
|
|
||||||
error: () => {}
|
|
||||||
})
|
|
||||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin loading blocked relays in background
|
// Begin loading blocked relays in background
|
||||||
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||||
|
|
||||||
@@ -649,43 +660,16 @@ function App() {
|
|||||||
applyRelaySetToPool(pool, finalRelays)
|
applyRelaySetToPool(pool, finalRelays)
|
||||||
|
|
||||||
updateKeepAlive()
|
updateKeepAlive()
|
||||||
|
updateAddressLoader()
|
||||||
// Update address loader with new relays
|
|
||||||
const activeRelays = getActiveRelayUrls(pool)
|
|
||||||
const addressLoader = createAddressLoader(pool, {
|
|
||||||
eventStore: store,
|
|
||||||
lookupRelays: activeRelays
|
|
||||||
})
|
|
||||||
store.addressableLoader = addressLoader
|
|
||||||
store.replaceableLoader = addressLoader
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
||||||
// Continue with initial relay set on error - no need to change anything
|
// Continue with initial relay set on error - no need to change anything
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// User logged out - reset to hardcoded relays
|
// User logged out - reset to hardcoded relays
|
||||||
|
|
||||||
applyRelaySetToPool(pool, RELAYS)
|
applyRelaySetToPool(pool, RELAYS)
|
||||||
|
updateKeepAlive(RELAYS)
|
||||||
|
updateAddressLoader(RELAYS)
|
||||||
// Update keep-alive subscription
|
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
|
||||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
|
||||||
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
|
||||||
next: () => {},
|
|
||||||
error: () => {}
|
|
||||||
})
|
|
||||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
|
||||||
|
|
||||||
// Reset address loader
|
|
||||||
const addressLoader = createAddressLoader(pool, {
|
|
||||||
eventStore: store,
|
|
||||||
lookupRelays: RELAYS
|
|
||||||
})
|
|
||||||
store.addressableLoader = addressLoader
|
|
||||||
store.replaceableLoader = addressLoader
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -755,6 +739,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [showToast])
|
}, [showToast])
|
||||||
|
|
||||||
|
// Strip _spa query parameter from URL after SPA loads
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (url.searchParams.has('_spa')) {
|
||||||
|
url.searchParams.delete('_spa')
|
||||||
|
const path = url.pathname + (url.search ? url.search : '') + url.hash
|
||||||
|
window.history.replaceState(null, '', path)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
|
|||||||
@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { fetchReadableContent } from '../services/readerService'
|
import { fetchReadableContent } from '../services/readerService'
|
||||||
|
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||||
|
|
||||||
interface AddBookmarkModalProps {
|
interface AddBookmarkModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract metadata from HTML
|
// Helper to extract tags from OpenGraph data
|
||||||
function extractMetaTag(html: string, patterns: string[]): string | null {
|
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = html.match(new RegExp(pattern, 'i'))
|
|
||||||
if (match) return match[1]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTags(html: string): string[] {
|
|
||||||
const tags: string[] = []
|
const tags: string[] = []
|
||||||
|
|
||||||
// Extract keywords meta tag
|
// Extract keywords from OpenGraph data
|
||||||
const keywords = extractMetaTag(html, [
|
if (ogData.keywords && typeof ogData.keywords === 'string') {
|
||||||
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
ogData.keywords.split(/[,;]/)
|
||||||
])
|
.map((k: string) => k.trim().toLowerCase())
|
||||||
if (keywords) {
|
.filter((k: string) => k.length > 0 && k.length < 30)
|
||||||
keywords.split(/[,;]/)
|
.forEach((k: string) => tags.push(k))
|
||||||
.map(k => k.trim().toLowerCase())
|
|
||||||
.filter(k => k.length > 0 && k.length < 30)
|
|
||||||
.forEach(k => tags.push(k))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract article:tag (multiple possible)
|
// Extract article:tag from OpenGraph data
|
||||||
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
if (ogData['article:tag']) {
|
||||||
let match
|
const articleTagValue = ogData['article:tag']
|
||||||
while ((match = articleTagRegex.exec(html)) !== null) {
|
const articleTags = Array.isArray(articleTagValue)
|
||||||
const tag = match[1].trim().toLowerCase()
|
? articleTagValue
|
||||||
if (tag && tag.length < 30) tags.push(tag)
|
: [articleTagValue]
|
||||||
|
|
||||||
|
articleTags.forEach((tag: unknown) => {
|
||||||
|
if (typeof tag === 'string') {
|
||||||
|
const cleanTag = tag.trim().toLowerCase()
|
||||||
|
if (cleanTag && cleanTag.length < 30) {
|
||||||
|
tags.push(cleanTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(new Set(tags)).slice(0, 5)
|
return Array.from(new Set(tags)).slice(0, 5)
|
||||||
@@ -83,17 +82,27 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
fetchTimeoutRef.current = window.setTimeout(async () => {
|
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||||
setIsFetchingMetadata(true)
|
setIsFetchingMetadata(true)
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(normalizedUrl)
|
// Fetch both readable content and OpenGraph data in parallel
|
||||||
lastFetchedUrlRef.current = normalizedUrl
|
const [content, ogData] = await Promise.all([
|
||||||
|
fetchReadableContent(normalizedUrl),
|
||||||
|
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
||||||
|
])
|
||||||
|
|
||||||
|
lastFetchedUrlRef.current = normalizedUrl
|
||||||
let extractedAnything = false
|
let extractedAnything = false
|
||||||
|
|
||||||
// Extract title: prioritize og:title > twitter:title > <title>
|
// Extract title: prioritize og:title > twitter:title > content.title
|
||||||
if (!title && content.html) {
|
if (!title) {
|
||||||
const extractedTitle = extractMetaTag(content.html, [
|
let extractedTitle = null
|
||||||
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
if (ogData) {
|
||||||
]) || content.title
|
extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to content.title if no OpenGraph title found
|
||||||
|
if (!extractedTitle) {
|
||||||
|
extractedTitle = content.title
|
||||||
|
}
|
||||||
|
|
||||||
if (extractedTitle) {
|
if (extractedTitle) {
|
||||||
setTitle(extractedTitle)
|
setTitle(extractedTitle)
|
||||||
@@ -102,12 +111,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract description: prioritize og:description > twitter:description > meta description
|
// Extract description: prioritize og:description > twitter:description > meta description
|
||||||
if (!description && content.html) {
|
if (!description && ogData) {
|
||||||
const extractedDesc = extractMetaTag(content.html, [
|
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||||
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
|
||||||
])
|
|
||||||
|
|
||||||
if (extractedDesc) {
|
if (extractedDesc) {
|
||||||
setDescription(extractedDesc)
|
setDescription(extractedDesc)
|
||||||
@@ -116,8 +121,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||||
if (!tagsInput && content.html) {
|
if (!tagsInput && ogData) {
|
||||||
const extractedTags = extractTags(content.html)
|
const extractedTags = extractTagsFromOgData(ogData)
|
||||||
|
|
||||||
// Only add boris tag if we extracted something
|
// Only add boris tag if we extracted something
|
||||||
if (extractedAnything || extractedTags.length > 0) {
|
if (extractedAnything || extractedTags.length > 0) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface AuthorCardProps {
|
interface AuthorCardProps {
|
||||||
authorPubkey: string
|
authorPubkey: string
|
||||||
@@ -16,9 +17,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
|||||||
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||||
|
|
||||||
const getAuthorName = () => {
|
const getAuthorName = () => {
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, authorPubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorImage = profile?.picture || profile?.image
|
const authorImage = profile?.picture || profile?.image
|
||||||
@@ -27,7 +26,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
|
|||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (clickable) {
|
if (clickable) {
|
||||||
const npub = nip19.npubEncode(authorPubkey)
|
const npub = nip19.npubEncode(authorPubkey)
|
||||||
navigate(`/p/${npub}`)
|
navigate(`/p/${npub}/writings`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BlogPostPreview } from '../services/exploreService'
|
|||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { isKnownBot } from '../config/bots'
|
import { isKnownBot } from '../config/bots'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface BlogPostCardProps {
|
interface BlogPostCardProps {
|
||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
@@ -18,8 +19,13 @@ interface BlogPostCardProps {
|
|||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
|
||||||
|
// when they come into view. The Service Worker will cache them automatically.
|
||||||
|
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
|
||||||
|
// when there are many blog posts.
|
||||||
|
|
||||||
|
const displayName = getProfileDisplayName(profile, post.author)
|
||||||
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||||
|
|
||||||
// Hide bot authors by name/display_name
|
// Hide bot authors by name/display_name
|
||||||
@@ -47,6 +53,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
// Reading progress display
|
// Reading progress display
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build article coordinate for navigation state (kind:pubkey:dTag)
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
@@ -56,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
image: post.image,
|
image: post.image,
|
||||||
summary: post.summary,
|
summary: post.summary,
|
||||||
published: post.published
|
published: post.published
|
||||||
}
|
},
|
||||||
|
articleCoordinate,
|
||||||
|
eventId: post.event.id
|
||||||
}}
|
}}
|
||||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
|||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl } from '../utils/helpers'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
@@ -62,12 +63,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||||
const authorNpub = npubEncode(bookmark.pubkey)
|
const authorNpub = npubEncode(bookmark.pubkey)
|
||||||
|
|
||||||
// Get display name for author
|
// Get display name for author using centralized utility
|
||||||
const getAuthorDisplayName = () => {
|
const getAuthorDisplayName = () => {
|
||||||
if (authorProfile?.name) return authorProfile.name
|
const displayName = getProfileDisplayName(authorProfile, bookmark.pubkey)
|
||||||
if (authorProfile?.display_name) return authorProfile.display_name
|
// getProfileDisplayName returns npub format for fallback, but we want short pubkey format
|
||||||
if (authorProfile?.nip05) return authorProfile.nip05
|
// So check if it's the fallback format and use short() instead
|
||||||
return short(bookmark.pubkey) // fallback to short pubkey
|
if (displayName.startsWith('@') && displayName.includes('...')) {
|
||||||
|
return short(bookmark.pubkey)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content type icon based on bookmark kind and URL classification
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
@@ -169,5 +173,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,10 +247,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{allIndividualBookmarks.length > 0 && (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
<BookmarkFilters
|
<div className="bookmark-filters-wrapper">
|
||||||
selectedFilter={selectedFilter}
|
<BookmarkFilters
|
||||||
onFilterChange={setSelectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
/>
|
onFilterChange={setSelectedFilter}
|
||||||
|
/>
|
||||||
|
<CompactButton
|
||||||
|
icon={faPlus}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
title="Add web bookmark"
|
||||||
|
ariaLabel="Add web bookmark"
|
||||||
|
className="bookmark-section-action"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!activeAccount ? (
|
{!activeAccount ? (
|
||||||
@@ -287,15 +296,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div key={section.key} className="bookmarks-section">
|
<div key={section.key} className="bookmarks-section">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||||
{section.key === 'web' && activeAccount && (
|
|
||||||
<CompactButton
|
|
||||||
icon={faPlus}
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
title="Add web bookmark"
|
|
||||||
ariaLabel="Add web bookmark"
|
|
||||||
className="bookmark-section-action"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
{section.items.map((individualBookmark, index) => (
|
{section.items.map((individualBookmark, index) => (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import RichContent from '../RichContent'
|
import RichContent from '../RichContent'
|
||||||
@@ -10,6 +11,7 @@ import { classifyUrl } from '../../utils/helpers'
|
|||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
import { naddrEncode } from 'nostr-tools/nip19'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -22,7 +24,7 @@ interface CardViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
articleTitle?: string
|
||||||
readingProgress?: number
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,13 +33,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
index,
|
index,
|
||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon,
|
articleTitle,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
@@ -45,21 +46,41 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||||
|
|
||||||
const [ogImage, setOgImage] = useState<string | null>(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 isArticle = bookmark.kind === 30023
|
||||||
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
const isNote = bookmark.kind === 1
|
||||||
|
|
||||||
// Calculate progress color (matching BlogPostCard logic)
|
// Extract title from tags for regular bookmarks (not just articles)
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
|
||||||
progressColor = '#10b981' // Green (completed)
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
const getContentTypeIcon = () => {
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
if (isArticle) return faNewspaper // Nostr-native article
|
||||||
|
|
||||||
|
// For web bookmarks, classify the URL to determine icon
|
||||||
|
if (isWebBookmark && firstUrlClassificationType) {
|
||||||
|
switch (firstUrlClassificationType) {
|
||||||
|
case 'youtube':
|
||||||
|
case 'video':
|
||||||
|
return faCirclePlay
|
||||||
|
case 'image':
|
||||||
|
return faCamera
|
||||||
|
case 'article':
|
||||||
|
return faFileLines
|
||||||
|
default:
|
||||||
|
return faGlobe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For notes, use sticky note icon
|
||||||
|
if (isNote) return faStickyNote
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return faLink
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Determine which image to use (article image, instant preview, or OG image)
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
@@ -71,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||||
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
@@ -106,122 +128,87 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${bookmark.id}-${index}`}
|
key={`${bookmark.id}-${index}`}
|
||||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
onClick={triggerOpen}
|
onClick={triggerOpen}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{cachedImage && (
|
<div className="card-layout">
|
||||||
<div
|
<div className="card-content">
|
||||||
className="article-hero-image"
|
<div className="card-content-header">
|
||||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
{(cachedImage || firstUrl) && (
|
||||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
<div
|
||||||
/>
|
className="card-thumbnail"
|
||||||
)}
|
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||||
<div className="bookmark-header">
|
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||||
<span className="bookmark-type">
|
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{getInternalRoute() ? (
|
|
||||||
<Link
|
|
||||||
to={getInternalRoute()!}
|
|
||||||
className="bookmark-date-link"
|
|
||||||
title="Open in app"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{extractedUrls.length > 0 && (
|
|
||||||
<div className="bookmark-urls">
|
|
||||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={urlIndex}
|
|
||||||
className="bookmark-url"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
|
||||||
title="Open in reader"
|
|
||||||
>
|
>
|
||||||
{url}
|
{!cachedImage && firstUrl && (
|
||||||
</button>
|
<div className="thumbnail-placeholder">
|
||||||
)
|
<FontAwesomeIcon icon={getContentTypeIcon()} />
|
||||||
})}
|
</div>
|
||||||
{extractedUrls.length > 1 && (
|
)}
|
||||||
<button
|
</div>
|
||||||
className="expand-toggle-urls"
|
)}
|
||||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
<div className="card-text-content">
|
||||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
<div className="bookmark-header">
|
||||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
</div>
|
||||||
|
|
||||||
|
{/* Display title for articles or bookmarks with titles */}
|
||||||
|
{(articleTitle || bookmarkTitle) && (
|
||||||
|
<h3 className="bookmark-title">
|
||||||
|
<RichContent content={articleTitle || bookmarkTitle || ''} className="" />
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isArticle && articleSummary ? (
|
||||||
|
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||||
|
) : bookmark.parsedContent ? (
|
||||||
|
<div className="bookmark-content">
|
||||||
|
{renderParsedContent(bookmark.parsedContent)}
|
||||||
|
</div>
|
||||||
|
) : bookmark.content && (
|
||||||
|
<RichContent content={bookmark.content} className="bookmark-content" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reading progress indicator as separator - always shown for all bookmark types */}
|
||||||
|
<ReadingProgressBar
|
||||||
|
readingProgress={readingProgress}
|
||||||
|
height={1}
|
||||||
|
marginTop="0.125rem"
|
||||||
|
marginBottom="0.125rem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bookmark-footer">
|
||||||
|
<div className="bookmark-meta-minimal">
|
||||||
|
<Link
|
||||||
|
to={`/p/${authorNpub}`}
|
||||||
|
className="author-link-minimal"
|
||||||
|
title="Open author profile"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
|
{getAuthorDisplayName()}
|
||||||
</button>
|
</Link>
|
||||||
)}
|
</div>
|
||||||
|
<div className="bookmark-footer-right">
|
||||||
|
{getInternalRoute() ? (
|
||||||
|
<Link
|
||||||
|
to={getInternalRoute()!}
|
||||||
|
className="bookmark-date-link"
|
||||||
|
title="Open in app"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{isArticle && articleSummary ? (
|
|
||||||
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
|
||||||
) : bookmark.parsedContent ? (
|
|
||||||
<div className="bookmark-content">
|
|
||||||
{shouldTruncate && bookmark.content
|
|
||||||
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}…`} className="" />
|
|
||||||
: renderParsedContent(bookmark.parsedContent)}
|
|
||||||
</div>
|
|
||||||
) : bookmark.content && (
|
|
||||||
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contentLength > 210 && (
|
|
||||||
<button
|
|
||||||
className="expand-toggle"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
||||||
title={expanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reading progress indicator for articles */}
|
|
||||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '3px',
|
|
||||||
width: '100%',
|
|
||||||
background: 'var(--color-border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '0.75rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${Math.round(readingProgress * 100)}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bookmark-footer">
|
|
||||||
<div className="bookmark-meta-minimal">
|
|
||||||
<Link
|
|
||||||
to={`/p/${authorNpub}`}
|
|
||||||
className="author-link-minimal"
|
|
||||||
title="Open author profile"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{getAuthorDisplayName()}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{/* CTA removed */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
|||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import RichContent from '../RichContent'
|
import RichContent from '../RichContent'
|
||||||
import { naddrEncode } from 'nostr-tools/nip19'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -36,13 +37,6 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
|
|
||||||
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||||
|
|
||||||
// Calculate progress color
|
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
|
||||||
progressColor = '#10b981' // Green (completed)
|
|
||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (isArticle) {
|
if (isArticle) {
|
||||||
@@ -86,27 +80,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reading progress indicator for all bookmark types with reading data */}
|
{/* Reading progress indicator - only show when there's actual progress */}
|
||||||
{readingProgress !== undefined && readingProgress > 0 && (
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
<div
|
<ReadingProgressBar
|
||||||
style={{
|
readingProgress={readingProgress}
|
||||||
height: '1px',
|
height={1}
|
||||||
width: '100%',
|
marginLeft="1.5rem"
|
||||||
background: 'var(--color-border)',
|
/>
|
||||||
overflow: 'hidden',
|
|
||||||
margin: '0',
|
|
||||||
marginLeft: '1.5rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${Math.round(readingProgress * 100)}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import RichContent from '../RichContent'
|
|||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { naddrEncode } from 'nostr-tools/nip19'
|
import { naddrEncode } from 'nostr-tools/nip19'
|
||||||
|
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -43,15 +44,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
// Calculate progress display (matching readingProgressUtils.ts logic)
|
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
|
||||||
let progressColor = '#6366f1' // Default blue (reading)
|
|
||||||
|
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
|
||||||
progressColor = '#10b981' // Green (completed)
|
|
||||||
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
|
||||||
progressColor = 'var(--color-text)' // Neutral text color (started)
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
@@ -122,27 +114,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
<RichContent content={bookmark.content} className="large-text" />
|
<RichContent content={bookmark.content} className="large-text" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
{/* Reading progress indicator for all bookmark types - always shown */}
|
||||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
<ReadingProgressBar
|
||||||
<div
|
readingProgress={readingProgress}
|
||||||
style={{
|
height={3}
|
||||||
height: '3px',
|
marginTop="0.75rem"
|
||||||
width: '100%',
|
/>
|
||||||
background: 'var(--color-border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginTop: '0.75rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease, background 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import React, { useMemo, useEffect, useRef } from 'react'
|
|||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventStore } from 'applesauce-react/hooks'
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||||
@@ -14,6 +17,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
|||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
import { useEventLoader } from '../hooks/useEventLoader'
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -58,6 +62,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
const eventId = eventIdParam
|
const eventId = eventIdParam
|
||||||
|
|
||||||
|
// Manage document title based on current route
|
||||||
|
const isViewingContent = !!(naddr || externalUrl || eventId)
|
||||||
|
useDocumentTitle({
|
||||||
|
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
|
||||||
|
})
|
||||||
|
|
||||||
// Extract tab from explore routes
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
@@ -72,16 +82,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Decode npub or nprofile to pubkey for profile view
|
// Decode npub or nprofile to pubkey for profile view using applesauce helper
|
||||||
let profilePubkey: string | undefined
|
let profilePubkey: string | undefined
|
||||||
if (npub && showProfile) {
|
if (npub && showProfile) {
|
||||||
try {
|
try {
|
||||||
const decoded = nip19.decode(npub)
|
const decoded = nip19.decode(npub)
|
||||||
if (decoded.type === 'npub') {
|
profilePubkey = getPubkeyFromDecodeResult(decoded)
|
||||||
profilePubkey = decoded.data
|
|
||||||
} else if (decoded.type === 'nprofile') {
|
|
||||||
profilePubkey = decoded.data.pubkey
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to decode npub/nprofile:', err)
|
console.error('Failed to decode npub/nprofile:', err)
|
||||||
}
|
}
|
||||||
@@ -94,6 +100,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
}
|
}
|
||||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||||
|
|
||||||
|
// Reset scroll to top when navigating to profile routes
|
||||||
|
useEffect(() => {
|
||||||
|
if (showProfile) {
|
||||||
|
// Reset scroll position when navigating to profile pages
|
||||||
|
// Use requestAnimationFrame to ensure it happens after DOM updates
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [location.pathname, showProfile])
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
const eventStore = useEventStore()
|
const eventStore = useEventStore()
|
||||||
@@ -222,13 +239,26 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
currentArticle,
|
currentArticle,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
readerContent,
|
readerContent,
|
||||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
onHighlightCreated: (highlight) => setHighlights(prev => {
|
||||||
|
// Deduplicate by checking if highlight with this ID already exists
|
||||||
|
const exists = prev.some(h => h.id === highlight.id)
|
||||||
|
if (exists) {
|
||||||
|
return prev // Don't add duplicate
|
||||||
|
}
|
||||||
|
return [highlight, ...prev]
|
||||||
|
}),
|
||||||
settings
|
settings
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Determine which loader should be active based on route
|
||||||
|
// Only one loader should run at a time to prevent state conflicts
|
||||||
|
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
|
||||||
|
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
|
||||||
|
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
|
||||||
|
|
||||||
// Load nostr-native article if naddr is in URL
|
// Load nostr-native article if naddr is in URL
|
||||||
useArticleLoader({
|
useArticleLoader({
|
||||||
naddr,
|
naddr: shouldLoadArticle ? naddr : undefined,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
@@ -245,7 +275,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
|
|
||||||
// Load external URL if /r/* route is used
|
// Load external URL if /r/* route is used
|
||||||
useExternalUrlLoader({
|
useExternalUrlLoader({
|
||||||
url: externalUrl,
|
url: shouldLoadExternal ? externalUrl : undefined,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
@@ -260,7 +290,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
|
|
||||||
// Load event if /e/:eventId route is used
|
// Load event if /e/:eventId route is used
|
||||||
useEventLoader({
|
useEventLoader({
|
||||||
eventId,
|
eventId: shouldLoadEvent ? eventId : undefined,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
|
|||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
selectedColor: string
|
selectedColor: string
|
||||||
onColorChange: (color: string) => void
|
onColorChange: (color: string) => void
|
||||||
|
colors?: typeof HIGHLIGHT_COLORS
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
|
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange, colors = HIGHLIGHT_COLORS }) => {
|
||||||
return (
|
return (
|
||||||
<div className="color-picker">
|
<div className="color-picker">
|
||||||
{HIGHLIGHT_COLORS.map(color => (
|
{colors.map(color => (
|
||||||
<button
|
<button
|
||||||
key={color.value}
|
key={color.value}
|
||||||
onClick={() => onColorChange(color.value)}
|
onClick={() => onColorChange(color.value)}
|
||||||
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
||||||
style={{ backgroundColor: color.value }}
|
style={{ backgroundColor: color.value }}
|
||||||
title={color.name}
|
title={color.name}
|
||||||
aria-label={`${color.name} highlight color`}
|
aria-label={`${color.name} color`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
@@ -13,6 +12,8 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { getActiveRelayUrls } from '../services/relayManager'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay } from '../config/relays'
|
||||||
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
@@ -34,9 +35,7 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
|||||||
import { archiveController } from '../services/archiveController'
|
import { archiveController } from '../services/archiveController'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { shouldTrackReadingProgress } from '../utils/helpers'
|
||||||
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
|
||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
@@ -111,15 +110,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
|
||||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
|
||||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const externalMenuRef = 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 { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
@@ -140,7 +135,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
||||||
}, [selectedUrl, title, markdown, html])
|
}, [selectedUrl, title, markdown, html])
|
||||||
|
|
||||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
const { contentRef } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
@@ -270,6 +265,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Reset scroll position and restore ref when article changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!articleIdentifier) return
|
||||||
|
|
||||||
|
// Suppress saves during navigation to prevent saving 0% position
|
||||||
|
// The 500ms suppression covers the scroll reset and initial render
|
||||||
|
if (suppressSavesForRef.current) {
|
||||||
|
suppressSavesForRef.current(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scroll to top when article identifier changes
|
||||||
|
// This prevents showing wrong scroll position from previous article
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||||
|
// Reset restore attempt tracking for new article
|
||||||
|
hasAttemptedRestoreRef.current = null
|
||||||
|
}, [articleIdentifier])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||||
return
|
return
|
||||||
@@ -343,21 +355,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
}
|
}
|
||||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
|
||||||
setShowVideoMenu(false)
|
|
||||||
}
|
|
||||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||||
setShowExternalMenu(false)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
if (showArticleMenu || showExternalMenu) {
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
}, [showArticleMenu, showExternalMenu])
|
||||||
|
|
||||||
// Check available space and position menu upward if needed
|
// Check available space and position menu upward if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,13 +389,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (showArticleMenu) {
|
if (showArticleMenu) {
|
||||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||||
}
|
}
|
||||||
if (showVideoMenu) {
|
|
||||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
|
||||||
}
|
|
||||||
if (showExternalMenu) {
|
if (showExternalMenu) {
|
||||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||||
}
|
}
|
||||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
}, [showArticleMenu, showExternalMenu])
|
||||||
|
|
||||||
const readingStats = useMemo(() => {
|
const readingStats = useMemo(() => {
|
||||||
const content = markdown || html || ''
|
const content = markdown || html || ''
|
||||||
@@ -418,34 +424,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
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
|
// Get article links for menu
|
||||||
@@ -454,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
const relayHints = activeRelays.filter(r =>
|
const relayHints = activeRelays
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
.filter(url => !isLocalRelay(url))
|
||||||
).slice(0, 3)
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
const naddr = nip19.naddrEncode({
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
@@ -483,7 +464,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
setShowArticleMenu(!showArticleMenu)
|
setShowArticleMenu(!showArticleMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
|
||||||
|
|
||||||
const handleOpenPortal = () => {
|
const handleOpenPortal = () => {
|
||||||
if (articleLinks) {
|
if (articleLinks) {
|
||||||
@@ -571,46 +551,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
setShowArticleMenu(false)
|
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
|
// External article actions
|
||||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||||
@@ -808,13 +748,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="reader" aria-busy="true">
|
|
||||||
<ContentSkeleton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightRgb = hexToRgb(highlightColor)
|
const highlightRgb = hexToRgb(highlightColor)
|
||||||
|
|
||||||
@@ -854,11 +787,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ReaderHeader
|
<ReaderHeader
|
||||||
title={ytMeta?.title || title}
|
title={title}
|
||||||
image={image}
|
image={image}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
published={published}
|
published={published}
|
||||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
readingTimeText={readingStats ? readingStats.text : null}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -871,86 +804,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isExternalVideo ? (
|
{loading || !markdown && !html ? (
|
||||||
<>
|
<div className="reader" aria-busy="true">
|
||||||
<div className="reader-video">
|
<ContentSkeleton />
|
||||||
<ReactPlayer
|
</div>
|
||||||
url={selectedUrl as string}
|
|
||||||
controls
|
|
||||||
width="100%"
|
|
||||||
height="auto"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
aspectRatio: '16/9'
|
|
||||||
}}
|
|
||||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
|
||||||
/>
|
|
||||||
</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 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 ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
|
||||||
<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={isCheckingReadStatus}
|
|
||||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
|
||||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
|
||||||
>
|
|
||||||
<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 || html ? (
|
||||||
<>
|
<>
|
||||||
{markdown ? (
|
{markdown ? (
|
||||||
@@ -959,16 +816,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
key={`content:${contentKey}`}
|
key={`content:${contentKey}`}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
html={finalHtml}
|
html={finalHtml}
|
||||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||||
className="reader-markdown"
|
className="reader-markdown"
|
||||||
onMouseUp={handleSelectionEnd}
|
|
||||||
onTouchEnd={handleSelectionEnd}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader-markdown">
|
<div className="reader-markdown">
|
||||||
<div className="loading-spinner">
|
<ContentSkeleton />
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -976,15 +829,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
key={`content:${contentKey}`}
|
key={`content:${contentKey}`}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
html={finalHtml || html || ''}
|
html={finalHtml || html || ''}
|
||||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||||
className="reader-html"
|
className="reader-html"
|
||||||
onMouseUp={handleSelectionEnd}
|
|
||||||
onTouchEnd={handleSelectionEnd}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Article menu for external URLs */}
|
{/* Article menu for external URLs */}
|
||||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
{!isNostrArticle && selectedUrl && (
|
||||||
<div className="article-menu-container">
|
<div className="article-menu-container">
|
||||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -1135,11 +986,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<div className="reader empty">
|
|
||||||
<p>No readable content found for this URL.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
|
||||||
import { decode } from 'nostr-tools/nip19'
|
|
||||||
import { extractNprofilePubkeys } from '../utils/helpers'
|
|
||||||
|
|
||||||
const { getPubkeyFromDecodeResult } = Helpers
|
|
||||||
|
|
||||||
interface Props { content: string }
|
|
||||||
|
|
||||||
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
|
|
||||||
const matches = extractNprofilePubkeys(content)
|
|
||||||
const decoded = matches
|
|
||||||
.map((m) => {
|
|
||||||
try { return decode(m) } catch { return undefined as undefined }
|
|
||||||
})
|
|
||||||
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
|
|
||||||
|
|
||||||
const lookups = decoded
|
|
||||||
.map((res) => getPubkeyFromDecodeResult(res))
|
|
||||||
.filter((v): v is string => typeof v === 'string')
|
|
||||||
|
|
||||||
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
|
|
||||||
|
|
||||||
let rendered = content
|
|
||||||
matches.forEach((m, i) => {
|
|
||||||
const pk = getPubkeyFromDecodeResult(decoded[i])
|
|
||||||
const found = profiles.find((p) => p.pubkey === pk)
|
|
||||||
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
|
|
||||||
if (name) rendered = rendered.replace(m, `@${name}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div className="bookmark-content">{rendered}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentWithResolvedProfiles
|
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
case 'highlights':
|
case 'highlights':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<HighlightSkeleton key={i} />
|
<HighlightSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
@@ -607,7 +607,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
<span>No highlights to show for the selected scope.</span>
|
<span>No highlights to show for the selected scope.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{classifiedHighlights.map((highlight) => (
|
{classifiedHighlights.map((highlight) => (
|
||||||
<HighlightItem
|
<HighlightItem
|
||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Models } from 'applesauce-core'
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface HighlightCitationProps {
|
interface HighlightCitationProps {
|
||||||
highlight: Highlight
|
highlight: Highlight
|
||||||
@@ -79,7 +80,8 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
loadTitle()
|
loadTitle()
|
||||||
}, [highlight.eventReference, relayPool])
|
}, [highlight.eventReference, relayPool])
|
||||||
|
|
||||||
const authorName = authorProfile?.name || authorProfile?.display_name
|
// Use centralized profile display name utility
|
||||||
|
const authorName = authorPubkey ? getProfileDisplayName(authorProfile, authorPubkey) : undefined
|
||||||
|
|
||||||
// For nostr-native content with article reference
|
// For nostr-native content with article reference
|
||||||
if (highlight.eventReference && (authorName || articleTitle)) {
|
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||||
import { getActiveRelayUrls } from '../services/relayManager'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
import { createDeletionRequest } from '../services/deletionService'
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
@@ -18,6 +19,7 @@ import CompactButton from './CompactButton'
|
|||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import NostrMentionLink from './NostrMentionLink'
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -114,7 +116,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -128,17 +129,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
// Get display name for the user
|
// Get display name for the user
|
||||||
const getUserDisplayName = () => {
|
const getUserDisplayName = () => {
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, highlight.pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update offline indicator when highlight prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlight.isOfflineCreated && !isSyncing) {
|
|
||||||
setShowOfflineIndicator(true)
|
|
||||||
}
|
|
||||||
}, [highlight.isOfflineCreated, isSyncing])
|
|
||||||
|
|
||||||
// Listen to sync state changes
|
// Listen to sync state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,8 +140,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setIsSyncing(syncingState)
|
setIsSyncing(syncingState)
|
||||||
// When sync completes successfully, update highlight to show all relays
|
// When sync completes successfully, update highlight to show all relays
|
||||||
if (!syncingState) {
|
if (!syncingState) {
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
// Update the highlight with all relays after successful sync
|
// Update the highlight with all relays after successful sync
|
||||||
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||||
const updatedHighlight = {
|
const updatedHighlight = {
|
||||||
@@ -189,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showMenu, showDeleteConfirm])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
// Navigate to the article that this highlight references and scroll to the highlight
|
||||||
// If onHighlightClick is provided, use it (legacy behavior)
|
const navigateToArticle = () => {
|
||||||
if (onHighlightClick) {
|
// Always try to navigate if we have a reference - quote button should always work
|
||||||
onHighlightClick(highlight.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, navigate to the article that this highlight references
|
|
||||||
if (highlight.eventReference) {
|
if (highlight.eventReference) {
|
||||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
const parts = highlight.eventReference.split(':')
|
const parts = highlight.eventReference.split(':')
|
||||||
@@ -219,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
openHighlights: true
|
openHighlights: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (highlight.urlReference) {
|
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
|
||||||
|
// as we don't have enough info to construct the article URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlight.urlReference) {
|
||||||
// Navigate to external URL with highlight ID to trigger scroll
|
// Navigate to external URL with highlight ID to trigger scroll
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||||
state: {
|
state: {
|
||||||
@@ -229,16 +220,57 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
openHighlights: true
|
openHighlights: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we get here, there's no valid reference to navigate to
|
||||||
|
// This shouldn't happen for valid highlights, but we'll log it for debugging
|
||||||
|
console.warn('Cannot navigate to article: highlight has no valid eventReference or urlReference', highlight.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, navigate to the article that this highlight references
|
||||||
|
navigateToArticle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHighlightLinks = () => {
|
const getHighlightLinks = () => {
|
||||||
// Encode the highlight event itself (kind 9802) as a nevent
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
// Get non-local relays for the hint
|
// Relay hint selection priority:
|
||||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
// 1. Published relays (where we successfully published the event)
|
||||||
const relayHints = activeRelays.filter(r =>
|
// 2. Seen relays (where we observed the event)
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
// 3. Configured content relays (deterministic fallback)
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
// All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
|
||||||
|
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
const seenOnRelays = highlight.seenOnRelays || []
|
||||||
|
|
||||||
|
// Determine base candidates: prefer published, then seen, then configured relays
|
||||||
|
let candidates: string[]
|
||||||
|
if (publishedRelays.length > 0) {
|
||||||
|
// Prefer published relays, but include seen relays as backup
|
||||||
|
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
} else if (seenOnRelays.length > 0) {
|
||||||
|
candidates = seenOnRelays
|
||||||
|
} else {
|
||||||
|
// Fallback to deterministic configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
|
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to content-capable remote relays (exclude local and non-content relays)
|
||||||
|
// Then take up to 3 for relay hints
|
||||||
|
const relayHints = candidates
|
||||||
|
.filter(url => !isLocalRelay(url))
|
||||||
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const nevent = nip19.neventEncode({
|
const nevent = nip19.neventEncode({
|
||||||
id: highlight.id,
|
id: highlight.id,
|
||||||
@@ -292,9 +324,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
onHighlightUpdate(updatedHighlight)
|
onHighlightUpdate(updatedHighlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setShowOfflineIndicator(false)
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to rebroadcast:', error)
|
console.error('❌ Failed to rebroadcast:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -313,8 +342,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show relay list, use plane icon for local-only
|
// Check if this highlight was only published to local relays
|
||||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
let isLocalOnly = highlight.isLocalOnly
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
|
||||||
|
// Fallback 1: Check if this highlight was marked for offline sync (flight mode)
|
||||||
|
if (isLocalOnly === undefined) {
|
||||||
|
if (isEventOfflineCreated(highlight.id)) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2: If publishedRelays only contains local relays, it's local-only
|
||||||
|
if (isLocalOnly === undefined && publishedRelays.length > 0) {
|
||||||
|
const hasOnlyLocalRelays = publishedRelays.every(url => isLocalRelay(url))
|
||||||
|
const hasRemoteRelays = publishedRelays.some(url => !isLocalRelay(url))
|
||||||
|
if (hasOnlyLocalRelays && !hasRemoteRelays) {
|
||||||
|
isLocalOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// If isLocalOnly is true (from any fallback), show airplane icon
|
||||||
|
if (isLocalOnly === true) {
|
||||||
|
return {
|
||||||
|
icon: faPlane,
|
||||||
|
tooltip: publishedRelays.length > 0
|
||||||
|
? 'Local relays only - will sync when remote relays available'
|
||||||
|
: 'Created in flight mode - will sync when remote relays available',
|
||||||
|
spin: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show highlighter icon with relay info if available
|
// Show highlighter icon with relay info if available
|
||||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||||
@@ -322,7 +380,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -418,6 +476,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
handleConfirmDelete()
|
handleConfirmDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate to author's profile
|
||||||
|
const navigateToProfile = (tab?: 'highlights' | 'writings') => {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(highlight.pubkey)
|
||||||
|
const path = tab === 'writings' ? `/p/${npub}/writings` : `/p/${npub}`
|
||||||
|
navigate(path)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to encode npub for profile navigation:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuViewProfile = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuGoToQuote = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHighlightText = () => {
|
||||||
|
const { content, context } = highlight
|
||||||
|
|
||||||
|
if (context && context.length > 0) {
|
||||||
|
const index = context.indexOf(content)
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const before = context.slice(0, index)
|
||||||
|
const after = context.slice(index + content.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show context and the core highlight separately
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="highlight-context-prefix">{context}</span>
|
||||||
|
<br />
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="highlight-core">{content}</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -467,15 +590,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
<CompactButton
|
<CompactButton
|
||||||
className="highlight-quote-button"
|
className="highlight-quote-button"
|
||||||
icon={faQuoteLeft}
|
icon={faQuoteLeft}
|
||||||
title="Quote"
|
title="Go to quote in article"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<blockquote className="highlight-text">
|
<blockquote
|
||||||
{highlight.content}
|
className="highlight-text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Go to quote in article"
|
||||||
|
>
|
||||||
|
{renderHighlightText()}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
{showCitation && (
|
{showCitation && (
|
||||||
@@ -508,9 +653,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="highlight-author">
|
<CompactButton
|
||||||
|
className="highlight-author"
|
||||||
|
onClick={handleAuthorClick}
|
||||||
|
title="View profile"
|
||||||
|
>
|
||||||
{getUserDisplayName()}
|
{getUserDisplayName()}
|
||||||
</span>
|
</CompactButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||||
@@ -549,6 +698,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="highlight-menu">
|
<div className="highlight-menu">
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuGoToQuote}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||||
|
<span>Go to quote</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuViewProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
<span>View profile</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="highlight-menu-item"
|
className="highlight-menu-item"
|
||||||
onClick={handleOpenPortal}
|
onClick={handleOpenPortal}
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ const Me: React.FC<MeProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
// Derive links from bookmarks with OpenGraph enhancement
|
||||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
const initialLinks = await deriveLinksFromBookmarks(bookmarks)
|
||||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
setLinksMap(initialMap)
|
setLinksMap(initialMap)
|
||||||
setLinks(initialLinks)
|
setLinks(initialLinks)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
interface NostrMentionLinkProps {
|
interface NostrMentionLinkProps {
|
||||||
nostrUri: string
|
nostrUri: string
|
||||||
@@ -20,25 +25,31 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// Decode the nostr URI first
|
// Decode the nostr URI first
|
||||||
let decoded: ReturnType<typeof nip19.decode> | null = null
|
let decoded: ReturnType<typeof nip19.decode> | null = null
|
||||||
let pubkey: string | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
decoded = nip19.decode(identifier)
|
decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
// Extract pubkey for profile fetching (works for npub and nprofile)
|
|
||||||
if (decoded.type === 'npub') {
|
|
||||||
pubkey = decoded.data
|
|
||||||
} else if (decoded.type === 'nprofile') {
|
|
||||||
pubkey = decoded.data.pubkey
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Decoding failed, will fallback to shortened identifier
|
// Decoding failed, will fallback to shortened identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
|
||||||
|
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
|
||||||
|
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
// Fetch profile at top level (Rules of Hooks)
|
// Fetch profile at top level (Rules of Hooks)
|
||||||
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
|
||||||
|
// Check if profile is in cache or eventStore for loading detection
|
||||||
|
const isInCacheOrStore = useMemo(() => {
|
||||||
|
if (!pubkey) return false
|
||||||
|
return isProfileInCacheOrStore(pubkey, eventStore)
|
||||||
|
}, [pubkey, eventStore])
|
||||||
|
|
||||||
|
// Show loading if profile doesn't exist and not in cache/store (for npub/nprofile)
|
||||||
|
// pubkey will be undefined for non-profile types, so no need for explicit type check
|
||||||
|
const isLoading = !profile && pubkey && !isInCacheOrStore
|
||||||
|
|
||||||
// If decoding failed, show shortened identifier
|
// If decoding failed, show shortened identifier
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
@@ -49,37 +60,30 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to render profile links (used for both npub and nprofile)
|
||||||
|
const renderProfileLink = (pubkey: string) => {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const displayName = getProfileDisplayName(profile, pubkey)
|
||||||
|
const linkClassName = isLoading ? `${className} profile-loading` : className
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className={linkClassName}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Render based on decoded type
|
// Render based on decoded type
|
||||||
|
// If we have a pubkey (from npub/nprofile), render profile link directly
|
||||||
|
if (pubkey) {
|
||||||
|
return renderProfileLink(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
switch (decoded.type) {
|
switch (decoded.type) {
|
||||||
case 'npub': {
|
|
||||||
const pk = decoded.data
|
|
||||||
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`/p/${nip19.npubEncode(pk)}`}
|
|
||||||
className={className}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
@{displayName}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nprofile': {
|
|
||||||
const { pubkey: pk } = decoded.data
|
|
||||||
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
|
||||||
const npub = nip19.npubEncode(pk)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`/p/${npub}`}
|
|
||||||
className={className}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
@{displayName}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'naddr': {
|
case 'naddr': {
|
||||||
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
|
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
|
||||||
// Check if it's a blog post (kind:30023)
|
// Check if it's a blog post (kind:30023)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
|
|||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
|
|||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { getProfileUrl } from '../config/nostrGateways'
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Reading progress state (naddr -> progress 0-1)
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
const npub = nip19.npubEncode(pubkey)
|
const npub = nip19.npubEncode(pubkey)
|
||||||
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showProfileMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showProfileMenu])
|
||||||
|
|
||||||
|
// Profile menu handlers
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setShowProfileMenu(!showProfileMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyProfileLink = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareProfile = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: 'Profile',
|
||||||
|
url: borisUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPortal = () => {
|
||||||
|
const portalUrl = getProfileUrl(npub)
|
||||||
|
window.open(portalUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = () => {
|
||||||
|
const nativeUrl = `nostr:${npub}`
|
||||||
|
window.location.href = nativeUrl
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'highlights':
|
case 'highlights':
|
||||||
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
pullPosition={pullPosition}
|
pullPosition={pullPosition}
|
||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
<div className="profile-header-wrapper">
|
||||||
|
<div className="profile-card-with-menu">
|
||||||
|
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||||
|
<div className="profile-card-menu-wrapper" ref={profileMenuRef}>
|
||||||
|
<CompactButton
|
||||||
|
icon={faEllipsisH}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
ariaLabel="Profile menu"
|
||||||
|
/>
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="profile-card-menu">
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleCopyProfileLink}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy Link</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleShareProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open with njump</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="me-tabs">
|
<div className="me-tabs">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="reader-hero-image">
|
<div className="reader-hero-image">
|
||||||
{cachedImage ? (
|
{cachedImage ? (
|
||||||
<img src={cachedImage} alt={title || 'Article image'} />
|
<img
|
||||||
|
src={cachedImage}
|
||||||
|
alt={title || 'Article image'}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('[reader-header] Image failed to load:', cachedImage, e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader-hero-placeholder">
|
<div className="reader-hero-placeholder">
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
|||||||
58
src/components/ReadingProgressBar.tsx
Normal file
58
src/components/ReadingProgressBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ReadingProgressBarProps {
|
||||||
|
readingProgress?: number
|
||||||
|
height?: number
|
||||||
|
marginTop?: string
|
||||||
|
marginBottom?: string
|
||||||
|
marginLeft?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadingProgressBar: React.FC<ReadingProgressBarProps> = ({
|
||||||
|
readingProgress,
|
||||||
|
height = 1,
|
||||||
|
marginTop,
|
||||||
|
marginBottom,
|
||||||
|
marginLeft,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
// Calculate progress color
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressWidth = readingProgress ? `${Math.round(readingProgress * 100)}%` : '0%'
|
||||||
|
const progressBackground = readingProgress ? progressColor : 'var(--color-border)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
height: `${height}px`,
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
borderRadius: '0.5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop,
|
||||||
|
marginBottom,
|
||||||
|
marginLeft,
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: `${height}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: progressWidth,
|
||||||
|
background: progressBackground,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease',
|
||||||
|
minHeight: `${height}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
|
||||||
|
|
||||||
const { getPubkeyFromDecodeResult } = Helpers
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
@@ -19,15 +22,27 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
|||||||
// ignore; will fallback to showing the encoded value
|
// ignore; will fallback to showing the encoded value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
|
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
|
||||||
const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded)
|
|
||||||
|
// Check if profile is in cache or eventStore
|
||||||
|
const isInCacheOrStore = useMemo(() => {
|
||||||
|
if (!pubkey) return false
|
||||||
|
return isProfileInCacheOrStore(pubkey, eventStore)
|
||||||
|
}, [pubkey, eventStore])
|
||||||
|
|
||||||
|
// Show loading if profile doesn't exist and not in cache/store
|
||||||
|
const isLoading = !profile && pubkey && !isInCacheOrStore
|
||||||
|
|
||||||
|
const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded
|
||||||
const npub = pubkey ? npubEncode(pubkey) : undefined
|
const npub = pubkey ? npubEncode(pubkey) : undefined
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
|
const className = isLoading ? 'nostr-mention profile-loading' : 'nostr-mention'
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/p/${npub}`}
|
to={`/p/${npub}`}
|
||||||
className="nostr-mention"
|
className={className}
|
||||||
>
|
>
|
||||||
@{display}
|
@{display}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import NostrMentionLink from './NostrMentionLink'
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
import { Tokens } from 'applesauce-content/helpers'
|
||||||
|
|
||||||
|
// Helper to add timestamps to error logs
|
||||||
|
const ts = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const ms = now.getMilliseconds().toString().padStart(3, '0')
|
||||||
|
return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}`
|
||||||
|
}
|
||||||
|
|
||||||
interface RichContentProps {
|
interface RichContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -18,18 +26,31 @@ const RichContent: React.FC<RichContentProps> = ({
|
|||||||
content,
|
content,
|
||||||
className = 'bookmark-content'
|
className = 'bookmark-content'
|
||||||
}) => {
|
}) => {
|
||||||
// Pattern to match:
|
try {
|
||||||
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
|
// Pattern to match:
|
||||||
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
|
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
|
||||||
// 3. http(s) URLs
|
// 2. http(s) URLs
|
||||||
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
|
const nostrPattern = Tokens.nostrLink
|
||||||
|
const urlPattern = /https?:\/\/[^\s]+/gi
|
||||||
|
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
|
||||||
|
|
||||||
const parts = content.split(pattern)
|
const parts = content.split(combinedPattern)
|
||||||
|
|
||||||
return (
|
// Helper to check if a string is a nostr identifier (without mutating regex state)
|
||||||
|
const isNostrIdentifier = (str: string): boolean => {
|
||||||
|
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
|
||||||
|
return testPattern.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
// Handle nostr: URIs
|
// Skip empty or undefined parts
|
||||||
|
if (!part) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nostr: URIs - Tokens.nostrLink matches both formats
|
||||||
if (part.startsWith('nostr:')) {
|
if (part.startsWith('nostr:')) {
|
||||||
return (
|
return (
|
||||||
<NostrMentionLink
|
<NostrMentionLink
|
||||||
@@ -39,10 +60,8 @@ const RichContent: React.FC<RichContentProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle plain nostr identifiers (add nostr: prefix)
|
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
|
||||||
if (
|
if (isNostrIdentifier(part)) {
|
||||||
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<NostrMentionLink
|
<NostrMentionLink
|
||||||
key={index}
|
key={index}
|
||||||
@@ -70,7 +89,11 @@ const RichContent: React.FC<RichContentProps> = ({
|
|||||||
return <React.Fragment key={index}>{part}</React.Fragment>
|
return <React.Fragment key={index}>{part}</React.Fragment>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err)
|
||||||
|
return <div className={className}>Error rendering content</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RichContent
|
export default RichContent
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
ttsDetectContentLanguage: true,
|
ttsDetectContentLanguage: true,
|
||||||
ttsLanguageMode: 'content',
|
ttsLanguageMode: 'content',
|
||||||
ttsDefaultSpeed: 2.1,
|
ttsDefaultSpeed: 2.1,
|
||||||
|
linkColorDark: '#38bdf8',
|
||||||
|
linkColorLight: '#3b82f6',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import IconButton from '../IconButton'
|
|||||||
import ColorPicker from '../ColorPicker'
|
import ColorPicker from '../ColorPicker'
|
||||||
import FontSelector from '../FontSelector'
|
import FontSelector from '../FontSelector'
|
||||||
import { getFontFamily } from '../../utils/fontLoader'
|
import { getFontFamily } from '../../utils/fontLoader'
|
||||||
import { hexToRgb } from '../../utils/colorHelpers'
|
import { hexToRgb, LINK_COLORS_DARK, LINK_COLORS_LIGHT } from '../../utils/colorHelpers'
|
||||||
|
|
||||||
interface ReadingDisplaySettingsProps {
|
interface ReadingDisplaySettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -15,6 +15,23 @@ interface ReadingDisplaySettingsProps {
|
|||||||
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
||||||
|
|
||||||
|
// Determine current effective theme for color palette selection
|
||||||
|
const currentTheme = settings.theme ?? 'system'
|
||||||
|
const isDark = currentTheme === 'dark' ||
|
||||||
|
(currentTheme === 'system' && (typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)').matches : true))
|
||||||
|
const linkColors = isDark ? LINK_COLORS_DARK : LINK_COLORS_LIGHT
|
||||||
|
const currentLinkColor = isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
|
|
||||||
|
const handleLinkColorChange = (color: string) => {
|
||||||
|
if (isDark) {
|
||||||
|
onUpdate({ linkColorDark: color })
|
||||||
|
} else {
|
||||||
|
onUpdate({ linkColorLight: color })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Reading & Display</h3>
|
<h3 className="section-title">Reading & Display</h3>
|
||||||
@@ -109,6 +126,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Link Color</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={currentLinkColor}
|
||||||
|
onColorChange={handleLinkColorChange}
|
||||||
|
colors={linkColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label className="setting-label">Font Size</label>
|
<label className="setting-label">Font Size</label>
|
||||||
<div className="setting-control">
|
<div className="setting-control">
|
||||||
@@ -179,14 +207,16 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
fontFamily: previewFontFamily,
|
fontFamily: previewFontFamily,
|
||||||
fontSize: `${settings.fontSize || 21}px`,
|
fontSize: `${settings.fontSize || 21}px`,
|
||||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||||
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
'--paragraph-alignment': settings.paragraphAlignment || 'justify',
|
||||||
|
'--color-link': isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h3>The Quick Brown Fox</h3>
|
<h3>The Quick Brown Fox</h3>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
<p>Totam rem aperiam, eaque ipsa quae ab illo <a href="/a/naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqq8ky6t5vdhkjm3dd9ej6arfd4jszh5rdq">inventore veritatis</a> et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useEventModel } from 'applesauce-react/hooks'
|
|||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { preloadImage } from '../hooks/useImageCache'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
@@ -28,14 +30,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
|
|
||||||
const getUserDisplayName = () => {
|
const getUserDisplayName = () => {
|
||||||
if (!activeAccount) return 'Unknown User'
|
if (!activeAccount) return 'Unknown User'
|
||||||
if (profile?.name) return profile.name
|
return getProfileDisplayName(profile, activeAccount.pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
if (profile?.nip05) return profile.nip05
|
|
||||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
|
|
||||||
|
// Preload profile image for offline access
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileImage) {
|
||||||
|
preloadImage(profileImage)
|
||||||
|
}
|
||||||
|
}, [profileImage])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -65,70 +71,70 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-header-bar">
|
<div className="sidebar-header-bar">
|
||||||
{activeAccount && (
|
<div className="sidebar-header-left">
|
||||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
{activeAccount && (
|
||||||
<button
|
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||||
className="profile-avatar-button"
|
<button
|
||||||
title={getUserDisplayName()}
|
className="profile-avatar-button"
|
||||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
title={getUserDisplayName()}
|
||||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||||
>
|
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||||
{profileImage ? (
|
>
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
{profileImage ? (
|
||||||
) : (
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="profile-dropdown-menu">
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span>My Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
|
<span>My Bookmarks</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
|
<span>My Reads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLink} />
|
||||||
|
<span>My Links</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span>My Writings</span>
|
||||||
|
</button>
|
||||||
|
<div className="profile-menu-separator"></div>
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(onLogout)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
{showProfileMenu && (
|
)}
|
||||||
<div className="profile-dropdown-menu">
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
|
||||||
<span>My Highlights</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
|
||||||
<span>My Bookmarks</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
|
||||||
<span>My Reads</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faLink} />
|
|
||||||
<span>My Links</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
|
||||||
<span>My Writings</span>
|
|
||||||
</button>
|
|
||||||
<div className="profile-menu-separator"></div>
|
|
||||||
<button
|
|
||||||
className="profile-menu-item"
|
|
||||||
onClick={() => handleMenuItemClick(onLogout)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
|
||||||
<span>Logout</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="sidebar-header-right">
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faHome}
|
icon={faHome}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -141,6 +147,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Home"
|
ariaLabel="Home"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-header-right">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faPersonHiking}
|
icon={faPersonHiking}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Models } from 'applesauce-core'
|
|||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getProfileDisplayName } from '../utils/nostrUriResolver'
|
||||||
|
|
||||||
interface SupportProps {
|
interface SupportProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -182,7 +183,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||||
|
|
||||||
const picture = profile?.picture
|
const picture = profile?.picture
|
||||||
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
const name = getProfileDisplayName(profile, supporter.pubkey)
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const npub = nip19.npubEncode(supporter.pubkey)
|
const npub = nip19.npubEncode(supporter.pubkey)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BookmarkList } from './BookmarkList'
|
import { BookmarkList } from './BookmarkList'
|
||||||
import ContentPanel from './ContentPanel'
|
import ContentPanel from './ContentPanel'
|
||||||
|
import VideoView from './VideoView'
|
||||||
import { HighlightsPanel } from './HighlightsPanel'
|
import { HighlightsPanel } from './HighlightsPanel'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { HighlightButtonRef } from './HighlightButton'
|
import { HighlightButtonRef } from './HighlightButton'
|
||||||
import { BookmarkReference } from '../utils/contentLoader'
|
import { BookmarkReference } from '../utils/contentLoader'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
@@ -373,42 +375,68 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
{props.support}
|
{props.support}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (() => {
|
||||||
<ContentPanel
|
// Determine if this is a video URL
|
||||||
loading={props.readerLoading}
|
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
title={props.readerContent?.title}
|
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
|
||||||
html={props.readerContent?.html}
|
|
||||||
markdown={props.readerContent?.markdown}
|
if (isExternalVideo) {
|
||||||
image={props.readerContent?.image}
|
return (
|
||||||
summary={props.readerContent?.summary}
|
<VideoView
|
||||||
published={props.readerContent?.published}
|
videoUrl={props.selectedUrl!}
|
||||||
selectedUrl={props.selectedUrl}
|
title={props.readerContent?.title}
|
||||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
image={props.readerContent?.image}
|
||||||
? props.highlights // article-specific highlights only
|
summary={props.readerContent?.summary}
|
||||||
: props.classifiedHighlights}
|
published={props.readerContent?.published}
|
||||||
showHighlights={props.showHighlights}
|
settings={props.settings}
|
||||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
relayPool={props.relayPool}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
activeAccount={props.activeAccount}
|
||||||
onHighlightClick={props.onHighlightClick}
|
onOpenHighlights={() => {
|
||||||
selectedHighlightId={props.selectedHighlightId}
|
if (props.isHighlightsCollapsed) {
|
||||||
highlightVisibility={props.highlightVisibility}
|
props.onToggleHighlightsPanel()
|
||||||
onTextSelection={props.onTextSelection}
|
}
|
||||||
onClearSelection={props.onClearSelection}
|
}}
|
||||||
currentUserPubkey={props.currentUserPubkey}
|
/>
|
||||||
followedPubkeys={props.followedPubkeys}
|
)
|
||||||
settings={props.settings}
|
}
|
||||||
relayPool={props.relayPool}
|
|
||||||
activeAccount={props.activeAccount}
|
return (
|
||||||
currentArticle={props.currentArticle}
|
<ContentPanel
|
||||||
isSidebarCollapsed={props.isCollapsed}
|
loading={props.readerLoading}
|
||||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
title={props.readerContent?.title}
|
||||||
onOpenHighlights={() => {
|
html={props.readerContent?.html}
|
||||||
if (props.isHighlightsCollapsed) {
|
markdown={props.readerContent?.markdown}
|
||||||
props.onToggleHighlightsPanel()
|
image={props.readerContent?.image}
|
||||||
}
|
summary={props.readerContent?.summary}
|
||||||
}}
|
published={props.readerContent?.published}
|
||||||
/>
|
selectedUrl={props.selectedUrl}
|
||||||
)}
|
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
|
? props.highlights // article-specific highlights only
|
||||||
|
: props.classifiedHighlights}
|
||||||
|
showHighlights={props.showHighlights}
|
||||||
|
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||||
|
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||||
|
onHighlightClick={props.onHighlightClick}
|
||||||
|
selectedHighlightId={props.selectedHighlightId}
|
||||||
|
highlightVisibility={props.highlightVisibility}
|
||||||
|
onTextSelection={props.onTextSelection}
|
||||||
|
onClearSelection={props.onClearSelection}
|
||||||
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
|
followedPubkeys={props.followedPubkeys}
|
||||||
|
settings={props.settings}
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
activeAccount={props.activeAccount}
|
||||||
|
currentArticle={props.currentArticle}
|
||||||
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
|
onOpenHighlights={() => {
|
||||||
|
if (props.isHighlightsCollapsed) {
|
||||||
|
props.onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={highlightsRef}
|
ref={highlightsRef}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, forwardRef } from 'react'
|
import { useMemo, forwardRef } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
import ReactPlayer from 'react-player'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
|
||||||
@@ -6,8 +6,6 @@ interface VideoEmbedProcessorProps {
|
|||||||
html: string
|
html: string
|
||||||
renderVideoLinksAsEmbeds: boolean
|
renderVideoLinksAsEmbeds: boolean
|
||||||
className?: string
|
className?: string
|
||||||
onMouseUp?: (e: React.MouseEvent) => void
|
|
||||||
onTouchEnd?: (e: React.TouchEvent) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,9 +15,7 @@ interface VideoEmbedProcessorProps {
|
|||||||
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||||
html,
|
html,
|
||||||
renderVideoLinksAsEmbeds,
|
renderVideoLinksAsEmbeds,
|
||||||
className,
|
className
|
||||||
onMouseUp,
|
|
||||||
onTouchEnd
|
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||||
const { processedHtml, videoUrls } = useMemo(() => {
|
const { processedHtml, videoUrls } = useMemo(() => {
|
||||||
@@ -109,8 +105,6 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={className}
|
className={className}
|
||||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +113,7 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
|
|||||||
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
<div ref={ref} className={className}>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||||
if (videoMatch) {
|
if (videoMatch) {
|
||||||
|
|||||||
320
src/components/VideoView.tsx
Normal file
320
src/components/VideoView.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
|
import { getYouTubeThumbnail } from '../utils/imagePreview'
|
||||||
|
|
||||||
|
// Helper function to get Vimeo thumbnail
|
||||||
|
const getVimeoThumbnail = (url: string): string | null => {
|
||||||
|
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||||
|
if (!vimeoMatch) return null
|
||||||
|
|
||||||
|
const videoId = vimeoMatch[1]
|
||||||
|
return `https://vumbnail.com/${videoId}.jpg`
|
||||||
|
}
|
||||||
|
import {
|
||||||
|
createWebsiteReaction,
|
||||||
|
hasMarkedWebsiteAsRead
|
||||||
|
} from '../services/reactionService'
|
||||||
|
import { unarchiveWebsite } from '../services/unarchiveService'
|
||||||
|
import ReaderHeader from './ReaderHeader'
|
||||||
|
|
||||||
|
interface VideoViewProps {
|
||||||
|
videoUrl: string
|
||||||
|
title?: string
|
||||||
|
image?: string
|
||||||
|
summary?: string
|
||||||
|
published?: number
|
||||||
|
settings?: UserSettings
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
onOpenHighlights?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoView: React.FC<VideoViewProps> = ({
|
||||||
|
videoUrl,
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
settings,
|
||||||
|
relayPool,
|
||||||
|
activeAccount,
|
||||||
|
onOpenHighlights
|
||||||
|
}) => {
|
||||||
|
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
|
||||||
|
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
|
||||||
|
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||||
|
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||||
|
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||||
|
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||||
|
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||||
|
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Load YouTube metadata when applicable
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!videoUrl) return setYtMeta(null)
|
||||||
|
const id = extractYouTubeId(videoUrl)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [videoUrl])
|
||||||
|
|
||||||
|
// Check if video is marked as watched
|
||||||
|
useEffect(() => {
|
||||||
|
const checkWatchedStatus = async () => {
|
||||||
|
if (!activeAccount || !videoUrl) return
|
||||||
|
|
||||||
|
setIsCheckingWatchedStatus(true)
|
||||||
|
try {
|
||||||
|
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
|
||||||
|
setIsMarkedAsWatched(isWatched)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check watched status:', error)
|
||||||
|
} finally {
|
||||||
|
setIsCheckingWatchedStatus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkWatchedStatus()
|
||||||
|
}, [activeAccount, videoUrl, relayPool])
|
||||||
|
|
||||||
|
// Handle click outside to close menu
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showVideoMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showVideoMenu])
|
||||||
|
|
||||||
|
// Check menu position for upward opening
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
|
||||||
|
if (!menuRef.current) return
|
||||||
|
|
||||||
|
const rect = menuRef.current.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
|
||||||
|
// Open upward if there's more space above and less space below
|
||||||
|
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showVideoMenu) {
|
||||||
|
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||||
|
}
|
||||||
|
}, [showVideoMenu])
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAsWatched = async () => {
|
||||||
|
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
|
||||||
|
|
||||||
|
setIsCheckingWatchedStatus(true)
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isMarkedAsWatched) {
|
||||||
|
// Unmark as watched
|
||||||
|
if (relayPool) {
|
||||||
|
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
|
||||||
|
}
|
||||||
|
setIsMarkedAsWatched(false)
|
||||||
|
} else {
|
||||||
|
// Mark as watched
|
||||||
|
if (relayPool) {
|
||||||
|
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
|
||||||
|
}
|
||||||
|
setIsMarkedAsWatched(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to update watched status:', error)
|
||||||
|
} finally {
|
||||||
|
setIsCheckingWatchedStatus(false)
|
||||||
|
setTimeout(() => setShowCheckAnimation(false), 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||||
|
|
||||||
|
const handleOpenVideoExternal = () => {
|
||||||
|
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenVideoNative = () => {
|
||||||
|
const native = buildNativeVideoUrl(videoUrl)
|
||||||
|
if (native) {
|
||||||
|
window.location.href = native
|
||||||
|
} else {
|
||||||
|
window.location.href = videoUrl
|
||||||
|
}
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(videoUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: ytMeta?.title || title || 'Video',
|
||||||
|
url: videoUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(videoUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayTitle = ytMeta?.title || title
|
||||||
|
const displaySummary = ytMeta?.description || summary
|
||||||
|
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
|
||||||
|
|
||||||
|
// Get video thumbnail for cover image
|
||||||
|
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
|
||||||
|
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
|
||||||
|
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
|
||||||
|
const displayImage = videoThumbnail || image
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReaderHeader
|
||||||
|
title={displayTitle}
|
||||||
|
image={displayImage}
|
||||||
|
summary={displaySummary}
|
||||||
|
published={published}
|
||||||
|
readingTimeText={durationText}
|
||||||
|
hasHighlights={false}
|
||||||
|
highlightCount={0}
|
||||||
|
settings={settings}
|
||||||
|
highlights={[]}
|
||||||
|
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||||
|
onHighlightCountClick={onOpenHighlights}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="reader-video">
|
||||||
|
<ReactPlayer
|
||||||
|
url={videoUrl}
|
||||||
|
controls
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16/9'
|
||||||
|
}}
|
||||||
|
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displaySummary && (
|
||||||
|
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||||
|
{displaySummary}
|
||||||
|
</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 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 ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||||
|
<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 ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
|
onClick={handleMarkAsWatched}
|
||||||
|
disabled={isCheckingWatchedStatus}
|
||||||
|
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||||
|
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faCheckCircle}
|
||||||
|
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
|
||||||
|
/>
|
||||||
|
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoView
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Nostr gateway URLs for viewing events and profiles on the web
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
export const NOSTR_GATEWAY = 'https://njump.to' as const
|
||||||
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +24,7 @@ export function getEventUrl(nevent: string): string {
|
|||||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||||
*/
|
*/
|
||||||
export function getNostrUrl(identifier: string): string {
|
export function getNostrUrl(identifier: string): string {
|
||||||
// nostr.at uses simple /{identifier} format for all types
|
// njump.to uses simple /{identifier} format for all types
|
||||||
return `${NOSTR_GATEWAY}/${identifier}`
|
return `${NOSTR_GATEWAY}/${identifier}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,101 @@
|
|||||||
|
import { normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized relay configuration
|
* Centralized relay configuration
|
||||||
* Single set of relays used throughout the application
|
* Single set of relays used throughout the application
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// All relays including local relays
|
export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
|
||||||
export const RELAYS = [
|
|
||||||
'ws://localhost:10547',
|
export interface RelayConfig {
|
||||||
'ws://localhost:4869',
|
url: string
|
||||||
'wss://relay.nsec.app',
|
roles: RelayRole[]
|
||||||
'wss://relay.damus.io',
|
}
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
/**
|
||||||
'wss://wot.dergigi.com',
|
* Central relay registry with role annotations
|
||||||
'wss://relay.snort.social',
|
*/
|
||||||
'wss://nostr-pub.wellorder.net',
|
const RELAY_CONFIGS: RelayConfig[] = [
|
||||||
'wss://purplepag.es',
|
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
|
||||||
'wss://relay.primal.net',
|
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
{ url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
|
||||||
|
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://nos.lol', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://wot.dergigi.com', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.snort.social', roles: ['default'] },
|
||||||
|
{ url: 'wss://nostr-pub.wellorder.net', roles: ['default'] },
|
||||||
|
{ url: 'wss://purplepag.es', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.primal.net', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all local cache relays (localhost relays)
|
||||||
|
*/
|
||||||
|
export function getLocalRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('local-cache'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all default relays (main public relays)
|
||||||
|
*/
|
||||||
|
export function getDefaultRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('default'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fallback content relays (last resort public relays for content fetching)
|
||||||
|
* These are reliable public relays that should be tried when other methods fail
|
||||||
|
*/
|
||||||
|
export function getFallbackContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('fallback'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays suitable for content fetching (excludes non-content relays like auth/signer relays)
|
||||||
|
*/
|
||||||
|
export function getContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => !config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays that should NOT be used as content hints
|
||||||
|
*/
|
||||||
|
export function getNonContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All relays including local relays (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const RELAYS = [
|
||||||
|
...getLocalRelays(),
|
||||||
|
...getDefaultRelays(),
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays that should NOT be used as content hints (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const NON_CONTENT_RELAYS = getNonContentRelays()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a relay URL is suitable for use as a content hint
|
||||||
|
* Returns true for relays that are reasonable for posts/highlights
|
||||||
|
*/
|
||||||
|
export function isContentRelay(url: string): boolean {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
|
||||||
|
return !nonContentRelays.includes(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import type { IEventStore } from 'applesauce-core'
|
import type { IEventStore } from 'applesauce-core'
|
||||||
@@ -6,12 +6,14 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { queryEvents } from '../services/dataFetch'
|
import { queryEvents } from '../services/dataFetch'
|
||||||
import { fetchArticleByNaddr } from '../services/articleService'
|
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
|
||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
|
import { preloadImage } from './useImageCache'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
interface PreviewData {
|
interface PreviewData {
|
||||||
title: string
|
title: string
|
||||||
@@ -20,6 +22,12 @@ interface PreviewData {
|
|||||||
published?: number
|
published?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NavigationState {
|
||||||
|
previewData?: PreviewData
|
||||||
|
articleCoordinate?: string
|
||||||
|
eventId?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface UseArticleLoaderProps {
|
interface UseArticleLoaderProps {
|
||||||
naddr: string | undefined
|
naddr: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -61,33 +69,417 @@ export function useArticleLoader({
|
|||||||
// Track in-flight request to prevent stale updates from previous naddr
|
// Track in-flight request to prevent stale updates from previous naddr
|
||||||
const currentRequestIdRef = useRef(0)
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
// Extract preview data from navigation state (from blog post cards)
|
// Extract navigation state (from blog post cards)
|
||||||
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
const navState = (location.state as NavigationState | null) || {}
|
||||||
|
const previewData = navState.previewData
|
||||||
|
const navArticleCoordinate = navState.articleCoordinate
|
||||||
|
const navEventId = navState.eventId
|
||||||
|
|
||||||
|
// Track the current article title for document title
|
||||||
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
|
useDocumentTitle({ title: currentTitle })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !naddr) return
|
// First check: naddr is required
|
||||||
|
if (!naddr) {
|
||||||
|
setReaderContent(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear readerContent immediately to prevent showing stale content from previous article
|
||||||
|
// This ensures images from previous articles don't flash briefly
|
||||||
|
setReaderContent(undefined)
|
||||||
|
|
||||||
|
// FIRST: Check navigation state for article coordinate/eventId (from Explore)
|
||||||
|
// This allows immediate hydration when coming from Explore without refetching
|
||||||
|
let foundInNavState = false
|
||||||
|
if (eventStore && (navArticleCoordinate || navEventId)) {
|
||||||
|
try {
|
||||||
|
let storedEvent: NostrEvent | undefined
|
||||||
|
|
||||||
|
// Try coordinate first (most reliable for replaceable events)
|
||||||
|
if (navArticleCoordinate) {
|
||||||
|
storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to eventId if coordinate lookup failed
|
||||||
|
if (!storedEvent && navEventId) {
|
||||||
|
// Note: eventStore.getEvent might not support eventId lookup directly
|
||||||
|
// We'll decode naddr to get coordinate as fallback
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore decode errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedEvent) {
|
||||||
|
foundInNavState = true
|
||||||
|
const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
|
const image = Helpers.getArticleImage(storedEvent) || previewData?.image
|
||||||
|
const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary
|
||||||
|
const published = Helpers.getArticlePublished(storedEvent) || previewData?.published
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: storedEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
|
setCurrentArticle?.(storedEvent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Preload image if available
|
||||||
|
if (image) {
|
||||||
|
preloadImage(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights in background if relayPool is available
|
||||||
|
if (relayPool) {
|
||||||
|
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = storedEvent.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setHighlights((prev: Highlight[]) => {
|
||||||
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
|
const next = [highlight, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background query to check for newer replaceable version
|
||||||
|
// but don't block UI - we already have content
|
||||||
|
if (relayPool) {
|
||||||
|
const backgroundRequestId = ++currentRequestIdRef.current
|
||||||
|
const originalCreatedAt = storedEvent.created_at
|
||||||
|
|
||||||
|
// Fire and forget background fetch
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') return
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return
|
||||||
|
|
||||||
|
// Store in event store
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore?.add?.(evt as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Ignore store errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if this is a newer version than what we loaded
|
||||||
|
if (evt.created_at > originalCreatedAt) {
|
||||||
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(evt)
|
||||||
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(evt.id)
|
||||||
|
setCurrentArticle?.(evt)
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: evt.pubkey,
|
||||||
|
event: evt
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Silently ignore background fetch errors - we already have content
|
||||||
|
console.warn('[article-loader] Background fetch failed:', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have content from navigation state
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If navigation state lookup fails, fall through to cache/EventStore
|
||||||
|
console.warn('[article-loader] Navigation state lookup failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronously check cache sources BEFORE checking relayPool
|
||||||
|
// This prevents showing loading skeletons when content is immediately available
|
||||||
|
// and fixes the race condition where relayPool isn't ready yet
|
||||||
|
let foundInCache = false
|
||||||
|
try {
|
||||||
|
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||||
|
const cachedArticle = getFromCache(naddr)
|
||||||
|
if (cachedArticle) {
|
||||||
|
foundInCache = true
|
||||||
|
const title = cachedArticle.title || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: cachedArticle.markdown,
|
||||||
|
image: cachedArticle.image,
|
||||||
|
summary: cachedArticle.summary,
|
||||||
|
published: cachedArticle.published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(cachedArticle.event.id)
|
||||||
|
setCurrentArticle?.(cachedArticle.event)
|
||||||
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Preload image if available to ensure it's cached by Service Worker
|
||||||
|
// This ensures images are available when offline
|
||||||
|
if (cachedArticle.image) {
|
||||||
|
preloadImage(cachedArticle.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in EventStore for future lookups
|
||||||
|
if (eventStore) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore.add?.(cachedArticle.event as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore store errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights in background (don't block UI)
|
||||||
|
// Only fetch highlights if relayPool is available
|
||||||
|
if (mountedRef.current && relayPool) {
|
||||||
|
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||||
|
const eventId = cachedArticle.event.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setHighlights((prev: Highlight[]) => {
|
||||||
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
|
const next = [highlight, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have cached content, no need to query relays
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If cache check fails, fall through to async loading
|
||||||
|
console.warn('[article-loader] Cache check failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check EventStore synchronously (also doesn't need relayPool)
|
||||||
|
let foundInEventStore = false
|
||||||
|
if (eventStore && !foundInCache && !foundInNavState) {
|
||||||
|
try {
|
||||||
|
// Decode naddr to get the coordinate
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||||
|
if (storedEvent) {
|
||||||
|
foundInEventStore = true
|
||||||
|
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
|
const image = Helpers.getArticleImage(storedEvent)
|
||||||
|
const summary = Helpers.getArticleSummary(storedEvent)
|
||||||
|
const published = Helpers.getArticlePublished(storedEvent)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: storedEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
|
setCurrentArticle?.(storedEvent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Fetch highlights in background if relayPool is available
|
||||||
|
if (relayPool) {
|
||||||
|
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = storedEvent.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setHighlights((prev: Highlight[]) => {
|
||||||
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
|
const next = [highlight, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have EventStore content, no need to query relays yet
|
||||||
|
// But we might want to fetch from relays in background if relayPool becomes available
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore store errors, fall through to relay query
|
||||||
|
console.warn('[article-loader] EventStore check failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return early if we have no content AND no relayPool to fetch from
|
||||||
|
if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) {
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have relayPool, proceed with async loading
|
||||||
|
if (!relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const loadArticle = async () => {
|
const loadArticle = async () => {
|
||||||
const requestId = ++currentRequestIdRef.current
|
const requestId = ++currentRequestIdRef.current
|
||||||
if (!mountedRef.current) return
|
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
|
|
||||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
// Don't clear highlights yet - let the smart filtering logic handle it
|
||||||
|
// when we know the article coordinate
|
||||||
|
setHighlightsLoading(false) // Don't show loading yet
|
||||||
|
|
||||||
|
// Note: Cache and EventStore were already checked synchronously above
|
||||||
|
// This async function only runs if we need to fetch from relays
|
||||||
|
|
||||||
|
// At this point, we've checked EventStore and cache - neither had content
|
||||||
|
// Only show loading skeleton if we also don't have preview data
|
||||||
if (previewData) {
|
if (previewData) {
|
||||||
|
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||||
|
setCurrentTitle(previewData.title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: previewData.title,
|
title: previewData.title,
|
||||||
markdown: '', // Will be loaded from store or relay
|
markdown: '', // Will be loaded from relay
|
||||||
image: previewData.image,
|
image: previewData.image,
|
||||||
summary: previewData.summary,
|
summary: previewData.summary,
|
||||||
published: previewData.published,
|
published: previewData.published,
|
||||||
url: `nostr:${naddr}`
|
url: `nostr:${naddr}`
|
||||||
})
|
})
|
||||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||||
|
|
||||||
|
// Don't preload image here - it should already be cached from BlogPostCard
|
||||||
|
// Preloading again would be redundant and could cause unnecessary network requests
|
||||||
} else {
|
} else {
|
||||||
|
// No cache, no EventStore, no preview data - need to load from relays
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
}
|
}
|
||||||
@@ -108,43 +500,15 @@ export function useArticleLoader({
|
|||||||
let firstEmitted = false
|
let firstEmitted = false
|
||||||
let latestEvent: NostrEvent | null = null
|
let latestEvent: NostrEvent | null = null
|
||||||
|
|
||||||
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
|
||||||
if (eventStore) {
|
|
||||||
try {
|
|
||||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
|
||||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
|
||||||
if (storedEvent) {
|
|
||||||
latestEvent = storedEvent as NostrEvent
|
|
||||||
firstEmitted = true
|
|
||||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
|
||||||
const image = Helpers.getArticleImage(storedEvent)
|
|
||||||
const summary = Helpers.getArticleSummary(storedEvent)
|
|
||||||
const published = Helpers.getArticlePublished(storedEvent)
|
|
||||||
setReaderContent({
|
|
||||||
title,
|
|
||||||
markdown: storedEvent.content,
|
|
||||||
image,
|
|
||||||
summary,
|
|
||||||
published,
|
|
||||||
url: `nostr:${naddr}`
|
|
||||||
})
|
|
||||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
|
||||||
setCurrentArticleCoordinate(articleCoordinate)
|
|
||||||
setCurrentArticleEventId(storedEvent.id)
|
|
||||||
setCurrentArticle?.(storedEvent)
|
|
||||||
setReaderLoading(false)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore store errors, fall through to relay query
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||||
const events = await queryEvents(relayPool, filter, {
|
const events = await queryEvents(relayPool, filter, {
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) {
|
||||||
if (currentRequestIdRef.current !== requestId) return
|
return
|
||||||
|
}
|
||||||
|
if (currentRequestIdRef.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Store in event store for future local reads
|
// Store in event store for future local reads
|
||||||
try {
|
try {
|
||||||
@@ -166,6 +530,8 @@ export function useArticleLoader({
|
|||||||
const image = Helpers.getArticleImage(evt)
|
const image = Helpers.getArticleImage(evt)
|
||||||
const summary = Helpers.getArticleSummary(evt)
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
const published = Helpers.getArticlePublished(evt)
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title,
|
title,
|
||||||
markdown: evt.content,
|
markdown: evt.content,
|
||||||
@@ -180,11 +546,31 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleEventId(evt.id)
|
setCurrentArticleEventId(evt.id)
|
||||||
setCurrentArticle?.(evt)
|
setCurrentArticle?.(evt)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
|
// Save to cache immediately when we get the first event
|
||||||
|
// Don't wait for queryEvents to complete in case it hangs
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: evt.pubkey,
|
||||||
|
event: evt
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
|
||||||
|
// Preload image to ensure it's cached by Service Worker
|
||||||
|
if (image) {
|
||||||
|
preloadImage(image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize with newest version if it's newer than what we first rendered
|
// Finalize with newest version if it's newer than what we first rendered
|
||||||
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||||
@@ -193,6 +579,8 @@ export function useArticleLoader({
|
|||||||
const image = Helpers.getArticleImage(finalEvent)
|
const image = Helpers.getArticleImage(finalEvent)
|
||||||
const summary = Helpers.getArticleSummary(finalEvent)
|
const summary = Helpers.getArticleSummary(finalEvent)
|
||||||
const published = Helpers.getArticlePublished(finalEvent)
|
const published = Helpers.getArticlePublished(finalEvent)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title,
|
title,
|
||||||
markdown: finalEvent.content,
|
markdown: finalEvent.content,
|
||||||
@@ -207,10 +595,28 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleCoordinate(articleCoordinate)
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
setCurrentArticleEventId(finalEvent.id)
|
setCurrentArticleEventId(finalEvent.id)
|
||||||
setCurrentArticle?.(finalEvent)
|
setCurrentArticle?.(finalEvent)
|
||||||
|
|
||||||
|
// Save to cache for future loads (if we haven't already saved from first event)
|
||||||
|
// Only save if this is a different/newer event than what we first rendered
|
||||||
|
// Note: We already saved from first event, so only save if this is different
|
||||||
|
if (!firstEmitted) {
|
||||||
|
// First event wasn't emitted, so save now
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: finalEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: finalEvent.pubkey,
|
||||||
|
event: finalEvent
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// As a last resort, fall back to the legacy helper (which includes cache)
|
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||||
|
setCurrentTitle(article.title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
markdown: article.markdown,
|
markdown: article.markdown,
|
||||||
@@ -237,7 +643,13 @@ export function useArticleLoader({
|
|||||||
|
|
||||||
if (coord && eventId) {
|
if (coord && eventId) {
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
setHighlights([])
|
// Clear highlights that don't belong to this article coordinate
|
||||||
|
setHighlights((prev) => {
|
||||||
|
return prev.filter(h => {
|
||||||
|
// Keep highlights that match this article coordinate or event ID
|
||||||
|
return h.eventReference === coord || h.eventReference === eventId
|
||||||
|
})
|
||||||
|
})
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
coord,
|
coord,
|
||||||
@@ -251,7 +663,9 @@ export function useArticleLoader({
|
|||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
settingsRef.current
|
settingsRef.current,
|
||||||
|
false, // force
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// No article event to fetch highlights for - clear and don't show loading
|
// No article event to fetch highlights for - clear and don't show loading
|
||||||
@@ -283,19 +697,13 @@ export function useArticleLoader({
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
}
|
}
|
||||||
|
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||||
|
// This fixes the race condition where articles don't load on direct navigation
|
||||||
|
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
|
||||||
eventStore,
|
|
||||||
previewData,
|
previewData,
|
||||||
setSelectedUrl,
|
relayPool
|
||||||
setReaderContent,
|
|
||||||
setReaderLoading,
|
|
||||||
setIsCollapsed,
|
|
||||||
setHighlights,
|
|
||||||
setHighlightsLoading,
|
|
||||||
setCurrentArticleCoordinate,
|
|
||||||
setCurrentArticleEventId,
|
|
||||||
setCurrentArticle
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/hooks/useDocumentTitle.ts
Normal file
35
src/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
|
||||||
|
|
||||||
|
interface UseDocumentTitleProps {
|
||||||
|
title?: string
|
||||||
|
fallback?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
|
||||||
|
const originalTitleRef = useRef<string>(document.title)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Store the original title on first mount
|
||||||
|
if (originalTitleRef.current === DEFAULT_TITLE) {
|
||||||
|
originalTitleRef.current = document.title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new title if provided, otherwise use fallback or default
|
||||||
|
const newTitle = title || fallback || DEFAULT_TITLE
|
||||||
|
document.title = newTitle
|
||||||
|
|
||||||
|
// Cleanup: restore original title when component unmounts
|
||||||
|
return () => {
|
||||||
|
document.title = originalTitleRef.current
|
||||||
|
}
|
||||||
|
}, [title, fallback])
|
||||||
|
|
||||||
|
// Return a function to manually reset to default
|
||||||
|
const resetTitle = () => {
|
||||||
|
document.title = DEFAULT_TITLE
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resetTitle }
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback, useState } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { eventManager } from '../services/eventManager'
|
import { eventManager } from '../services/eventManager'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
|
||||||
|
import { extractProfileDisplayName } from '../utils/profileUtils'
|
||||||
|
|
||||||
interface UseEventLoaderProps {
|
interface UseEventLoaderProps {
|
||||||
eventId?: string
|
eventId?: string
|
||||||
@@ -25,6 +28,9 @@ export function useEventLoader({
|
|||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
setIsCollapsed
|
setIsCollapsed
|
||||||
}: UseEventLoaderProps) {
|
}: UseEventLoaderProps) {
|
||||||
|
// Track the current event title for document title
|
||||||
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
|
useDocumentTitle({ title: currentTitle })
|
||||||
const displayEvent = useCallback((event: NostrEvent) => {
|
const displayEvent = useCallback((event: NostrEvent) => {
|
||||||
// Escape HTML in content and convert newlines to breaks for plain text display
|
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||||
const escapedContent = event.content
|
const escapedContent = event.content
|
||||||
@@ -36,7 +42,7 @@ export function useEventLoader({
|
|||||||
// Initial title
|
// Initial title
|
||||||
let title = `Note (${event.kind})`
|
let title = `Note (${event.kind})`
|
||||||
if (event.kind === 1) {
|
if (event.kind === 1) {
|
||||||
title = `Note by @${event.pubkey.slice(0, 8)}...`
|
title = `Note by ${getNpubFallbackDisplay(event.pubkey)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit immediately
|
// Emit immediately
|
||||||
@@ -46,6 +52,7 @@ export function useEventLoader({
|
|||||||
title,
|
title,
|
||||||
published: event.created_at
|
published: event.created_at
|
||||||
}
|
}
|
||||||
|
setCurrentTitle(title)
|
||||||
setReaderContent(baseContent)
|
setReaderContent(baseContent)
|
||||||
|
|
||||||
// Background: resolve author profile for kind:1 and update title
|
// Background: resolve author profile for kind:1 and update title
|
||||||
@@ -57,11 +64,12 @@ export function useEventLoader({
|
|||||||
// First, try to get from event store cache
|
// First, try to get from event store cache
|
||||||
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
||||||
if (storedProfile) {
|
if (storedProfile) {
|
||||||
try {
|
const displayName = extractProfileDisplayName(storedProfile as NostrEvent)
|
||||||
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
// Remove @ prefix if present (we'll add it when displaying)
|
||||||
} catch {
|
resolved = displayName
|
||||||
// ignore parse errors
|
} else if (displayName) {
|
||||||
|
resolved = displayName.substring(1) // Remove @ prefix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,17 +78,19 @@ export function useEventLoader({
|
|||||||
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
||||||
if (profiles && profiles.length > 0) {
|
if (profiles && profiles.length > 0) {
|
||||||
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
try {
|
const displayName = extractProfileDisplayName(latest)
|
||||||
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
resolved = displayName
|
||||||
} catch {
|
} else if (displayName) {
|
||||||
// ignore parse errors
|
resolved = displayName.substring(1) // Remove @ prefix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
const updatedTitle = `Note by @${resolved}`
|
||||||
|
setCurrentTitle(updatedTitle)
|
||||||
|
setReaderContent({ ...baseContent, title: updatedTitle })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore profile failures; keep fallback title
|
// ignore profile failures; keep fallback title
|
||||||
@@ -119,6 +129,7 @@ export function useEventLoader({
|
|||||||
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||||
title: 'Error'
|
title: 'Error'
|
||||||
}
|
}
|
||||||
|
setCurrentTitle('Error')
|
||||||
setReaderContent(errorContent)
|
setReaderContent(errorContent)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react'
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { useStoreTimeline } from './useStoreTimeline'
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
// Helper to extract filename from URL
|
// Helper to extract filename from URL
|
||||||
function getFilenameFromUrl(url: string): string {
|
function getFilenameFromUrl(url: string): string {
|
||||||
@@ -52,6 +53,10 @@ export function useExternalUrlLoader({
|
|||||||
// Track in-flight request to prevent stale updates when switching quickly
|
// Track in-flight request to prevent stale updates when switching quickly
|
||||||
const currentRequestIdRef = useRef(0)
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
// Track the current content title for document title
|
||||||
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
|
useDocumentTitle({ title: currentTitle })
|
||||||
|
|
||||||
// Load cached URL-specific highlights from event store
|
// Load cached URL-specific highlights from event store
|
||||||
const urlFilter = useMemo(() => {
|
const urlFilter = useMemo(() => {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
@@ -88,6 +93,7 @@ export function useExternalUrlLoader({
|
|||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
if (currentRequestIdRef.current !== requestId) return
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
|
setCurrentTitle(content.title)
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
@@ -159,19 +165,12 @@ export function useExternalUrlLoader({
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
}
|
}
|
||||||
|
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||||
|
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
url,
|
url,
|
||||||
relayPool,
|
cachedUrlHighlights
|
||||||
eventStore,
|
|
||||||
cachedUrlHighlights,
|
|
||||||
setReaderContent,
|
|
||||||
setReaderLoading,
|
|
||||||
setIsCollapsed,
|
|
||||||
setSelectedUrl,
|
|
||||||
setHighlights,
|
|
||||||
setCurrentArticleCoordinate,
|
|
||||||
setCurrentArticleEventId,
|
|
||||||
setHighlightsLoading
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// Keep UI highlights synced with cached store updates without reloading content
|
// Keep UI highlights synced with cached store updates without reloading content
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||||
import { normalizeUrl } from '../utils/urlHelpers'
|
import { normalizeUrl } from '../utils/urlHelpers'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseFilteredHighlightsParams {
|
interface UseFilteredHighlightsParams {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
|
|||||||
|
|
||||||
let urlFiltered = highlights
|
let urlFiltered = highlights
|
||||||
|
|
||||||
// For Nostr articles, we already fetched highlights specifically for this article
|
// Filter highlights based on URL type
|
||||||
if (!selectedUrl.startsWith('nostr:')) {
|
if (selectedUrl.startsWith('nostr:')) {
|
||||||
|
// For Nostr articles, extract the article coordinate and filter by eventReference
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||||
|
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||||
|
|
||||||
|
urlFiltered = highlights.filter(h => {
|
||||||
|
// Keep highlights that match this article coordinate
|
||||||
|
return h.eventReference === articleCoordinate
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Not a valid naddr, clear all highlights
|
||||||
|
urlFiltered = []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid naddr, clear all highlights
|
||||||
|
urlFiltered = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For web URLs, filter by URL matching
|
||||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||||
|
|
||||||
urlFiltered = highlights.filter(h => {
|
urlFiltered = highlights.filter(h => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const useHighlightCreation = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||||
|
|
||||||
if (!activeAccount || !relayPool || !eventStore) {
|
if (!activeAccount || !relayPool || !eventStore) {
|
||||||
console.error('Missing requirements for highlight creation')
|
console.error('Missing requirements for highlight creation')
|
||||||
return
|
return
|
||||||
@@ -60,7 +61,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
source,
|
source,
|
||||||
@@ -73,7 +73,6 @@ export const useHighlightCreation = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Highlight created successfully
|
// Highlight created successfully
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
|||||||
@@ -93,26 +93,37 @@ export const useHighlightInteractions = ({
|
|||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [selectedHighlightId, contentVersion])
|
}, [selectedHighlightId, contentVersion])
|
||||||
|
|
||||||
// Handle text selection (works for both mouse and touch)
|
// Shared function to check and handle text selection
|
||||||
const handleSelectionEnd = useCallback(() => {
|
const checkSelection = useCallback(() => {
|
||||||
setTimeout(() => {
|
const selection = window.getSelection()
|
||||||
const selection = window.getSelection()
|
if (!selection || selection.rangeCount === 0) {
|
||||||
if (!selection || selection.rangeCount === 0) {
|
onClearSelection?.()
|
||||||
onClearSelection?.()
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
const text = selection.toString().trim()
|
const text = selection.toString().trim()
|
||||||
|
|
||||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||||
onTextSelection?.(text)
|
onTextSelection?.(text)
|
||||||
} else {
|
} else {
|
||||||
onClearSelection?.()
|
onClearSelection?.()
|
||||||
}
|
}
|
||||||
}, 10)
|
|
||||||
}, [onTextSelection, onClearSelection])
|
}, [onTextSelection, onClearSelection])
|
||||||
|
|
||||||
return { contentRef, handleSelectionEnd }
|
// Listen to selectionchange events for immediate detection (works reliably on mobile)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
// Use requestAnimationFrame to ensure selection is checked after browser updates
|
||||||
|
requestAnimationFrame(checkSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||||
|
}
|
||||||
|
}, [checkSelection])
|
||||||
|
|
||||||
|
return { contentRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function useImageCache(
|
|||||||
imageUrl: string | undefined
|
imageUrl: string | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
// Service Worker handles everything - just return the URL as-is
|
// Service Worker handles everything - just return the URL as-is
|
||||||
|
// The Service Worker will intercept fetch requests and cache them
|
||||||
|
// Make sure images use standard <img src> tags for SW interception
|
||||||
return imageUrl
|
return imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
|
|||||||
void imageUrl
|
void imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload an image URL to ensure it's cached by the Service Worker
|
||||||
|
* This is useful when loading content from cache - we want to ensure
|
||||||
|
* images are cached before going offline
|
||||||
|
*/
|
||||||
|
export function preloadImage(imageUrl: string | undefined): void {
|
||||||
|
if (!imageUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a link element with rel=prefetch or use Image object to trigger fetch
|
||||||
|
// Service Worker will intercept and cache the request
|
||||||
|
const img = new Image()
|
||||||
|
img.src = imageUrl
|
||||||
|
|
||||||
|
// Also try using fetch to explicitly trigger Service Worker
|
||||||
|
// This ensures the image is cached even if <img> tag hasn't rendered yet
|
||||||
|
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
|
||||||
|
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
|
||||||
|
// The Image() approach above will work for most cases
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels, addLoadingClassToProfileLinks } from '../utils/nostrUriResolver'
|
||||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||||
|
import { useProfileLabels } from './useProfileLabels'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||||
@@ -18,58 +19,129 @@ export const useMarkdownToHTML = (
|
|||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Resolve profile labels progressively as profiles load
|
||||||
|
const { labels: profileLabels, loading: profileLoading } = useProfileLabels(markdown || '', relayPool)
|
||||||
|
|
||||||
|
// Create stable dependencies based on Map contents, not Map objects
|
||||||
|
// This prevents unnecessary reprocessing when Maps are recreated with same content
|
||||||
|
const profileLabelsKey = useMemo(() => {
|
||||||
|
const key = Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
|
||||||
|
return key
|
||||||
|
}, [profileLabels])
|
||||||
|
|
||||||
|
const profileLoadingKey = useMemo(() => {
|
||||||
|
return Array.from(profileLoading.entries())
|
||||||
|
.filter(([, loading]) => loading)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k]) => k)
|
||||||
|
.join('|')
|
||||||
|
}, [profileLoading])
|
||||||
|
|
||||||
|
const articleTitlesKey = useMemo(() => {
|
||||||
|
return Array.from(articleTitles.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
|
||||||
|
}, [articleTitles])
|
||||||
|
|
||||||
|
// Keep refs to latest Maps for processing without causing re-renders
|
||||||
|
const profileLabelsRef = useRef(profileLabels)
|
||||||
|
const profileLoadingRef = useRef(profileLoading)
|
||||||
|
const articleTitlesRef = useRef(articleTitles)
|
||||||
|
|
||||||
|
// Ref to track second RAF ID for HTML extraction cleanup
|
||||||
|
const htmlExtractionRafIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always clear previous render immediately to avoid showing stale content while processing
|
profileLabelsRef.current = profileLabels
|
||||||
setRenderedHtml('')
|
profileLoadingRef.current = profileLoading
|
||||||
setProcessedMarkdown('')
|
articleTitlesRef.current = articleTitles
|
||||||
|
}, [profileLabels, profileLoading, articleTitles])
|
||||||
|
|
||||||
if (!markdown) {
|
// Fetch article titles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdown || !relayPool) {
|
||||||
|
setArticleTitles(new Map())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
|
|
||||||
const processMarkdown = async () => {
|
const fetchTitles = async () => {
|
||||||
// Extract all naddr references
|
|
||||||
const naddrs = extractNaddrUris(markdown)
|
const naddrs = extractNaddrUris(markdown)
|
||||||
|
if (naddrs.length === 0) {
|
||||||
let processed: string
|
setArticleTitles(new Map())
|
||||||
|
return
|
||||||
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)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch article titles:', error)
|
|
||||||
// Fall back to basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No articles to resolve, use basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancelled) return
|
try {
|
||||||
|
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
|
||||||
setProcessedMarkdown(processed)
|
if (!isCancelled) {
|
||||||
|
setArticleTitles(titlesMap)
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
|
||||||
if (previewRef.current && !isCancelled) {
|
|
||||||
const html = previewRef.current.innerHTML
|
|
||||||
setRenderedHtml(html)
|
|
||||||
} else if (!isCancelled) {
|
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
|
if (!isCancelled) setArticleTitles(new Map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => cancelAnimationFrame(rafId)
|
fetchTitles()
|
||||||
|
return () => { isCancelled = true }
|
||||||
|
}, [markdown, relayPool])
|
||||||
|
|
||||||
|
// Track previous markdown and processed state to detect actual content changes
|
||||||
|
const previousMarkdownRef = useRef<string | undefined>(markdown)
|
||||||
|
const processedMarkdownRef = useRef<string>(processedMarkdown)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
processedMarkdownRef.current = processedMarkdown
|
||||||
|
}, [processedMarkdown])
|
||||||
|
|
||||||
|
// Process markdown with progressive profile labels and article titles
|
||||||
|
// Use stable string keys instead of Map objects to prevent excessive reprocessing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdown) {
|
||||||
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
|
previousMarkdownRef.current = markdown
|
||||||
|
processedMarkdownRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
const processMarkdown = () => {
|
||||||
|
try {
|
||||||
|
// Replace nostr URIs with profile labels (progressive) and article titles
|
||||||
|
// Use refs to get latest values without causing dependency changes
|
||||||
|
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
|
||||||
|
markdown,
|
||||||
|
profileLabelsRef.current,
|
||||||
|
articleTitlesRef.current,
|
||||||
|
profileLoadingRef.current
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
setProcessedMarkdown(processed)
|
||||||
|
processedMarkdownRef.current = processed
|
||||||
|
// HTML extraction will happen in separate useEffect that watches processedMarkdown
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[markdown-to-html] Error processing markdown:`, error)
|
||||||
|
if (!isCancelled) {
|
||||||
|
setProcessedMarkdown(markdown) // Fallback to original
|
||||||
|
processedMarkdownRef.current = markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear previous content if this is the first processing or markdown changed
|
||||||
|
// For profile updates, just reprocess without clearing to avoid flicker
|
||||||
|
const isMarkdownChange = previousMarkdownRef.current !== markdown
|
||||||
|
previousMarkdownRef.current = markdown
|
||||||
|
|
||||||
|
if (isMarkdownChange || !processedMarkdownRef.current) {
|
||||||
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
|
processedMarkdownRef.current = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
processMarkdown()
|
processMarkdown()
|
||||||
@@ -77,7 +149,44 @@ export const useMarkdownToHTML = (
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
isCancelled = true
|
||||||
}
|
}
|
||||||
}, [markdown, relayPool])
|
}, [markdown, profileLabelsKey, profileLoadingKey, articleTitlesKey])
|
||||||
|
|
||||||
|
// Extract HTML after processedMarkdown renders
|
||||||
|
// This useEffect watches processedMarkdown and extracts HTML once ReactMarkdown has rendered it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processedMarkdown || !markdown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
// Use double RAF to ensure ReactMarkdown has finished rendering:
|
||||||
|
// First RAF: let React complete its render cycle
|
||||||
|
// Second RAF: extract HTML after DOM has updated
|
||||||
|
const rafId1 = requestAnimationFrame(() => {
|
||||||
|
htmlExtractionRafIdRef.current = requestAnimationFrame(() => {
|
||||||
|
if (previewRef.current && !isCancelled) {
|
||||||
|
let html = previewRef.current.innerHTML
|
||||||
|
|
||||||
|
// Post-process HTML to add loading class to profile links
|
||||||
|
html = addLoadingClassToProfileLinks(html, profileLoadingRef.current)
|
||||||
|
|
||||||
|
setRenderedHtml(html)
|
||||||
|
} else if (!isCancelled && processedMarkdown) {
|
||||||
|
console.warn('⚠️ markdownPreviewRef.current is null but processedMarkdown exists')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
cancelAnimationFrame(rafId1)
|
||||||
|
if (htmlExtractionRafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(htmlExtractionRafIdRef.current)
|
||||||
|
htmlExtractionRafIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [processedMarkdown, markdown])
|
||||||
|
|
||||||
return { renderedHtml, previewRef, processedMarkdown }
|
return { renderedHtml, previewRef, processedMarkdown }
|
||||||
}
|
}
|
||||||
|
|||||||
324
src/hooks/useProfileLabels.ts
Normal file
324
src/hooks/useProfileLabels.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
|
import { getContentPointers } from 'applesauce-factory/helpers'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { fetchProfiles, loadCachedProfiles } from '../services/profileService'
|
||||||
|
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
|
||||||
|
import { extractProfileDisplayName } from '../utils/profileUtils'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to resolve profile labels from content containing npub/nprofile identifiers
|
||||||
|
* Returns an object with labels Map and loading Map that updates progressively as profiles load
|
||||||
|
*/
|
||||||
|
export function useProfileLabels(
|
||||||
|
content: string,
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
): { labels: Map<string, string>; loading: Map<string, boolean> } {
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
|
// Extract profile pointers (npub and nprofile) using applesauce helpers
|
||||||
|
const profileData = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const pointers = getContentPointers(content)
|
||||||
|
const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile')
|
||||||
|
const result: Array<{ pubkey: string; encoded: string }> = []
|
||||||
|
filtered.forEach(pointer => {
|
||||||
|
try {
|
||||||
|
const pubkey = getPubkeyFromDecodeResult(pointer)
|
||||||
|
const encoded = encodeDecodeResult(pointer)
|
||||||
|
if (pubkey && encoded) {
|
||||||
|
result.push({ pubkey, encoded: encoded as string })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors, continue processing other pointers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[profile-labels] Error extracting profile pointers:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Initialize labels synchronously from cache on first render to avoid delay
|
||||||
|
// Use pubkey (hex) as the key instead of encoded string for canonical identification
|
||||||
|
const initialLabels = useMemo(() => {
|
||||||
|
if (profileData.length === 0) {
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
|
||||||
|
const cachedProfiles = loadCachedProfiles(allPubkeys)
|
||||||
|
const labels = new Map<string, string>()
|
||||||
|
|
||||||
|
profileData.forEach(({ pubkey }) => {
|
||||||
|
const cachedProfile = cachedProfiles.get(pubkey)
|
||||||
|
if (cachedProfile) {
|
||||||
|
const displayName = extractProfileDisplayName(cachedProfile)
|
||||||
|
if (displayName) {
|
||||||
|
// Add @ prefix (extractProfileDisplayName returns name without @)
|
||||||
|
const label = `@${displayName}`
|
||||||
|
labels.set(pubkey, label)
|
||||||
|
} else {
|
||||||
|
// Use fallback npub display if profile has no name (add @ prefix)
|
||||||
|
const fallback = getNpubFallbackDisplay(pubkey)
|
||||||
|
labels.set(pubkey, `@${fallback}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}, [profileData])
|
||||||
|
|
||||||
|
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels)
|
||||||
|
const [profileLoading, setProfileLoading] = useState<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
// Batching strategy: Collect profile updates and apply them in batches via RAF to prevent UI flicker
|
||||||
|
// when many profiles resolve simultaneously. We use refs to avoid stale closures in async callbacks.
|
||||||
|
// Use pubkey (hex) as the key for canonical identification
|
||||||
|
const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
|
||||||
|
const rafScheduledRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to apply pending batched updates to state
|
||||||
|
* Cancels any scheduled RAF and applies updates synchronously
|
||||||
|
*/
|
||||||
|
const applyPendingUpdates = () => {
|
||||||
|
const pendingUpdates = pendingUpdatesRef.current
|
||||||
|
if (pendingUpdates.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel scheduled RAF since we're applying synchronously
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply all pending updates in one batch
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const updatedLabels = new Map(prevLabels)
|
||||||
|
for (const [pubkey, label] of pendingUpdates.entries()) {
|
||||||
|
updatedLabels.set(pubkey, label)
|
||||||
|
}
|
||||||
|
pendingUpdates.clear()
|
||||||
|
return updatedLabels
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to schedule a batched update via RAF (if not already scheduled)
|
||||||
|
* This prevents multiple RAF calls when many profiles resolve at once
|
||||||
|
* Wrapped in useCallback for stable reference in dependency arrays
|
||||||
|
*/
|
||||||
|
const scheduleBatchedUpdate = useCallback(() => {
|
||||||
|
if (rafScheduledRef.current === null) {
|
||||||
|
rafScheduledRef.current = requestAnimationFrame(() => {
|
||||||
|
applyPendingUpdates()
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, []) // Empty deps: only uses refs which are stable
|
||||||
|
|
||||||
|
// Sync state when initialLabels changes (e.g., when content changes)
|
||||||
|
// This ensures we start with the correct cached labels even if profiles haven't loaded yet
|
||||||
|
useEffect(() => {
|
||||||
|
// Use a functional update to access current state without including it in dependencies
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const currentPubkeys = new Set(Array.from(prevLabels.keys()))
|
||||||
|
const newPubkeys = new Set(profileData.map(p => p.pubkey))
|
||||||
|
|
||||||
|
// If the content changed significantly (different set of profiles), reset state
|
||||||
|
const hasDifferentProfiles =
|
||||||
|
currentPubkeys.size !== newPubkeys.size ||
|
||||||
|
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
|
||||||
|
|
||||||
|
if (hasDifferentProfiles) {
|
||||||
|
// Clear pending updates and cancel RAF for old profiles
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
// Reset to initial labels
|
||||||
|
return new Map(initialLabels)
|
||||||
|
} else {
|
||||||
|
// Same profiles, merge initial labels with existing state
|
||||||
|
// IMPORTANT: Preserve existing labels (from pending updates) and only add initial labels if missing
|
||||||
|
const merged = new Map(prevLabels)
|
||||||
|
for (const [pubkey, label] of initialLabels.entries()) {
|
||||||
|
// Only add initial label if we don't already have a label for this pubkey
|
||||||
|
// This preserves labels that were added via applyPendingUpdates
|
||||||
|
if (!merged.has(pubkey)) {
|
||||||
|
merged.set(pubkey, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset loading state when content changes significantly
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const currentPubkeys = new Set(Array.from(prevLoading.keys()))
|
||||||
|
const newPubkeys = new Set(profileData.map(p => p.pubkey))
|
||||||
|
|
||||||
|
const hasDifferentProfiles =
|
||||||
|
currentPubkeys.size !== newPubkeys.size ||
|
||||||
|
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
|
||||||
|
|
||||||
|
if (hasDifferentProfiles) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
return prevLoading
|
||||||
|
})
|
||||||
|
}, [initialLabels, profileData])
|
||||||
|
|
||||||
|
// Build initial labels: localStorage cache -> eventStore -> fetch from relays
|
||||||
|
useEffect(() => {
|
||||||
|
// Extract all pubkeys
|
||||||
|
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
|
||||||
|
|
||||||
|
if (allPubkeys.length === 0) {
|
||||||
|
setProfileLabels(new Map())
|
||||||
|
setProfileLoading(new Map())
|
||||||
|
// Clear pending updates and cancel RAF when clearing labels
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cached profiles to EventStore for consistency
|
||||||
|
const cachedProfiles = loadCachedProfiles(allPubkeys)
|
||||||
|
if (eventStore) {
|
||||||
|
for (const profile of cachedProfiles.values()) {
|
||||||
|
eventStore.add(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build labels from localStorage cache and eventStore
|
||||||
|
// initialLabels already has all cached profiles, so we only need to check eventStore
|
||||||
|
// Use pubkey (hex) as the key for canonical identification
|
||||||
|
const labels = new Map<string, string>(initialLabels)
|
||||||
|
const loading = new Map<string, boolean>()
|
||||||
|
|
||||||
|
const pubkeysToFetch: string[] = []
|
||||||
|
|
||||||
|
profileData.forEach(({ pubkey }) => {
|
||||||
|
// Skip if already resolved from initial cache
|
||||||
|
if (labels.has(pubkey)) {
|
||||||
|
loading.set(pubkey, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check EventStore for profiles that weren't in cache
|
||||||
|
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
|
||||||
|
|
||||||
|
if (eventStoreProfile && eventStore) {
|
||||||
|
// Extract display name using centralized utility
|
||||||
|
const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent)
|
||||||
|
if (displayName) {
|
||||||
|
// Add @ prefix (extractProfileDisplayName returns name without @)
|
||||||
|
const label = `@${displayName}`
|
||||||
|
labels.set(pubkey, label)
|
||||||
|
} else {
|
||||||
|
// Use fallback npub display if profile has no name (add @ prefix)
|
||||||
|
const fallback = getNpubFallbackDisplay(pubkey)
|
||||||
|
labels.set(pubkey, `@${fallback}`)
|
||||||
|
}
|
||||||
|
loading.set(pubkey, false)
|
||||||
|
} else {
|
||||||
|
// No profile found yet, will use fallback after fetch or keep empty
|
||||||
|
// We'll set fallback labels for missing profiles at the end
|
||||||
|
// Mark as loading since we'll fetch it
|
||||||
|
pubkeysToFetch.push(pubkey)
|
||||||
|
loading.set(pubkey, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Don't set fallback labels in the Map - we'll use fallback directly when rendering
|
||||||
|
// This allows us to distinguish between "no label yet" (use fallback) vs "resolved label" (use Map value)
|
||||||
|
|
||||||
|
setProfileLabels(new Map(labels))
|
||||||
|
setProfileLoading(new Map(loading))
|
||||||
|
|
||||||
|
// Fetch missing profiles asynchronously with reactive updates
|
||||||
|
if (pubkeysToFetch.length > 0 && relayPool && eventStore) {
|
||||||
|
|
||||||
|
// Reactive callback: collects profile updates and batches them via RAF to prevent flicker
|
||||||
|
// Strategy: Apply label immediately when profile resolves, but still batch for multiple profiles
|
||||||
|
const handleProfileEvent = (event: NostrEvent) => {
|
||||||
|
// Use pubkey directly as the key
|
||||||
|
const pubkey = event.pubkey
|
||||||
|
|
||||||
|
// Determine the label for this profile using centralized utility
|
||||||
|
// Add @ prefix (both extractProfileDisplayName and getNpubFallbackDisplay return names without @)
|
||||||
|
const displayName = extractProfileDisplayName(event)
|
||||||
|
const label = displayName ? `@${displayName}` : `@${getNpubFallbackDisplay(pubkey)}`
|
||||||
|
|
||||||
|
// Apply label immediately to prevent race condition with loading state
|
||||||
|
// This ensures labels are available when isLoading becomes false
|
||||||
|
setProfileLabels(prevLabels => {
|
||||||
|
const updated = new Map(prevLabels)
|
||||||
|
updated.set(pubkey, label)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear loading state for this profile when it resolves
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent)
|
||||||
|
.then(() => {
|
||||||
|
// After EOSE: apply any remaining pending updates immediately
|
||||||
|
// This ensures all profile updates are applied even if RAF hasn't fired yet
|
||||||
|
applyPendingUpdates()
|
||||||
|
|
||||||
|
// Clear loading state for all fetched profiles
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
pubkeysToFetch.forEach(pubkey => {
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[profile-labels] Error fetching profiles:`, error)
|
||||||
|
// Silently handle fetch errors, but still clear any pending updates
|
||||||
|
pendingUpdatesRef.current.clear()
|
||||||
|
if (rafScheduledRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafScheduledRef.current)
|
||||||
|
rafScheduledRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on error (show fallback)
|
||||||
|
setProfileLoading(prevLoading => {
|
||||||
|
const updated = new Map(prevLoading)
|
||||||
|
pubkeysToFetch.forEach(pubkey => {
|
||||||
|
updated.set(pubkey, false)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup: apply any pending updates before unmount to avoid losing them
|
||||||
|
return () => {
|
||||||
|
applyPendingUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate])
|
||||||
|
|
||||||
|
return { labels: profileLabels, loading: profileLoading }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -68,11 +68,18 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||||
|
|
||||||
|
// Set link colors for dark and light themes separately
|
||||||
|
const darkLinkColor = settings.linkColorDark || '#38bdf8'
|
||||||
|
const lightLinkColor = settings.linkColorLight || '#3b82f6'
|
||||||
|
root.setProperty('--color-link-dark', darkLinkColor)
|
||||||
|
root.setProperty('--color-link-light', lightLinkColor)
|
||||||
|
|
||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
// Set image max-width based on full-width setting
|
// Set image width and max-height based on full-width setting
|
||||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
root.setProperty('--image-width', settings.fullWidthImages ? '100%' : 'auto')
|
||||||
|
root.setProperty('--image-max-height', settings.fullWidthImages ? 'none' : '70vh')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
src/main.tsx
75
src/main.tsx
@@ -5,16 +5,60 @@ import './styles/tailwind.css'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import 'react-loading-skeleton/dist/skeleton.css'
|
import 'react-loading-skeleton/dist/skeleton.css'
|
||||||
|
|
||||||
// Register Service Worker for PWA functionality (production only)
|
// Register Service Worker for PWA functionality
|
||||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
// With injectRegister: null, we need to register manually
|
||||||
|
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker
|
const swPath = '/sw.js'
|
||||||
.register('/sw.js')
|
|
||||||
|
// Check if already registered/active first
|
||||||
|
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
|
||||||
|
if (registrations.length > 0) {
|
||||||
|
return registrations[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not registered yet, try to register
|
||||||
|
// In dev mode, use the dev Service Worker for testing
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const devSwPath = '/sw-dev.js'
|
||||||
|
try {
|
||||||
|
// Check if dev SW exists
|
||||||
|
const response = await fetch(devSwPath)
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
|
||||||
|
|
||||||
|
if (response.ok && isJavaScript) {
|
||||||
|
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
|
||||||
|
} else {
|
||||||
|
console.warn('[sw-registration] Development Service Worker not available')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sw-registration] Could not load development Service Worker:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In production, just register directly
|
||||||
|
return await navigator.serviceWorker.register(swPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
// Check for updates periodically
|
if (!registration) return
|
||||||
setInterval(() => {
|
|
||||||
registration.update()
|
// Wait for Service Worker to activate
|
||||||
}, 60 * 60 * 1000) // Check every hour
|
if (registration.installing) {
|
||||||
|
registration.installing.addEventListener('statechange', () => {
|
||||||
|
// Service Worker state changed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates periodically (production only)
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update()
|
||||||
|
}, 60 * 60 * 1000) // Check every hour
|
||||||
|
}
|
||||||
|
|
||||||
// Handle service worker updates
|
// Handle service worker updates
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
@@ -31,9 +75,22 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('❌ Service Worker registration failed:', error)
|
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
|
||||||
|
console.error('[sw-registration] Error details:', {
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
stack: error.stack
|
||||||
|
})
|
||||||
|
|
||||||
|
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
|
||||||
|
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays'
|
||||||
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||||
import { merge, toArray as rxToArray } from 'rxjs'
|
import { merge, toArray as rxToArray } from 'rxjs'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
|
|||||||
return `${CACHE_PREFIX}${naddr}`
|
return `${CACHE_PREFIX}${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFromCache(naddr: string): ArticleContent | null {
|
export function getFromCache(naddr: string): ArticleContent | null {
|
||||||
try {
|
try {
|
||||||
const cacheKey = getCacheKey(naddr)
|
const cacheKey = getCacheKey(naddr)
|
||||||
const cached = localStorage.getItem(cacheKey)
|
const cached = localStorage.getItem(cacheKey)
|
||||||
if (!cached) return null
|
if (!cached) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||||
const age = Date.now() - timestamp
|
const age = Date.now() - timestamp
|
||||||
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// Silently handle cache read errors
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
/**
|
||||||
|
* Caches an article event to localStorage for offline access
|
||||||
|
* @param event - The Nostr event to cache
|
||||||
|
* @param settings - Optional user settings
|
||||||
|
*/
|
||||||
|
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||||
|
try {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
if (!dTag || event.kind !== 30023) return
|
||||||
|
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
|
||||||
|
const articleContent: ArticleContent = {
|
||||||
|
title: getArticleTitle(event) || 'Untitled Article',
|
||||||
|
markdown: event.content,
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
author: event.pubkey,
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||||
|
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||||
|
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||||
|
// Future: could add explicit enableArticleCache setting
|
||||||
|
// For now, we cache aggressively but handle errors gracefully
|
||||||
|
// Note: settings parameter reserved for future use
|
||||||
|
void settings // Mark as intentionally unused for now
|
||||||
try {
|
try {
|
||||||
const cacheKey = getCacheKey(naddr)
|
const cacheKey = getCacheKey(naddr)
|
||||||
const cached: CachedArticle = {
|
const cached: CachedArticle = {
|
||||||
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
|||||||
}
|
}
|
||||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to cache article:', err)
|
// Silently fail - don't block the UI if caching fails
|
||||||
// Silently fail if storage is full or unavailable
|
// Handles quota exceeded, invalid data, and other errors gracefully
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +138,6 @@ export async function fetchArticleByNaddr(
|
|||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
// Define relays to query - use union of relay hints from naddr and configured relays
|
|
||||||
// This avoids failures when naddr contains stale/unreachable relay hints
|
|
||||||
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
|
||||||
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
|
||||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
|
||||||
|
|
||||||
// Fetch the article event
|
// Fetch the article event
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [pointer.kind],
|
kinds: [pointer.kind],
|
||||||
@@ -111,24 +145,45 @@ export async function fetchArticleByNaddr(
|
|||||||
'#d': [pointer.identifier]
|
'#d': [pointer.identifier]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel local+remote, stream immediate, collect up to first from each
|
let events: NostrEvent[] = []
|
||||||
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()))
|
|
||||||
let events = collected as NostrEvent[]
|
|
||||||
|
|
||||||
// Fallback: if nothing found, try a second round against a set of reliable public relays
|
// Build unified relay set: hints + configured content relays
|
||||||
|
// Filter hinted relays to only content-capable relays
|
||||||
|
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
|
||||||
|
? pointer.relays.filter(isContentRelay)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Get configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
|
||||||
|
// Union of hinted and configured relays (deduplicated)
|
||||||
|
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
|
||||||
|
|
||||||
|
if (unifiedRelays.length > 0) {
|
||||||
|
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
|
||||||
|
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
|
||||||
|
|
||||||
|
const { local$, remote$ } = createParallelReqStreams(
|
||||||
|
relayPool,
|
||||||
|
localUnified,
|
||||||
|
remoteUnified,
|
||||||
|
filter,
|
||||||
|
1200,
|
||||||
|
6000
|
||||||
|
)
|
||||||
|
const collected = await lastValueFrom(
|
||||||
|
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||||
|
)
|
||||||
|
events = collected as NostrEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try fallback content relays (most reliable public relays)
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
const reliableRelays = Array.from(new Set<string>([
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.primal.net',
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
...remoteRelays // keep any configured remote relays
|
|
||||||
]))
|
|
||||||
const { remote$: fallback$ } = createParallelReqStreams(
|
const { remote$: fallback$ } = createParallelReqStreams(
|
||||||
relayPool,
|
relayPool,
|
||||||
[], // no local
|
[], // no local for fallback
|
||||||
reliableRelays,
|
fallbackRelays,
|
||||||
filter,
|
filter,
|
||||||
1500,
|
1500,
|
||||||
12000
|
12000
|
||||||
@@ -164,7 +219,7 @@ export async function fetchArticleByNaddr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to cache before returning
|
// Save to cache before returning
|
||||||
saveToCache(naddr, content)
|
saveToCache(naddr, content, settings)
|
||||||
|
|
||||||
// Image caching is handled automatically by Service Worker
|
// Image caching is handled automatically by Service Worker
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { Helpers, IEventStore } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { cacheArticleEvent } from './articleService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
onPost(post)
|
onPost(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache article content in localStorage for offline access
|
||||||
|
cacheArticleEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch blog posts:', error)
|
console.error('Failed to fetch blog posts:', error)
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
|||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { publishEvent } from './writeService'
|
import { setHighlightMetadata } from './highlightEventProcessor'
|
||||||
|
|
||||||
// Boris pubkey for zap splits
|
// Boris pubkey for zap splits
|
||||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -118,25 +127,111 @@ export async function createHighlight(
|
|||||||
// Sign the event
|
// Sign the event
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Initialize custom properties on the event (will be updated after publishing)
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: false,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
// Check current connection status for UI feedback
|
// Get only connected relays to avoid long timeouts
|
||||||
const connectedRelays = Array.from(relayPool.relays.values())
|
const connectedRelays = Array.from(relayPool.relays.values())
|
||||||
.filter(relay => relay.connected)
|
.filter(relay => relay.connected)
|
||||||
.map(relay => relay.url)
|
.map(relay => relay.url)
|
||||||
|
|
||||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
let publishResponses: { ok: boolean; message?: string; from: string }[] = []
|
||||||
const expectedSuccessRelays = hasRemoteConnection
|
let isLocalOnly = false
|
||||||
? RELAYS
|
|
||||||
: RELAYS.filter(isLocalRelay)
|
|
||||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
|
||||||
|
|
||||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
|
||||||
|
try {
|
||||||
|
// Publish only to connected relays to avoid long timeouts
|
||||||
|
if (connectedRelays.length === 0) {
|
||||||
|
isLocalOnly = true
|
||||||
|
} else {
|
||||||
|
publishResponses = await relayPool.publish(connectedRelays, signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which relays successfully accepted the event
|
||||||
|
const successfulRelays = publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
|
||||||
|
const successfulLocalRelays = successfulRelays.filter(url => isLocalRelay(url))
|
||||||
|
const successfulRemoteRelays = successfulRelays.filter(url => !isLocalRelay(url))
|
||||||
|
|
||||||
|
// isLocalOnly is true if only local relays accepted the event
|
||||||
|
isLocalOnly = successfulLocalRelays.length > 0 && successfulRemoteRelays.length === 0
|
||||||
|
|
||||||
|
|
||||||
|
// Handle case when no relays were connected
|
||||||
|
const successfulRelaysList = publishResponses.length > 0
|
||||||
|
? publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Store metadata in cache (persists across EventStore serialization)
|
||||||
|
setHighlightMetadata(signedEvent.id, {
|
||||||
|
publishedRelays: successfulRelaysList,
|
||||||
|
isLocalOnly,
|
||||||
|
isSyncing: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update the event with the actual properties (for backwards compatibility)
|
||||||
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: successfulRelaysList,
|
||||||
|
isLocalOnly,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event in EventStore AFTER updating with final properties
|
||||||
|
eventStore.add(signedEvent)
|
||||||
|
|
||||||
|
// Mark for offline sync if we're in local-only mode
|
||||||
|
if (isLocalOnly) {
|
||||||
|
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||||
|
markEventAsOfflineCreated(signedEvent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [HIGHLIGHT-PUBLISH] Failed to publish highlight to relays:', error)
|
||||||
|
// If publishing fails completely, assume local-only mode
|
||||||
|
isLocalOnly = true
|
||||||
|
|
||||||
|
// Store metadata in cache (persists across EventStore serialization)
|
||||||
|
setHighlightMetadata(signedEvent.id, {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: true,
|
||||||
|
isSyncing: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update the event with the error state (for backwards compatibility)
|
||||||
|
;(signedEvent as HighlightEvent).__highlightProps = {
|
||||||
|
publishedRelays: [],
|
||||||
|
isLocalOnly: true,
|
||||||
|
isSyncing: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event in EventStore AFTER updating with final properties
|
||||||
|
eventStore.add(signedEvent)
|
||||||
|
|
||||||
|
const { markEventAsOfflineCreated } = await import('./offlineSyncService')
|
||||||
|
markEventAsOfflineCreated(signedEvent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Highlight with relay tracking info
|
||||||
const highlight = eventToHighlight(signedEvent)
|
const highlight = eventToHighlight(signedEvent)
|
||||||
highlight.publishedRelays = expectedSuccessRelays
|
|
||||||
|
// Manually set the properties since __highlightProps might not be working
|
||||||
|
const finalPublishedRelays = publishResponses.length > 0
|
||||||
|
? publishResponses
|
||||||
|
.filter(response => response.ok)
|
||||||
|
.map(response => response.from)
|
||||||
|
: []
|
||||||
|
|
||||||
|
highlight.publishedRelays = finalPublishedRelays
|
||||||
highlight.isLocalOnly = isLocalOnly
|
highlight.isLocalOnly = isLocalOnly
|
||||||
highlight.isOfflineCreated = isLocalOnly
|
highlight.isSyncing = false
|
||||||
|
|
||||||
return highlight
|
return highlight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
// Extended event type with highlight metadata
|
||||||
|
interface HighlightEvent extends NostrEvent {
|
||||||
|
__highlightProps?: {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -12,6 +21,66 @@ const {
|
|||||||
getHighlightAttributions
|
getHighlightAttributions
|
||||||
} = Helpers
|
} = Helpers
|
||||||
|
|
||||||
|
const METADATA_CACHE_KEY = 'highlightMetadataCache'
|
||||||
|
|
||||||
|
type HighlightMetadata = {
|
||||||
|
publishedRelays?: string[]
|
||||||
|
isLocalOnly?: boolean
|
||||||
|
isSyncing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load highlight metadata from localStorage
|
||||||
|
*/
|
||||||
|
function loadHighlightMetadataFromStorage(): Map<string, HighlightMetadata> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(METADATA_CACHE_KEY)
|
||||||
|
if (!raw) return new Map()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, HighlightMetadata>
|
||||||
|
return new Map(Object.entries(parsed))
|
||||||
|
} catch {
|
||||||
|
// Silently fail on parse errors or if storage is unavailable
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save highlight metadata to localStorage
|
||||||
|
*/
|
||||||
|
function saveHighlightMetadataToStorage(cache: Map<string, HighlightMetadata>): void {
|
||||||
|
try {
|
||||||
|
const record = Object.fromEntries(cache.entries())
|
||||||
|
localStorage.setItem(METADATA_CACHE_KEY, JSON.stringify(record))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if storage is full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for highlight metadata that persists across EventStore serialization
|
||||||
|
* Key: event ID, Value: { publishedRelays, isLocalOnly, isSyncing }
|
||||||
|
*/
|
||||||
|
const highlightMetadataCache = loadHighlightMetadataFromStorage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function setHighlightMetadata(
|
||||||
|
eventId: string,
|
||||||
|
metadata: HighlightMetadata
|
||||||
|
): void {
|
||||||
|
highlightMetadataCache.set(eventId, metadata)
|
||||||
|
saveHighlightMetadataToStorage(highlightMetadataCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highlight metadata for an event ID
|
||||||
|
*/
|
||||||
|
export function getHighlightMetadata(eventId: string): HighlightMetadata | undefined {
|
||||||
|
return highlightMetadataCache.get(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a NostrEvent to a Highlight object
|
* Convert a NostrEvent to a Highlight object
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +97,12 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
const eventReference = sourceEventPointer?.id ||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
|
// Check cache first (persists across EventStore serialization)
|
||||||
|
const cachedMetadata = getHighlightMetadata(event.id)
|
||||||
|
|
||||||
|
// Fall back to __highlightProps if cache doesn't have it (for backwards compatibility)
|
||||||
|
const customProps = cachedMetadata || (event as HighlightEvent).__highlightProps || {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
@@ -38,7 +113,11 @@ export function eventToHighlight(event: NostrEvent): Highlight {
|
|||||||
urlReference: sourceUrl,
|
urlReference: sourceUrl,
|
||||||
author,
|
author,
|
||||||
context,
|
context,
|
||||||
comment
|
comment,
|
||||||
|
// Preserve custom properties if they exist
|
||||||
|
publishedRelays: customProps.publishedRelays,
|
||||||
|
isLocalOnly: customProps.isLocalOnly,
|
||||||
|
isSyncing: customProps.isSyncing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* Service Worker automatically caches images on fetch
|
* Service Worker automatically caches images on fetch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'boris-image-cache-v1'
|
// Must match the cache name in src/sw.ts
|
||||||
|
const CACHE_NAME = 'boris-images'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cached images
|
* Clear all cached images
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { cacheArticleEvent } from './articleService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
}
|
}
|
||||||
onPost(post)
|
onPost(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache article content in localStorage for offline access
|
||||||
|
cacheArticleEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||||
|
|||||||
@@ -3,11 +3,42 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
|
import { setHighlightMetadata, getHighlightMetadata } from './highlightEventProcessor'
|
||||||
|
|
||||||
|
const OFFLINE_EVENTS_KEY = 'offlineCreatedEvents'
|
||||||
|
|
||||||
let isSyncing = false
|
let isSyncing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load offline events from localStorage
|
||||||
|
*/
|
||||||
|
function loadOfflineEventsFromStorage(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(OFFLINE_EVENTS_KEY)
|
||||||
|
if (!raw) return new Set()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as string[]
|
||||||
|
return new Set(parsed)
|
||||||
|
} catch {
|
||||||
|
// Silently fail on parse errors or if storage is unavailable
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save offline events to localStorage
|
||||||
|
*/
|
||||||
|
function saveOfflineEventsToStorage(events: Set<string>): void {
|
||||||
|
try {
|
||||||
|
const array = Array.from(events)
|
||||||
|
localStorage.setItem(OFFLINE_EVENTS_KEY, JSON.stringify(array))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if storage is full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track events created during offline period
|
// Track events created during offline period
|
||||||
const offlineCreatedEvents = new Set<string>()
|
const offlineCreatedEvents = loadOfflineEventsFromStorage()
|
||||||
|
|
||||||
// Track events currently being synced
|
// Track events currently being synced
|
||||||
const syncingEvents = new Set<string>()
|
const syncingEvents = new Set<string>()
|
||||||
@@ -20,6 +51,14 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
|
|||||||
*/
|
*/
|
||||||
export function markEventAsOfflineCreated(eventId: string): void {
|
export function markEventAsOfflineCreated(eventId: string): void {
|
||||||
offlineCreatedEvents.add(eventId)
|
offlineCreatedEvents.add(eventId)
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event was created during offline period (flight mode)
|
||||||
|
*/
|
||||||
|
export function isEventOfflineCreated(eventId: string): boolean {
|
||||||
|
return offlineCreatedEvents.has(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +126,7 @@ export async function syncLocalEventsToRemote(
|
|||||||
if (eventsToSync.length === 0) {
|
if (eventsToSync.length === 0) {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
offlineCreatedEvents.clear()
|
offlineCreatedEvents.clear()
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +135,17 @@ export async function syncLocalEventsToRemote(
|
|||||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mark all events as syncing
|
// Mark all events as syncing and update metadata
|
||||||
uniqueEvents.forEach(event => {
|
uniqueEvents.forEach(event => {
|
||||||
syncingEvents.add(event.id)
|
syncingEvents.add(event.id)
|
||||||
notifySyncStateChange(event.id, true)
|
notifySyncStateChange(event.id, true)
|
||||||
|
|
||||||
|
// Update metadata cache to reflect syncing state
|
||||||
|
const existingMetadata = getHighlightMetadata(event.id)
|
||||||
|
setHighlightMetadata(event.id, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Publish to remote relays
|
// Publish to remote relays
|
||||||
@@ -118,13 +165,32 @@ export async function syncLocalEventsToRemote(
|
|||||||
syncingEvents.delete(eventId)
|
syncingEvents.delete(eventId)
|
||||||
offlineCreatedEvents.delete(eventId)
|
offlineCreatedEvents.delete(eventId)
|
||||||
notifySyncStateChange(eventId, false)
|
notifySyncStateChange(eventId, false)
|
||||||
|
|
||||||
|
// Update metadata cache: sync complete, no longer local-only
|
||||||
|
const existingMetadata = getHighlightMetadata(eventId)
|
||||||
|
setHighlightMetadata(eventId, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: false,
|
||||||
|
isLocalOnly: false
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Save updated offline events set to localStorage
|
||||||
|
saveOfflineEventsToStorage(offlineCreatedEvents)
|
||||||
|
|
||||||
// Clear syncing state for failed events
|
// Clear syncing state for failed events
|
||||||
uniqueEvents.forEach(event => {
|
uniqueEvents.forEach(event => {
|
||||||
if (!successfulIds.includes(event.id)) {
|
if (!successfulIds.includes(event.id)) {
|
||||||
syncingEvents.delete(event.id)
|
syncingEvents.delete(event.id)
|
||||||
notifySyncStateChange(event.id, false)
|
notifySyncStateChange(event.id, false)
|
||||||
|
|
||||||
|
// Update metadata cache: sync failed, still local-only
|
||||||
|
const existingMetadata = getHighlightMetadata(event.id)
|
||||||
|
setHighlightMetadata(event.id, {
|
||||||
|
...existingMetadata,
|
||||||
|
isSyncing: false
|
||||||
|
// Keep isLocalOnly as true (sync failed)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
114
src/services/opengraphEnhancer.ts
Normal file
114
src/services/opengraphEnhancer.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
|
// Cache for OpenGraph data to avoid repeated requests
|
||||||
|
const ogCache = new Map<string, Record<string, unknown>>()
|
||||||
|
|
||||||
|
function getCachedOgData(url: string): Record<string, unknown> | null {
|
||||||
|
const cached = ogCache.get(url)
|
||||||
|
if (!cached) return null
|
||||||
|
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedOgData(url: string, data: Record<string, unknown>): void {
|
||||||
|
ogCache.set(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhances a ReadItem with OpenGraph data
|
||||||
|
* Only fetches if the item doesn't already have good metadata
|
||||||
|
*/
|
||||||
|
export async function enhanceReadItemWithOpenGraph(item: ReadItem): Promise<ReadItem> {
|
||||||
|
// Skip if we already have good metadata
|
||||||
|
if (item.title && item.title !== fallbackTitleFromUrl(item.url || '') && item.image) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.url) return item
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
let ogData = getCachedOgData(item.url)
|
||||||
|
|
||||||
|
if (!ogData) {
|
||||||
|
// Fetch OpenGraph data
|
||||||
|
const fetchedOgData = await fetchOpenGraph(item.url)
|
||||||
|
if (fetchedOgData) {
|
||||||
|
ogData = fetchedOgData
|
||||||
|
setCachedOgData(item.url, fetchedOgData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ogData) return item
|
||||||
|
|
||||||
|
// Enhance the item with OpenGraph data
|
||||||
|
const enhanced: ReadItem = { ...item }
|
||||||
|
|
||||||
|
// Use OpenGraph title if we don't have a good title
|
||||||
|
if (!enhanced.title || enhanced.title === fallbackTitleFromUrl(item.url)) {
|
||||||
|
const ogTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
|
||||||
|
if (typeof ogTitle === 'string') {
|
||||||
|
enhanced.title = ogTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use OpenGraph description if we don't have a summary
|
||||||
|
if (!enhanced.summary) {
|
||||||
|
const ogDescription = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||||
|
if (typeof ogDescription === 'string') {
|
||||||
|
enhanced.summary = ogDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use OpenGraph image if we don't have an image
|
||||||
|
if (!enhanced.image) {
|
||||||
|
const ogImage = ogData['og:image'] || ogData['twitter:image'] || ogData.image
|
||||||
|
if (typeof ogImage === 'string') {
|
||||||
|
enhanced.image = ogImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enhanced
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to enhance ReadItem with OpenGraph data:', error)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhances multiple ReadItems with OpenGraph data in parallel
|
||||||
|
* Uses batching to avoid overwhelming the service
|
||||||
|
*/
|
||||||
|
export async function enhanceReadItemsWithOpenGraph(items: ReadItem[]): Promise<ReadItem[]> {
|
||||||
|
const BATCH_SIZE = 5
|
||||||
|
const BATCH_DELAY = 1000 // 1 second between batches
|
||||||
|
|
||||||
|
const enhancedItems: ReadItem[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||||
|
const batch = items.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
// Process batch in parallel
|
||||||
|
const batchPromises = batch.map(item => enhanceReadItemWithOpenGraph(item))
|
||||||
|
const batchResults = await Promise.all(batchPromises)
|
||||||
|
enhancedItems.push(...batchResults)
|
||||||
|
|
||||||
|
// Add delay between batches to be respectful to the service
|
||||||
|
if (i + BATCH_SIZE < items.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enhancedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate fallback title from URL
|
||||||
|
function fallbackTitleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
return urlObj.hostname.replace('www.', '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,325 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
|
import { lastValueFrom, merge, Observable, toArray, tap } from 'rxjs'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
|
|
||||||
|
interface CachedProfile {
|
||||||
|
event: NostrEvent
|
||||||
|
timestamp: number
|
||||||
|
lastAccessed: number // For LRU eviction
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_CACHE_TTL = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds (profiles change less frequently than articles)
|
||||||
|
const PROFILE_CACHE_PREFIX = 'profile_cache_'
|
||||||
|
const MAX_CACHED_PROFILES = 1000 // Limit number of cached profiles to prevent quota issues
|
||||||
|
let quotaExceededLogged = false // Only log quota error once per session
|
||||||
|
|
||||||
|
// Request deduplication: track in-flight fetch requests by sorted pubkey array
|
||||||
|
// Key: sorted, comma-separated pubkeys, Value: Promise for that fetch
|
||||||
|
const inFlightRequests = new Map<string, Promise<NostrEvent[]>>()
|
||||||
|
|
||||||
|
function getProfileCacheKey(pubkey: string): string {
|
||||||
|
return `${PROFILE_CACHE_PREFIX}${pubkey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached profile from localStorage
|
||||||
|
* Returns null if not found, expired, or on error
|
||||||
|
* Updates lastAccessed timestamp for LRU eviction
|
||||||
|
*/
|
||||||
|
export function getCachedProfile(pubkey: string): NostrEvent | null {
|
||||||
|
try {
|
||||||
|
const cacheKey = getProfileCacheKey(pubkey)
|
||||||
|
const cached = localStorage.getItem(cacheKey)
|
||||||
|
if (!cached) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: CachedProfile = JSON.parse(cached)
|
||||||
|
const age = Date.now() - data.timestamp
|
||||||
|
|
||||||
|
if (age > PROFILE_CACHE_TTL) {
|
||||||
|
localStorage.removeItem(cacheKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastAccessed for LRU eviction (but don't fail if update fails)
|
||||||
|
try {
|
||||||
|
data.lastAccessed = Date.now()
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(data))
|
||||||
|
} catch {
|
||||||
|
// Ignore update errors, still return the profile
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.event
|
||||||
|
} catch (err) {
|
||||||
|
// Silently handle cache read errors (quota, invalid data, etc.)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cached profile keys for eviction
|
||||||
|
*/
|
||||||
|
function getAllCachedProfileKeys(): Array<{ key: string; lastAccessed: number }> {
|
||||||
|
const keys: Array<{ key: string; lastAccessed: number }> = []
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key && key.startsWith(PROFILE_CACHE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(key)
|
||||||
|
if (cached) {
|
||||||
|
const data: CachedProfile = JSON.parse(cached)
|
||||||
|
keys.push({
|
||||||
|
key,
|
||||||
|
lastAccessed: data.lastAccessed || data.timestamp || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during enumeration
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest profiles (LRU) to free up space
|
||||||
|
* Removes the oldest accessed profiles until we're under the limit
|
||||||
|
*/
|
||||||
|
function evictOldProfiles(targetCount: number): void {
|
||||||
|
try {
|
||||||
|
const keys = getAllCachedProfileKeys()
|
||||||
|
if (keys.length <= targetCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by lastAccessed (oldest first) and remove oldest
|
||||||
|
keys.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||||
|
const toRemove = keys.slice(0, keys.length - targetCount)
|
||||||
|
|
||||||
|
for (const { key } of toRemove) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail eviction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache a profile to localStorage
|
||||||
|
* Handles errors gracefully (quota exceeded, invalid data, etc.)
|
||||||
|
* Implements LRU eviction when cache is full
|
||||||
|
*/
|
||||||
|
export function cacheProfile(profile: NostrEvent): void {
|
||||||
|
try {
|
||||||
|
if (profile.kind !== 0) {
|
||||||
|
return // Only cache kind:0 (profile) events
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = getProfileCacheKey(profile.pubkey)
|
||||||
|
|
||||||
|
// Check if we need to evict before caching
|
||||||
|
const existingKeys = getAllCachedProfileKeys()
|
||||||
|
if (existingKeys.length >= MAX_CACHED_PROFILES) {
|
||||||
|
// Check if this profile is already cached
|
||||||
|
const alreadyCached = existingKeys.some(k => k.key === cacheKey)
|
||||||
|
if (!alreadyCached) {
|
||||||
|
// Evict oldest profiles to make room (keep 90% of max)
|
||||||
|
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached: CachedProfile = {
|
||||||
|
event: profile,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
}
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
|
} catch (err) {
|
||||||
|
// Handle quota exceeded by evicting and retrying once
|
||||||
|
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
|
||||||
|
if (!quotaExceededLogged) {
|
||||||
|
console.warn(`[npub-cache] localStorage quota exceeded, evicting old profiles...`)
|
||||||
|
quotaExceededLogged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try evicting more aggressively and retry
|
||||||
|
try {
|
||||||
|
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.5))
|
||||||
|
const cached: CachedProfile = {
|
||||||
|
event: profile,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
}
|
||||||
|
localStorage.setItem(getProfileCacheKey(profile.pubkey), JSON.stringify(cached))
|
||||||
|
} catch {
|
||||||
|
// Silently fail if still can't cache - don't block the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Silently handle other caching errors (invalid data, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch load multiple profiles from localStorage cache
|
||||||
|
* Returns a Map of pubkey -> NostrEvent for all found profiles
|
||||||
|
*/
|
||||||
|
export function loadCachedProfiles(pubkeys: string[]): Map<string, NostrEvent> {
|
||||||
|
const cached = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
const profile = getCachedProfile(pubkey)
|
||||||
|
if (profile) {
|
||||||
|
cached.set(pubkey, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches profile metadata (kind:0) for a list of pubkeys
|
* Fetches profile metadata (kind:0) for a list of pubkeys
|
||||||
* Stores profiles in the event store and optionally to local relays
|
* Checks localStorage cache first, then fetches from relays for missing/expired profiles
|
||||||
|
* Stores profiles in the event store and caches to localStorage
|
||||||
|
* Implements request deduplication to prevent duplicate relay requests for the same pubkey sets
|
||||||
*/
|
*/
|
||||||
export const fetchProfiles = async (
|
export const fetchProfiles = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
eventStore: IEventStore,
|
eventStore: IEventStore,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
onEvent?: (event: NostrEvent) => void
|
||||||
): Promise<NostrEvent[]> => {
|
): Promise<NostrEvent[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniquePubkeys = Array.from(new Set(pubkeys))
|
const uniquePubkeys = Array.from(new Set(pubkeys)).sort()
|
||||||
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
// Check for in-flight request with same pubkey set (deduplication)
|
||||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
const requestKey = uniquePubkeys.join(',')
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
const existingRequest = inFlightRequests.get(requestKey)
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest
|
||||||
|
}
|
||||||
|
|
||||||
// Keep only the most recent profile for each pubkey
|
// Create the fetch promise and track it
|
||||||
const profilesByPubkey = new Map<string, NostrEvent>()
|
const fetchPromise = (async () => {
|
||||||
|
// First, check localStorage cache for all requested profiles
|
||||||
|
const cachedProfiles = loadCachedProfiles(uniquePubkeys)
|
||||||
|
const profilesByPubkey = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
const processEvent = (event: NostrEvent) => {
|
// Add cached profiles to the map and EventStore
|
||||||
const existing = profilesByPubkey.get(event.pubkey)
|
for (const [pubkey, profile] of cachedProfiles.entries()) {
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
profilesByPubkey.set(pubkey, profile)
|
||||||
profilesByPubkey.set(event.pubkey, event)
|
// Ensure cached profiles are also in EventStore for consistency
|
||||||
// Store in event store immediately
|
eventStore.add(profile)
|
||||||
eventStore.add(event)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const local$ = localRelays.length > 0
|
// Determine which pubkeys need to be fetched from relays
|
||||||
? relayPool
|
const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey))
|
||||||
.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
|
// If all profiles are cached, return early
|
||||||
? relayPool
|
if (pubkeysToFetch.length === 0) {
|
||||||
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
|
return Array.from(profilesByPubkey.values())
|
||||||
.pipe(
|
}
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => processEvent(event)),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
|
|
||||||
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
// Fetch missing profiles from relays
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||||
|
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||||
|
const hasPurplePages = relayUrls.some(url => url.includes('purplepag.es'))
|
||||||
|
if (!hasPurplePages) {
|
||||||
|
console.warn(`[fetch-profiles] purplepag.es not in active relay pool, adding it temporarily`)
|
||||||
|
// Add purplepag.es if it's not in the pool (it might not have connected yet)
|
||||||
|
const purplePagesUrl = 'wss://purplepag.es'
|
||||||
|
if (!relayPool.relays.has(purplePagesUrl)) {
|
||||||
|
relayPool.group([purplePagesUrl])
|
||||||
|
}
|
||||||
|
// Ensure it's included in the remote relays for this fetch
|
||||||
|
if (!remoteRelays.includes(purplePagesUrl)) {
|
||||||
|
remoteRelays.push(purplePagesUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fetchedPubkeys = new Set<string>()
|
||||||
|
|
||||||
const profiles = Array.from(profilesByPubkey.values())
|
const processEvent = (event: NostrEvent) => {
|
||||||
|
fetchedPubkeys.add(event.pubkey)
|
||||||
|
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)
|
||||||
|
// Cache to localStorage for future use
|
||||||
|
cacheProfile(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rebroadcast profiles to local/all relays based on settings
|
const local$ = localRelays.length > 0
|
||||||
if (profiles.length > 0) {
|
? relayPool
|
||||||
await rebroadcastEvents(profiles, relayPool, settings)
|
.req(localRelays, { kinds: [0], authors: pubkeysToFetch })
|
||||||
}
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
|
||||||
|
tap((event: NostrEvent) => processEvent(event)),
|
||||||
|
completeOnEose()
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
return profiles
|
const remote$ = remoteRelays.length > 0
|
||||||
|
? relayPool
|
||||||
|
.req(remoteRelays, { kinds: [0], authors: pubkeysToFetch })
|
||||||
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
|
||||||
|
tap((event: NostrEvent) => processEvent(event)),
|
||||||
|
completeOnEose()
|
||||||
|
)
|
||||||
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||||
|
|
||||||
|
const profiles = Array.from(profilesByPubkey.values())
|
||||||
|
|
||||||
|
const missingPubkeys = pubkeysToFetch.filter(p => !fetchedPubkeys.has(p))
|
||||||
|
if (missingPubkeys.length > 0) {
|
||||||
|
console.warn(`[fetch-profiles] ${missingPubkeys.length} profiles not found on relays:`, missingPubkeys.map(p => p.slice(0, 16) + '...'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
|
||||||
|
// Profile images will be cached by Service Worker when they're actually displayed.
|
||||||
|
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
|
||||||
|
|
||||||
|
// Rebroadcast profiles to local/all relays based on settings
|
||||||
|
// Only rebroadcast newly fetched profiles, not cached ones
|
||||||
|
const newlyFetchedProfiles = profiles.filter(p => pubkeysToFetch.includes(p.pubkey))
|
||||||
|
if (newlyFetchedProfiles.length > 0) {
|
||||||
|
await rebroadcastEvents(newlyFetchedProfiles, relayPool, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Track the request
|
||||||
|
inFlightRequests.set(requestKey, fetchPromise)
|
||||||
|
|
||||||
|
// Clean up when request completes (success or failure)
|
||||||
|
fetchPromise.finally(() => {
|
||||||
|
inFlightRequests.delete(requestKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
return fetchPromise
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch profiles:', error)
|
console.error('[fetch-profiles] Failed to fetch profiles:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,3 +110,4 @@ export async function fetchReadableContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
|
||||||
export interface UserRelayInfo {
|
export interface UserRelayInfo {
|
||||||
url: string
|
url: string
|
||||||
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
|
|||||||
alwaysIncludeLocal
|
alwaysIncludeLocal
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
|
// Normalize all URLs for consistent comparison and deduplication
|
||||||
|
const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl))
|
||||||
|
const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl))
|
||||||
|
|
||||||
const relaySet = new Set<string>()
|
const relaySet = new Set<string>()
|
||||||
const blockedSet = new Set(blocked)
|
const normalizedRelaySet = new Set<string>()
|
||||||
|
|
||||||
// Helper to check if relay should be included
|
// Helper to check if relay should be included (using normalized URLs)
|
||||||
const shouldInclude = (url: string): boolean => {
|
const shouldInclude = (normalizedUrl: string): boolean => {
|
||||||
// Always include local relays
|
// Always include local relays
|
||||||
if (alwaysIncludeLocal.includes(url)) return true
|
if (normalizedLocal.has(normalizedUrl)) return true
|
||||||
// Otherwise check if blocked
|
// Otherwise check if blocked
|
||||||
return !blockedSet.has(url)
|
return !normalizedBlocked.has(normalizedUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add hardcoded relays
|
// Add hardcoded relays (normalized)
|
||||||
for (const url of hardcoded) {
|
for (const url of hardcoded) {
|
||||||
if (shouldInclude(url)) relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add bunker relays
|
// Add bunker relays (normalized)
|
||||||
for (const url of bunker) {
|
for (const url of bunker) {
|
||||||
if (shouldInclude(url)) relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user relays (treating 'both' and 'read' as applicable for queries)
|
// Add user relays (normalized)
|
||||||
for (const relay of userList) {
|
for (const relay of userList) {
|
||||||
if (shouldInclude(relay.url)) relaySet.add(relay.url)
|
const normalized = normalizeRelayUrl(relay.url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(relay.url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always ensure local relays are present
|
// Always ensure local relays are present (normalized check)
|
||||||
for (const url of alwaysIncludeLocal) {
|
for (const url of alwaysIncludeLocal) {
|
||||||
relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (!normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(relaySet)
|
return Array.from(relaySet)
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
import { getLocalRelays, getFallbackContentRelays } from '../config/relays'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local relays that are always included
|
* Local relays that are always included
|
||||||
*/
|
*/
|
||||||
export const ALWAYS_LOCAL_RELAYS = [
|
export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
|
||||||
'ws://localhost:10547',
|
|
||||||
'ws://localhost:4869'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hardcoded relays that are always included
|
* Hardcoded relays that are always included (minimal reliable set)
|
||||||
|
* Derived from RELAY_CONFIGS fallback relays
|
||||||
*/
|
*/
|
||||||
export const HARDCODED_RELAYS = [
|
export const HARDCODED_RELAYS = getFallbackContentRelays()
|
||||||
'wss://relay.nostr.band'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets active relay URLs from the relay pool
|
* Gets active relay URLs from the relay pool
|
||||||
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
|
|||||||
return prioritizeLocalRelays(urls)
|
return prioritizeLocalRelays(urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface RelaySetChangeSummary {
|
||||||
* Normalizes a relay URL to match what applesauce-relay stores internally
|
added: string[]
|
||||||
* Adds trailing slash for URLs without a path
|
removed: string[]
|
||||||
*/
|
|
||||||
function normalizeRelayUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
// If the pathname is empty or just "/", ensure it ends with "/"
|
|
||||||
if (parsed.pathname === '' || parsed.pathname === '/') {
|
|
||||||
return url.endsWith('/') ? url : url + '/'
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
// If URL parsing fails, return as-is
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a new relay set to the pool: adds missing relays, removes extras
|
* Applies a new relay set to the pool: adds missing relays, removes extras
|
||||||
|
* Always preserves local relays even if not in finalUrls
|
||||||
|
* @returns Summary of changes for debugging
|
||||||
*/
|
*/
|
||||||
export function applyRelaySetToPool(
|
export function applyRelaySetToPool(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
finalUrls: string[]
|
finalUrls: string[],
|
||||||
): void {
|
options?: { preserveAlwaysLocal?: boolean }
|
||||||
// Normalize all URLs to match pool's internal format
|
): RelaySetChangeSummary {
|
||||||
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
const preserveLocal = options?.preserveAlwaysLocal !== false // default true
|
||||||
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
|
||||||
|
|
||||||
|
// Ensure local relays are always included
|
||||||
|
const urlsWithLocal = preserveLocal
|
||||||
|
? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
|
||||||
|
: finalUrls
|
||||||
|
|
||||||
|
// Normalize all URLs consistently for comparison
|
||||||
|
const normalizedCurrent = new Set(
|
||||||
|
Array.from(relayPool.relays.keys()).map(normalizeRelayUrl)
|
||||||
|
)
|
||||||
|
const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl))
|
||||||
|
|
||||||
// Add new relays (use original URLs for adding, not normalized)
|
// Map normalized URLs back to original for adding
|
||||||
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
const normalizedToOriginal = new Map<string, string>()
|
||||||
|
for (const url of urlsWithLocal) {
|
||||||
if (toAdd.length > 0) {
|
normalizedToOriginal.set(normalizeRelayUrl(url), url)
|
||||||
relayPool.group(toAdd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove relays not in target (but always keep local relays)
|
// Find relays to add (not in current pool)
|
||||||
|
const toAdd: string[] = []
|
||||||
|
for (const normalizedUrl of normalizedTarget) {
|
||||||
|
if (!normalizedCurrent.has(normalizedUrl)) {
|
||||||
|
const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl
|
||||||
|
toAdd.push(originalUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find relays to remove (not in target, but preserve local relays)
|
||||||
|
const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl))
|
||||||
const toRemove: string[] = []
|
const toRemove: string[] = []
|
||||||
for (const url of currentUrls) {
|
for (const currentUrl of relayPool.relays.keys()) {
|
||||||
// Check if this normalized URL is in the target set
|
const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
|
||||||
if (!normalizedTargetUrls.has(url)) {
|
if (!normalizedTarget.has(normalizedCurrentUrl)) {
|
||||||
// Also check if it's a local relay (check both normalized and original forms)
|
// Always preserve local relays
|
||||||
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
|
if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
|
||||||
normalizeRelayUrl(localUrl) === url || localUrl === url
|
toRemove.push(currentUrl)
|
||||||
)
|
|
||||||
if (!isLocal) {
|
|
||||||
toRemove.push(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply changes
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
relayPool.group(toAdd)
|
||||||
|
}
|
||||||
|
|
||||||
for (const url of toRemove) {
|
for (const url of toRemove) {
|
||||||
const relay = relayPool.relays.get(url)
|
const relay = relayPool.relays.get(url)
|
||||||
if (relay) {
|
if (relay) {
|
||||||
try {
|
try {
|
||||||
// Only close if relay is actually connected or attempting to connect
|
|
||||||
// This helps avoid WebSocket warnings for connections that never started
|
|
||||||
relay.close()
|
relay.close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress errors when closing relays that haven't fully connected yet
|
// Suppress errors when closing relays that haven't fully connected yet
|
||||||
// This can happen when switching relay sets before connections establish
|
|
||||||
console.debug('[relay-manager] Ignoring error when closing relay:', url, error)
|
|
||||||
}
|
}
|
||||||
relayPool.relays.delete(url)
|
relayPool.relays.delete(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return summary for debugging (useful for understanding relay churn)
|
||||||
|
if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) {
|
||||||
|
console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: toAdd, removed: toRemove }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export interface UserSettings {
|
|||||||
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
||||||
// Text-to-Speech settings
|
// Text-to-Speech settings
|
||||||
ttsDefaultSpeed?: number // default: 2.1
|
ttsDefaultSpeed?: number // default: 2.1
|
||||||
|
// Link color for article content (theme-specific)
|
||||||
|
linkColorDark?: string // default: #38bdf8 (sky-400)
|
||||||
|
linkColorLight?: string // default: #3b82f6 (blue-500)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
--color-text-muted: #71717a; /* zinc-500 */
|
--color-text-muted: #71717a; /* zinc-500 */
|
||||||
--color-primary: #6366f1; /* indigo-500 */
|
--color-primary: #6366f1; /* indigo-500 */
|
||||||
--color-primary-hover: #4f46e5; /* indigo-600 */
|
--color-primary-hover: #4f46e5; /* indigo-600 */
|
||||||
|
--color-link: var(--color-link-dark, #38bdf8); /* sky-400 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme */
|
/* Light theme */
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
--color-text-muted: #6b7280; /* gray-500 */
|
--color-text-muted: #6b7280; /* gray-500 */
|
||||||
--color-primary: #4f46e5; /* indigo-600 */
|
--color-primary: #4f46e5; /* indigo-600 */
|
||||||
--color-primary-hover: #4338ca; /* indigo-700 */
|
--color-primary-hover: #4338ca; /* indigo-700 */
|
||||||
|
--color-link: var(--color-link-light, #3b82f6); /* blue-500 */
|
||||||
|
|
||||||
/* Highlight colors for light theme - use same Tailwind colors */
|
/* Highlight colors for light theme - use same Tailwind colors */
|
||||||
--highlight-color-mine: #fde047; /* yellow-300 */
|
--highlight-color-mine: #fde047; /* yellow-300 */
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
--color-text-muted: #71717a;
|
--color-text-muted: #71717a;
|
||||||
--color-primary: #6366f1;
|
--color-primary: #6366f1;
|
||||||
--color-primary-hover: #4f46e5;
|
--color-primary-hover: #4f46e5;
|
||||||
|
--color-link: var(--color-link-dark, #38bdf8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
--color-text-muted: #6b7280;
|
--color-text-muted: #6b7280;
|
||||||
--color-primary: #4f46e5;
|
--color-primary: #4f46e5;
|
||||||
--color-primary-hover: #4338ca;
|
--color-primary-hover: #4338ca;
|
||||||
|
--color-link: var(--color-link-light, #3b82f6);
|
||||||
|
|
||||||
/* Standard highlight colors */
|
/* Standard highlight colors */
|
||||||
--highlight-color-mine: #fde047;
|
--highlight-color-mine: #fde047;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
.compact-read-btn:active { transform: translateY(1px); }
|
.compact-read-btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
.bookmark-type { font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
|
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||||
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
|
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
|
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
|
||||||
@@ -76,6 +76,179 @@
|
|||||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||||
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
|
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
/* Medium-sized card view */
|
||||||
|
.individual-bookmark.card-view {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-bg-elevated);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individual-bookmark.card-view:hover {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.1) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail .thumbnail-placeholder {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-border-subtle);
|
||||||
|
opacity: 0.6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-content.article-summary {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer .bookmark-type {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer .bookmark-type .content-type-icon {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer .bookmark-date,
|
||||||
|
.card-content .bookmark-footer .bookmark-date-link {
|
||||||
|
display: inline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reading progress as separator - always shown, full width */
|
||||||
|
.reading-progress-separator {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 0.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-separator .progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.5px;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Large preview view */
|
/* Large preview view */
|
||||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||||
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||||
@@ -90,7 +263,12 @@
|
|||||||
.large-read-button:hover { background: var(--color-primary-hover); }
|
.large-read-button:hover { background: var(--color-primary-hover); }
|
||||||
|
|
||||||
/* Blog cards (Explore) */
|
/* Blog cards (Explore) */
|
||||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
.explore-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: var(--main-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||||
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
||||||
@@ -99,7 +277,15 @@
|
|||||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||||
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
||||||
.explore-empty { color: var(--color-text-secondary); }
|
.explore-empty { color: var(--color-text-secondary); }
|
||||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
.explore-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.explore-grid.single-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||||
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||||
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
||||||
@@ -115,6 +301,74 @@
|
|||||||
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
|
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
|
||||||
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
||||||
|
/* Responsive design for medium-sized cards */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.individual-bookmark.card-view {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-layout {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-header {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text-content {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-content {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-footer {
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card-layout {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-header {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail .thumbnail-placeholder {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content .bookmark-content {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.explore-container { padding: 1rem; }
|
.explore-container { padding: 1rem; }
|
||||||
.explore-header h1 { font-size: 2rem; }
|
.explore-header h1 { font-size: 2rem; }
|
||||||
|
|||||||
@@ -43,6 +43,13 @@
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
text-align: var(--paragraph-alignment, justify);
|
text-align: var(--paragraph-alignment, justify);
|
||||||
}
|
}
|
||||||
|
.preview-content a {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.preview-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||||
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
|||||||
@@ -73,7 +73,8 @@
|
|||||||
|
|
||||||
/* Align highlight list width with profile card width on /my */
|
/* Align highlight list width with profile card width on /my */
|
||||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
.explore-header .profile-header-wrapper { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||||
|
.explore-header .author-card { max-width: none; margin: 0; width: auto; flex: 1; }
|
||||||
|
|
||||||
/* Hide tab labels on mobile to save space */
|
/* Hide tab labels on mobile to save space */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -10,6 +10,71 @@
|
|||||||
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
||||||
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||||
|
|
||||||
|
/* Profile header */
|
||||||
|
.profile-header-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove horizontal padding when inside explore-header to match tabs width */
|
||||||
|
.explore-header .profile-header-wrapper {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-with-menu {
|
||||||
|
position: relative;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile card menu - inside card, bottom-right */
|
||||||
|
.profile-card-menu-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.25rem;
|
||||||
|
top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item svg {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.author-card-container {
|
.author-card-container {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
@@ -26,5 +91,13 @@
|
|||||||
.author-card-avatar svg { font-size: 2rem; }
|
.author-card-avatar svg { font-size: 2rem; }
|
||||||
.author-card-name { font-size: 0.95rem; }
|
.author-card-name { font-size: 0.95rem; }
|
||||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||||
}
|
|
||||||
|
|
||||||
|
.profile-header-wrapper {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-wrapper {
|
||||||
|
right: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,20 +57,21 @@
|
|||||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
||||||
/* Tame images from external content */
|
/* Tame images from external content */
|
||||||
.reader .reader-html img, .reader .reader-markdown img {
|
.reader .reader-html img, .reader .reader-markdown img {
|
||||||
max-width: var(--image-max-width, 100%);
|
width: var(--image-width, auto);
|
||||||
max-height: 70vh;
|
max-width: 100%;
|
||||||
|
max-height: var(--image-max-height, 70vh);
|
||||||
height: auto;
|
height: auto;
|
||||||
width: auto;
|
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0.75rem auto;
|
margin: 0.75rem auto;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
/* Headlines with Tailwind typography */
|
/* Headlines with Tailwind typography */
|
||||||
.reader-markdown h1, .reader-html h1 {
|
.reader-markdown h1, .reader-html h1 {
|
||||||
font-size: 2.25rem; /* text-4xl */
|
font-size: 2.25rem; /* text-4xl */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-top: 2rem;
|
margin-top: 5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
font-size: 1.875rem; /* text-3xl */
|
font-size: 1.875rem; /* text-3xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 1.75rem;
|
margin-top: 4.5rem;
|
||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
font-size: 1.5rem; /* text-2xl */
|
font-size: 1.5rem; /* text-2xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1.5rem;
|
margin-top: 4rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
font-size: 1.25rem; /* text-xl */
|
font-size: 1.25rem; /* text-xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1.25rem;
|
margin-top: 3.5rem;
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.625rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
font-size: 1.125rem; /* text-lg */
|
font-size: 1.125rem; /* text-lg */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -110,12 +111,13 @@
|
|||||||
font-size: 1rem; /* text-base */
|
font-size: 1rem; /* text-base */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.reader-markdown p { margin: 0.5rem 0; }
|
.reader-markdown p { margin: 1.5rem 0; }
|
||||||
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
.reader-html p { margin: 1.5rem 0; }
|
||||||
|
.reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
||||||
/* Lists */
|
/* Lists */
|
||||||
.reader-markdown ul, .reader-html ul {
|
.reader-markdown ul, .reader-html ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
@@ -158,7 +160,7 @@
|
|||||||
opacity: 0.69;
|
opacity: 0.69;
|
||||||
margin: 2.5rem 0;
|
margin: 2.5rem 0;
|
||||||
}
|
}
|
||||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
.reader-markdown a { color: var(--color-link); text-decoration: none; }
|
||||||
.reader-markdown a:hover { text-decoration: underline; }
|
.reader-markdown a:hover { text-decoration: underline; }
|
||||||
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
@@ -169,6 +171,49 @@
|
|||||||
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
|
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
|
||||||
|
/* Tables - subtle styling that matches the app theme */
|
||||||
|
.reader-markdown table, .reader-html table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.reader-markdown thead, .reader-html thead {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
.reader-markdown th, .reader-html th {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.reader-markdown td, .reader-html td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.reader-markdown tbody tr:last-child td, .reader-html tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
/* Subtle row striping for better readability */
|
||||||
|
.reader-markdown tbody tr:nth-child(even), .reader-html tbody tr:nth-child(even) {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
}
|
||||||
|
/* Table alignment support */
|
||||||
|
.reader-markdown th[align="center"], .reader-html th[align="center"],
|
||||||
|
.reader-markdown td[align="center"], .reader-html td[align="center"] {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.reader-markdown th[align="right"], .reader-html th[align="right"],
|
||||||
|
.reader-markdown td[align="right"], .reader-html td[align="right"] {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
/* Mobile: prevent code blocks from causing horizontal overflow */
|
/* Mobile: prevent code blocks from causing horizontal overflow */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.reader-markdown pre, .reader-html pre {
|
.reader-markdown pre, .reader-html pre {
|
||||||
@@ -189,12 +234,20 @@
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
/* Reduce padding on mobile for better fit */
|
||||||
|
.reader-markdown table td, .reader-html table td,
|
||||||
|
.reader-markdown table th, .reader-html table th {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-markdown img, .reader-html img {
|
.reader-markdown img, .reader-html img {
|
||||||
|
width: var(--image-width, auto) !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
width: auto !important;
|
max-height: var(--image-max-height, 70vh) !important;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,3 +323,21 @@
|
|||||||
|
|
||||||
/* Reading Progress Indicator - now using Tailwind utilities in component */
|
/* Reading Progress Indicator - now using Tailwind utilities in component */
|
||||||
|
|
||||||
|
/* Profile loading state - subtle opacity pulse animation */
|
||||||
|
.profile-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: profile-loading-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.profile-loading {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-loading-pulse {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
|
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
|
||||||
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
||||||
|
|
||||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
.highlights-list { overflow-y: auto; padding: 1rem; padding-bottom: 10rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||||
.highlight-item:hover { border-color: var(--color-primary); }
|
.highlight-item:hover { border-color: var(--color-primary); }
|
||||||
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||||
@@ -149,7 +149,40 @@
|
|||||||
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||||
|
|
||||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
||||||
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
.highlight-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
border-left: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
/* Aggressive wrapping for long words/URLs inside highlights */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
.highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color, #fde047) 35%, transparent);
|
||||||
|
padding: 0 0.1em;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.highlight-item.level-mine .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-mine, #fde047) 40%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-item.level-friends .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-item.level-nostrverse .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-context-prefix {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
||||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
|
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
|
||||||
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
||||||
@@ -177,7 +210,10 @@
|
|||||||
padding: 0.25rem; /* CompactButton base */
|
padding: 0.25rem; /* CompactButton base */
|
||||||
}
|
}
|
||||||
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
||||||
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); bottom: auto; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
||||||
|
/* Open menu upward when there's not enough space below */
|
||||||
|
.highlight-menu-wrapper:last-child .highlight-menu,
|
||||||
|
.highlight-item:last-child .highlight-menu-wrapper .highlight-menu { top: auto; bottom: calc(100% + 4px); }
|
||||||
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||||
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
|
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
|
||||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header-right {
|
.sidebar-header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -260,6 +267,42 @@
|
|||||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||||
|
|
||||||
/* Bookmark filters */
|
/* Bookmark filters */
|
||||||
|
.bookmark-filters-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters-wrapper > .bookmark-filters {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters-wrapper > .compact-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: none;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-filters-wrapper > .compact-button:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
.bookmark-filters {
|
.bookmark-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
13
src/sw.ts
13
src/sw.ts
@@ -23,13 +23,15 @@ sw.skipWaiting()
|
|||||||
clientsClaim()
|
clientsClaim()
|
||||||
|
|
||||||
|
|
||||||
// Runtime cache: Cross-origin images
|
// Runtime cache: All images (cross-origin and same-origin)
|
||||||
// This preserves the existing image caching behavior
|
// Cache both external images and any internal image assets
|
||||||
registerRoute(
|
registerRoute(
|
||||||
({ request, url }) => {
|
({ request, url }) => {
|
||||||
const isImage = request.destination === 'image' ||
|
const isImage = request.destination === 'image' ||
|
||||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||||
return isImage && url.origin !== sw.location.origin
|
// Cache all images, not just cross-origin ones
|
||||||
|
// This ensures article images from any source get cached
|
||||||
|
return isImage
|
||||||
},
|
},
|
||||||
new StaleWhileRevalidate({
|
new StaleWhileRevalidate({
|
||||||
cacheName: 'boris-images',
|
cacheName: 'boris-images',
|
||||||
@@ -41,6 +43,11 @@ registerRoute(
|
|||||||
new CacheableResponsePlugin({
|
new CacheableResponsePlugin({
|
||||||
statuses: [0, 200],
|
statuses: [0, 200],
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
cacheWillUpdate: async ({ response }) => {
|
||||||
|
return response.ok ? response : null
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ export interface Highlight {
|
|||||||
comment?: string // optional comment about the highlight
|
comment?: string // optional comment about the highlight
|
||||||
// Level classification (computed based on user's context)
|
// Level classification (computed based on user's context)
|
||||||
level?: HighlightLevel
|
level?: HighlightLevel
|
||||||
// Relay tracking for offline/local-only highlights
|
// Relay tracking for local-only highlights
|
||||||
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
||||||
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
||||||
isLocalOnly?: boolean // true if only published to local relays
|
isLocalOnly?: boolean // true if only published to local relays
|
||||||
isOfflineCreated?: boolean // true if created while in flight mode (offline)
|
|
||||||
isSyncing?: boolean // true if currently being synced to remote relays
|
isSyncing?: boolean // true if currently being synced to remote relays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,23 @@ export const HIGHLIGHT_COLORS = [
|
|||||||
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
{ name: 'Purple', value: '#9333ea' } // purple-600
|
{ name: 'Purple', value: '#9333ea' } // purple-600
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Tailwind color palette for link colors - optimized for dark themes
|
||||||
|
export const LINK_COLORS_DARK = [
|
||||||
|
{ name: 'Sky Blue', value: '#38bdf8' }, // sky-400
|
||||||
|
{ name: 'Cyan', value: '#22d3ee' }, // cyan-400
|
||||||
|
{ name: 'Light Blue', value: '#60a5fa' }, // blue-400
|
||||||
|
{ name: 'Indigo Light', value: '#818cf8' }, // indigo-400
|
||||||
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
|
{ name: 'Purple', value: '#9333ea' } // purple-600
|
||||||
|
]
|
||||||
|
|
||||||
|
// Tailwind color palette for link colors - optimized for light themes
|
||||||
|
export const LINK_COLORS_LIGHT = [
|
||||||
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
|
{ name: 'Indigo', value: '#6366f1' }, // indigo-500
|
||||||
|
{ name: 'Purple', value: '#9333ea' }, // purple-600
|
||||||
|
{ name: 'Sky Blue', value: '#0ea5e9' }, // sky-500 (darker for light bg)
|
||||||
|
{ name: 'Cyan', value: '#06b6d4' }, // cyan-500 (darker for light bg)
|
||||||
|
{ name: 'Teal', value: '#14b8a6' } // teal-500
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
// Extract pubkeys from nprofile strings in content
|
|
||||||
import { READING_PROGRESS } from '../config/kinds'
|
import { READING_PROGRESS } from '../config/kinds'
|
||||||
|
|
||||||
export const extractNprofilePubkeys = (content: string): string[] => {
|
|
||||||
const nprofileRegex = /nprofile1[a-z0-9]+/gi
|
|
||||||
const matches = content.match(nprofileRegex) || []
|
|
||||||
const unique = new Set<string>(matches)
|
|
||||||
return Array.from(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
||||||
|
|
||||||
export interface UrlClassification {
|
export interface UrlClassification {
|
||||||
@@ -47,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
|
|||||||
return { type: 'article' }
|
return { type: 'article' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a relay URL to match what applesauce-relay stores internally
|
||||||
|
* Adds trailing slash for URLs without a path
|
||||||
|
*/
|
||||||
|
export function normalizeRelayUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// If the pathname is empty or just "/", ensure it ends with "/"
|
||||||
|
if (parsed.pathname === '' || parsed.pathname === '/') {
|
||||||
|
return url.endsWith('/') ? url : url + '/'
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, return as-is
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
|
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,10 +64,36 @@ export function tryMarkInTextNodes(
|
|||||||
let actualIndex = index
|
let actualIndex = index
|
||||||
if (useNormalized) {
|
if (useNormalized) {
|
||||||
// Map normalized index back to original text
|
// Map normalized index back to original text
|
||||||
let normalizedIdx = 0
|
// Build normalized text while tracking original positions
|
||||||
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
|
let normalizedPos = 0
|
||||||
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
|
let prevWasWs = false
|
||||||
actualIndex = i + 1
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i]
|
||||||
|
const isWs = /\s/.test(ch)
|
||||||
|
|
||||||
|
if (isWs) {
|
||||||
|
// Whitespace: count only at start of whitespace sequence
|
||||||
|
if (!prevWasWs) {
|
||||||
|
if (normalizedPos === index) {
|
||||||
|
actualIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
normalizedPos++
|
||||||
|
}
|
||||||
|
prevWasWs = true
|
||||||
|
} else {
|
||||||
|
// Non-whitespace: count each character
|
||||||
|
if (normalizedPos === index) {
|
||||||
|
actualIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
normalizedPos++
|
||||||
|
prevWasWs = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we didn't find exact match, use last position
|
||||||
|
if (normalizedPos < index) {
|
||||||
|
actualIndex = text.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { tryMarkInTextNodes } from './domUtils'
|
import { tryMarkInTextNodes } from './domUtils'
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
html: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory cache for highlighted HTML results
|
||||||
|
const highlightCache = new Map<string, CacheEntry>()
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||||
|
const MAX_CACHE_SIZE = 50 // FIFO eviction after this many entries
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key from content and highlights
|
||||||
|
*/
|
||||||
|
function getCacheKey(html: string, highlights: Highlight[], highlightStyle: string): string {
|
||||||
|
// Create a stable key from content hash (first 200 chars) and highlight IDs
|
||||||
|
const contentHash = html.slice(0, 200).replace(/\s+/g, ' ').trim()
|
||||||
|
const highlightIds = highlights
|
||||||
|
.map(h => h.id)
|
||||||
|
.sort()
|
||||||
|
.join(',')
|
||||||
|
return `${contentHash.length}:${highlightIds}:${highlightStyle}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old cache entries and enforce size limit
|
||||||
|
*/
|
||||||
|
function cleanupCache(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
const entries = Array.from(highlightCache.entries())
|
||||||
|
|
||||||
|
// Remove expired entries
|
||||||
|
for (const [key, entry] of entries) {
|
||||||
|
if (now - entry.timestamp > CACHE_TTL) {
|
||||||
|
highlightCache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce size limit with FIFO eviction (oldest first)
|
||||||
|
if (highlightCache.size > MAX_CACHE_SIZE) {
|
||||||
|
const sortedEntries = Array.from(highlightCache.entries())
|
||||||
|
.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||||
|
const toRemove = sortedEntries.slice(0, highlightCache.size - MAX_CACHE_SIZE)
|
||||||
|
for (const [key] of toRemove) {
|
||||||
|
highlightCache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||||
*/
|
*/
|
||||||
@@ -13,19 +61,24 @@ export function applyHighlightsToHTML(
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cacheKey = getCacheKey(html, highlights, highlightStyle)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.html
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cache periodically
|
||||||
|
cleanupCache()
|
||||||
|
|
||||||
const tempDiv = document.createElement('div')
|
const tempDiv = document.createElement('div')
|
||||||
tempDiv.innerHTML = html
|
tempDiv.innerHTML = html
|
||||||
|
|
||||||
// CRITICAL: Remove any existing highlight marks to start with clean HTML
|
// Collect all text nodes once before processing highlights (performance optimization)
|
||||||
// This prevents old broken highlights from corrupting the new rendering
|
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||||
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
|
const textNodes: Text[] = []
|
||||||
existingMarks.forEach(mark => {
|
let node: Node | null
|
||||||
// Replace the mark with its text content
|
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||||
const textNode = document.createTextNode(mark.textContent || '')
|
|
||||||
mark.parentNode?.replaceChild(textNode, mark)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
for (const highlight of highlights) {
|
for (const highlight of highlights) {
|
||||||
const searchText = highlight.content.trim()
|
const searchText = highlight.content.trim()
|
||||||
@@ -34,14 +87,6 @@ export function applyHighlightsToHTML(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Collect all text nodes
|
|
||||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
|
||||||
const textNodes: Text[] = []
|
|
||||||
let node: Node | null
|
|
||||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
|
||||||
|
|
||||||
|
|
||||||
// Try exact match first, then normalized match
|
// Try exact match first, then normalized match
|
||||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||||
@@ -51,7 +96,14 @@ export function applyHighlightsToHTML(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = tempDiv.innerHTML
|
||||||
|
|
||||||
return tempDiv.innerHTML
|
// Store in cache
|
||||||
|
highlightCache.set(cacheKey, {
|
||||||
|
html: result,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks'
|
|||||||
import { ReadItem } from '../services/readsService'
|
import { ReadItem } from '../services/readsService'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { fallbackTitleFromUrl } from './readItemMerge'
|
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||||
|
import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives ReadItems from bookmarks for external URLs:
|
* Derives ReadItems from bookmarks for external URLs:
|
||||||
* - Web bookmarks (kind:39701)
|
* - Web bookmarks (kind:39701)
|
||||||
* - Any bookmark with http(s) URLs in content or urlReferences
|
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||||
*/
|
*/
|
||||||
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
export async function deriveLinksFromBookmarks(bookmarks: Bookmark[]): Promise<ReadItem[]> {
|
||||||
const linksMap = new Map<string, ReadItem>()
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
@@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by most recent bookmark activity
|
// Get initial items sorted by most recent bookmark activity
|
||||||
return Array.from(linksMap.values()).sort((a, b) => {
|
const initialItems = Array.from(linksMap.values()).sort((a, b) => {
|
||||||
const timeA = a.readingTimestamp || 0
|
const timeA = a.readingTimestamp || 0
|
||||||
const timeB = b.readingTimestamp || 0
|
const timeB = b.readingTimestamp || 0
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Enhance with OpenGraph data
|
||||||
|
return await enhanceReadItemsWithOpenGraph(initialItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,51 @@
|
|||||||
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
|
||||||
import { getNostrUrl } from '../config/nostrGateways'
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
|
import { Tokens } from 'applesauce-content/helpers'
|
||||||
|
import { getContentPointers } from 'applesauce-factory/helpers'
|
||||||
|
import { encodeDecodeResult } from 'applesauce-core/helpers'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
|
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
|
||||||
|
* Uses applesauce Tokens.nostrLink which includes word boundary checks
|
||||||
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
|
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
|
||||||
* Also matches bare identifiers without the nostr: prefix
|
* Also matches bare identifiers without the nostr: prefix
|
||||||
*/
|
*/
|
||||||
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
|
const NOSTR_URI_REGEX = Tokens.nostrLink
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all nostr URIs from text
|
* Extract all nostr URIs from text using applesauce helpers
|
||||||
*/
|
*/
|
||||||
export function extractNostrUris(text: string): string[] {
|
export function extractNostrUris(text: string): string[] {
|
||||||
const matches = text.match(NOSTR_URI_REGEX)
|
try {
|
||||||
if (!matches) return []
|
const pointers = getContentPointers(text)
|
||||||
|
const result: string[] = []
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
pointers.forEach(pointer => {
|
||||||
return matches.map(match => {
|
try {
|
||||||
const cleanMatch = match.replace(/^nostr:/, '')
|
const encoded = encodeDecodeResult(pointer)
|
||||||
return cleanMatch
|
if (encoded) {
|
||||||
})
|
result.push(encoded)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore encoding errors, continue processing other pointers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all naddr (article) identifiers from text
|
* Extract all naddr (article) identifiers from text using applesauce helpers
|
||||||
*/
|
*/
|
||||||
export function extractNaddrUris(text: string): string[] {
|
export function extractNaddrUris(text: string): string[] {
|
||||||
const allUris = extractNostrUris(text)
|
const pointers = getContentPointers(text)
|
||||||
return allUris.filter(uri => {
|
return pointers
|
||||||
try {
|
.filter(pointer => pointer.type === 'naddr')
|
||||||
const decoded = decode(uri)
|
.map(pointer => encodeDecodeResult(pointer))
|
||||||
return decoded.type === 'naddr'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,13 +88,14 @@ export function getNostrUriLabel(encoded: string): string {
|
|||||||
try {
|
try {
|
||||||
const decoded = decode(encoded)
|
const decoded = decode(encoded)
|
||||||
|
|
||||||
|
// Use applesauce helper to extract pubkey for npub/nprofile
|
||||||
|
const pubkey = getPubkeyFromDecodeResult(decoded)
|
||||||
|
if (pubkey) {
|
||||||
|
// Use shared fallback display function and add @ for label
|
||||||
|
return `@${getNpubFallbackDisplay(pubkey)}`
|
||||||
|
}
|
||||||
|
|
||||||
switch (decoded.type) {
|
switch (decoded.type) {
|
||||||
case 'npub':
|
|
||||||
return `@${encoded.slice(0, 12)}...`
|
|
||||||
case 'nprofile': {
|
|
||||||
const npub = npubEncode(decoded.data.pubkey)
|
|
||||||
return `@${npub.slice(0, 12)}...`
|
|
||||||
}
|
|
||||||
case 'note':
|
case 'note':
|
||||||
return `note:${encoded.slice(5, 12)}...`
|
return `note:${encoded.slice(5, 12)}...`
|
||||||
case 'nevent': {
|
case 'nevent': {
|
||||||
@@ -107,17 +119,161 @@ export function getNostrUriLabel(encoded: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a standardized fallback display name for a pubkey when profile has no name
|
||||||
|
* Returns npub format: abc1234... (without @ prefix)
|
||||||
|
* Components should add @ prefix when rendering mentions/links
|
||||||
|
* @param pubkey The pubkey in hex format
|
||||||
|
* @returns Formatted npub display string without @ prefix
|
||||||
|
*/
|
||||||
|
export function getNpubFallbackDisplay(pubkey: string): string {
|
||||||
|
try {
|
||||||
|
const npub = npubEncode(pubkey)
|
||||||
|
// Remove "npub1" prefix (5 chars) and show next 7 chars
|
||||||
|
return `${npub.slice(5, 12)}...`
|
||||||
|
} catch {
|
||||||
|
// Fallback to shortened pubkey if encoding fails
|
||||||
|
return `${pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for a profile with consistent priority order
|
||||||
|
* Returns: profile.name || profile.display_name || profile.nip05 || npub fallback
|
||||||
|
* This function works with parsed profile objects (from useEventModel)
|
||||||
|
* For NostrEvent objects, use extractProfileDisplayName from profileUtils
|
||||||
|
* @param profile Profile object with optional name, display_name, and nip05 fields
|
||||||
|
* @param pubkey The pubkey in hex format (required for fallback)
|
||||||
|
* @returns Display name string
|
||||||
|
*/
|
||||||
|
export function getProfileDisplayName(
|
||||||
|
profile: { name?: string; display_name?: string; nip05?: string } | null | undefined,
|
||||||
|
pubkey: string
|
||||||
|
): string {
|
||||||
|
// Consistent priority order: name || display_name || nip05 || fallback
|
||||||
|
if (profile?.name) return profile.name
|
||||||
|
if (profile?.display_name) return profile.display_name
|
||||||
|
if (profile?.nip05) return profile.nip05
|
||||||
|
return getNpubFallbackDisplay(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process markdown to replace nostr URIs while skipping those inside markdown links
|
||||||
|
* This prevents nested markdown link issues when nostr identifiers appear in URLs
|
||||||
|
*/
|
||||||
|
function replaceNostrUrisSafely(
|
||||||
|
markdown: string,
|
||||||
|
getReplacement: (encoded: string) => string
|
||||||
|
): string {
|
||||||
|
// Track positions where we're inside a markdown link URL
|
||||||
|
// Use a parser approach to correctly handle URLs with brackets/parentheses
|
||||||
|
const linkRanges: Array<{ start: number, end: number }> = []
|
||||||
|
|
||||||
|
// Find all markdown link URLs by looking for ]( pattern and tracking to matching )
|
||||||
|
let i = 0
|
||||||
|
while (i < markdown.length) {
|
||||||
|
// Look for ]( pattern that starts a markdown link URL
|
||||||
|
const urlStartMatch = markdown.indexOf('](', i)
|
||||||
|
if (urlStartMatch === -1) break
|
||||||
|
|
||||||
|
const urlStart = urlStartMatch + 2 // Position after "]("
|
||||||
|
|
||||||
|
// Now find the matching closing parenthesis
|
||||||
|
// We need to account for nested parentheses and escaped characters
|
||||||
|
let pos = urlStart
|
||||||
|
let depth = 1 // We're inside one set of parentheses
|
||||||
|
let urlEnd = -1
|
||||||
|
|
||||||
|
while (pos < markdown.length && depth > 0) {
|
||||||
|
const char = markdown[pos]
|
||||||
|
const nextChar = pos + 1 < markdown.length ? markdown[pos + 1] : ''
|
||||||
|
|
||||||
|
// Check for escaped characters
|
||||||
|
if (char === '\\' && nextChar) {
|
||||||
|
pos += 2 // Skip escaped character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '(') {
|
||||||
|
depth++
|
||||||
|
} else if (char === ')') {
|
||||||
|
depth--
|
||||||
|
if (depth === 0) {
|
||||||
|
urlEnd = pos
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlEnd !== -1) {
|
||||||
|
linkRanges.push({
|
||||||
|
start: urlStart,
|
||||||
|
end: urlEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
i = urlEnd + 1
|
||||||
|
} else {
|
||||||
|
// No matching closing paren found, skip this one
|
||||||
|
i = urlStart + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a position is inside any markdown link URL
|
||||||
|
const isInsideLinkUrl = (pos: number): boolean => {
|
||||||
|
return linkRanges.some(range => pos >= range.start && pos < range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace nostr URIs, but skip those inside link URLs
|
||||||
|
// Also check if nostr URI is part of any URL pattern (http/https URLs)
|
||||||
|
// Callback params: (match, encoded, type, offset, string)
|
||||||
|
const result = markdown.replace(NOSTR_URI_REGEX, (match, encoded, _type, offset, fullString) => {
|
||||||
|
const matchEnd = offset + match.length
|
||||||
|
|
||||||
|
// Check if this match is inside a markdown link URL
|
||||||
|
// Check both start and end positions to ensure we catch the whole match
|
||||||
|
const startInside = isInsideLinkUrl(offset)
|
||||||
|
const endInside = isInsideLinkUrl(matchEnd - 1) // Check end position
|
||||||
|
|
||||||
|
if (startInside || endInside) {
|
||||||
|
// Don't replace - return original match
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the nostr URI is part of an HTTP/HTTPS URL pattern
|
||||||
|
// This catches cases where the source markdown has URLs like https://example.com/naddr1...
|
||||||
|
// before they're formatted as markdown links
|
||||||
|
const contextBefore = fullString.slice(Math.max(0, offset - 200), offset)
|
||||||
|
const contextAfter = fullString.slice(matchEnd, Math.min(fullString.length, matchEnd + 10))
|
||||||
|
|
||||||
|
// Check if we're inside an http/https URL (looking for https?:// pattern before the match)
|
||||||
|
// and the match is followed by valid URL characters (not whitespace or closing paren)
|
||||||
|
const urlPatternBefore = /https?:\/\/[^\s)]*$/i
|
||||||
|
const isInHttpUrl = urlPatternBefore.test(contextBefore)
|
||||||
|
const isValidUrlContinuation = !contextAfter.match(/^[\s)]/) // Not followed by space or closing paren
|
||||||
|
|
||||||
|
if (isInHttpUrl && isValidUrlContinuation) {
|
||||||
|
// Don't replace - return original match
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||||
|
const replacement = getReplacement(encoded)
|
||||||
|
return replacement
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace nostr: URIs in markdown with proper markdown links
|
* Replace nostr: URIs in markdown with proper markdown links
|
||||||
* This converts: nostr:npub1... to [label](link)
|
* This converts: nostr:npub1... to [label](link)
|
||||||
*/
|
*/
|
||||||
export function replaceNostrUrisInMarkdown(markdown: string): string {
|
export function replaceNostrUrisInMarkdown(markdown: string): string {
|
||||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
|
||||||
const encoded = match.replace(/^nostr:/, '')
|
|
||||||
const link = createNostrLink(encoded)
|
const link = createNostrLink(encoded)
|
||||||
const label = getNostrUriLabel(encoded)
|
const label = getNostrUriLabel(encoded)
|
||||||
|
|
||||||
return `[${label}](${link})`
|
return `[${label}](${link})`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -132,9 +288,7 @@ export function replaceNostrUrisInMarkdownWithTitles(
|
|||||||
markdown: string,
|
markdown: string,
|
||||||
articleTitles: Map<string, string>
|
articleTitles: Map<string, string>
|
||||||
): string {
|
): string {
|
||||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
|
||||||
const encoded = match.replace(/^nostr:/, '')
|
|
||||||
const link = createNostrLink(encoded)
|
const link = createNostrLink(encoded)
|
||||||
|
|
||||||
// For articles, use the resolved title if available
|
// For articles, use the resolved title if available
|
||||||
@@ -154,14 +308,120 @@ export function replaceNostrUrisInMarkdownWithTitles(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace nostr: URIs in markdown with proper markdown links, using resolved profile names and article titles
|
||||||
|
* This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link)
|
||||||
|
* Labels update progressively as profiles load
|
||||||
|
* @param markdown The markdown content to process
|
||||||
|
* @param profileLabels Map of pubkey (hex) -> display name (e.g., pubkey -> @username)
|
||||||
|
* @param articleTitles Map of naddr -> title for resolved articles
|
||||||
|
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
|
||||||
|
*/
|
||||||
|
export function replaceNostrUrisInMarkdownWithProfileLabels(
|
||||||
|
markdown: string,
|
||||||
|
profileLabels: Map<string, string> = new Map(),
|
||||||
|
articleTitles: Map<string, string> = new Map(),
|
||||||
|
profileLoading: Map<string, boolean> = new Map()
|
||||||
|
): string {
|
||||||
|
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||||
|
const link = createNostrLink(encoded)
|
||||||
|
|
||||||
|
// For articles, use the resolved title if available
|
||||||
|
try {
|
||||||
|
const decoded = decode(encoded)
|
||||||
|
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
|
||||||
|
const title = articleTitles.get(encoded)!
|
||||||
|
return `[${title}](${link})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// For npub/nprofile, extract pubkey using applesauce helper
|
||||||
|
const pubkey = getPubkeyFromDecodeResult(decoded)
|
||||||
|
if (pubkey) {
|
||||||
|
// Check if we have a resolved profile name using pubkey as key
|
||||||
|
// Use the label if: 1) we have a label, AND 2) profile is not currently loading (false or undefined)
|
||||||
|
const isLoading = profileLoading.get(pubkey)
|
||||||
|
const hasLabel = profileLabels.has(pubkey)
|
||||||
|
|
||||||
|
// Use resolved label if we have one and profile is not loading
|
||||||
|
// isLoading can be: true (loading), false (loaded), or undefined (never was loading)
|
||||||
|
// We only avoid using the label if isLoading === true
|
||||||
|
if (isLoading !== true && hasLabel) {
|
||||||
|
const displayName = profileLabels.get(pubkey)!
|
||||||
|
return `[${displayName}](${link})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading or no resolved label yet, use fallback (will show loading via post-processing)
|
||||||
|
const label = getNostrUriLabel(encoded)
|
||||||
|
return `[${label}](${link})`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore decode errors, fall through to default label
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other types or if not resolved, use default label (shortened npub format)
|
||||||
|
const label = getNostrUriLabel(encoded)
|
||||||
|
return `[${label}](${link})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-process rendered HTML to add loading class to profile links that are still loading
|
||||||
|
* This is necessary because HTML inside markdown links doesn't render correctly
|
||||||
|
* @param html The rendered HTML string
|
||||||
|
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
|
||||||
|
* @returns HTML with profile-loading class added to loading profile links
|
||||||
|
*/
|
||||||
|
export function addLoadingClassToProfileLinks(
|
||||||
|
html: string,
|
||||||
|
profileLoading: Map<string, boolean>
|
||||||
|
): string {
|
||||||
|
if (profileLoading.size === 0) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all <a> tags with href starting with /p/ (profile links)
|
||||||
|
const result = html.replace(/<a\s+[^>]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => {
|
||||||
|
try {
|
||||||
|
// Decode npub or nprofile to get pubkey using applesauce helper
|
||||||
|
const decoded: ReturnType<typeof decode> = decode(npub)
|
||||||
|
const pubkey = getPubkeyFromDecodeResult(decoded)
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
// Check if this profile is loading
|
||||||
|
const isLoading = profileLoading.get(pubkey)
|
||||||
|
|
||||||
|
if (isLoading === true) {
|
||||||
|
// Add profile-loading class if not already present
|
||||||
|
if (!match.includes('profile-loading')) {
|
||||||
|
// Insert class before the closing >
|
||||||
|
const classMatch = /class="([^"]*)"/.exec(match)
|
||||||
|
if (classMatch) {
|
||||||
|
const updated = match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`)
|
||||||
|
return updated
|
||||||
|
} else {
|
||||||
|
const updated = match.replace(/(<a\s+[^>]*?)>/, '$1 class="profile-loading">')
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore processing errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace nostr: URIs in HTML with clickable links
|
* Replace nostr: URIs in HTML with clickable links
|
||||||
* This is used when processing HTML content directly
|
* This is used when processing HTML content directly
|
||||||
*/
|
*/
|
||||||
export function replaceNostrUrisInHTML(html: string): string {
|
export function replaceNostrUrisInHTML(html: string): string {
|
||||||
return html.replace(NOSTR_URI_REGEX, (match) => {
|
return html.replace(NOSTR_URI_REGEX, (_match, encoded) => {
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||||
const encoded = match.replace(/^nostr:/, '')
|
|
||||||
const link = createNostrLink(encoded)
|
const link = createNostrLink(encoded)
|
||||||
const label = getNostrUriLabel(encoded)
|
const label = getNostrUriLabel(encoded)
|
||||||
|
|
||||||
|
|||||||
27
src/utils/profileLoadingUtils.ts
Normal file
27
src/utils/profileLoadingUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { loadCachedProfiles } from '../services/profileService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a profile exists in cache or eventStore
|
||||||
|
* Used to determine if profile loading state should be shown
|
||||||
|
* @param pubkey The pubkey in hex format
|
||||||
|
* @param eventStore Optional eventStore instance
|
||||||
|
* @returns true if profile exists in cache or eventStore, false otherwise
|
||||||
|
*/
|
||||||
|
export function isProfileInCacheOrStore(
|
||||||
|
pubkey: string,
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
): boolean {
|
||||||
|
if (!pubkey) return false
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = loadCachedProfiles([pubkey])
|
||||||
|
if (cached.has(pubkey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check eventStore
|
||||||
|
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
|
||||||
|
return !!eventStoreProfile
|
||||||
|
}
|
||||||
|
|
||||||
2
src/utils/profileUtils.ts
Normal file
2
src/utils/profileUtils.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { extractProfileDisplayName } from '../../lib/profile'
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
export function normalizeUrl(url: string): string {
|
export function normalizeUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -15,10 +16,26 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For Nostr articles, we already fetched highlights specifically for this article
|
// For Nostr articles, filter by article coordinate
|
||||||
// So we don't need to filter them - they're all relevant
|
|
||||||
if (selectedUrl.startsWith('nostr:')) {
|
if (selectedUrl.startsWith('nostr:')) {
|
||||||
return highlights
|
try {
|
||||||
|
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||||
|
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||||
|
|
||||||
|
return highlights.filter(h => {
|
||||||
|
// Keep highlights that match this article coordinate
|
||||||
|
return h.eventReference === articleCoordinate
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Not a valid naddr, return empty array
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid naddr, return empty array
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For web URLs, filter by URL matching
|
// For web URLs, filter by URL matching
|
||||||
|
|||||||
217
test/markdown/basic-blockquotes.md
Normal file
217
test/markdown/basic-blockquotes.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Basic Blockquotes Test
|
||||||
|
|
||||||
|
This file tests blockquote syntax using the `>` character.
|
||||||
|
|
||||||
|
## Basic Blockquotes
|
||||||
|
|
||||||
|
Blockquotes are created by placing a `>` character at the start of a line, followed by a space.
|
||||||
|
|
||||||
|
> This is a blockquote.
|
||||||
|
|
||||||
|
> This is another blockquote with multiple sentences. It demonstrates that blockquotes can contain extended text. The entire blockquote should be rendered with appropriate styling to distinguish it from regular paragraphs.
|
||||||
|
|
||||||
|
## Multiple Paragraph Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can span multiple paragraphs by placing `>` at the start of each paragraph.
|
||||||
|
|
||||||
|
> This is the first paragraph in a blockquote.
|
||||||
|
>
|
||||||
|
> This is the second paragraph in the same blockquote.
|
||||||
|
|
||||||
|
> First paragraph.
|
||||||
|
>
|
||||||
|
> Second paragraph.
|
||||||
|
>
|
||||||
|
> Third paragraph.
|
||||||
|
|
||||||
|
## Blockquotes with Formatting
|
||||||
|
|
||||||
|
Blockquotes can contain inline formatting like bold, italic, and code.
|
||||||
|
|
||||||
|
> This blockquote contains **bold text**.
|
||||||
|
|
||||||
|
> This blockquote contains *italic text*.
|
||||||
|
|
||||||
|
> This blockquote contains ***bold and italic text***.
|
||||||
|
|
||||||
|
> This blockquote contains `code text`.
|
||||||
|
|
||||||
|
> This blockquote contains **bold**, *italic*, and `code` all together.
|
||||||
|
|
||||||
|
## Blockquotes with Links
|
||||||
|
|
||||||
|
Blockquotes can contain links.
|
||||||
|
|
||||||
|
> This blockquote contains a [link to example.com](https://example.com).
|
||||||
|
|
||||||
|
> This blockquote contains a [reference link][ref].
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
## Nested Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can be nested by using multiple `>` characters.
|
||||||
|
|
||||||
|
> This is the first level of a blockquote.
|
||||||
|
>
|
||||||
|
> > This is a nested blockquote.
|
||||||
|
>
|
||||||
|
> Back to the first level.
|
||||||
|
|
||||||
|
> First level.
|
||||||
|
>
|
||||||
|
> > Second level.
|
||||||
|
>
|
||||||
|
> > > Third level.
|
||||||
|
>
|
||||||
|
> > Back to second level.
|
||||||
|
>
|
||||||
|
> Back to first level.
|
||||||
|
|
||||||
|
## Blockquotes with Lists
|
||||||
|
|
||||||
|
Blockquotes can contain lists.
|
||||||
|
|
||||||
|
> This is a blockquote with a list:
|
||||||
|
>
|
||||||
|
> - First item
|
||||||
|
> - Second item
|
||||||
|
> - Third item
|
||||||
|
|
||||||
|
> This is a blockquote with a numbered list:
|
||||||
|
>
|
||||||
|
> 1. First item
|
||||||
|
> 2. Second item
|
||||||
|
> 3. Third item
|
||||||
|
|
||||||
|
> This is a blockquote with a nested list:
|
||||||
|
>
|
||||||
|
> - First item
|
||||||
|
> - Nested item
|
||||||
|
> - Another nested item
|
||||||
|
> - Second item
|
||||||
|
|
||||||
|
## Blockquotes with Code
|
||||||
|
|
||||||
|
Blockquotes can contain inline code and code blocks.
|
||||||
|
|
||||||
|
> This blockquote contains `inline code`.
|
||||||
|
|
||||||
|
> This blockquote contains a code block:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Code block here
|
||||||
|
> More code
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> This blockquote contains a code block with language:
|
||||||
|
>
|
||||||
|
> ```javascript
|
||||||
|
> function example() {
|
||||||
|
> return "Hello";
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Blockquotes with Headings
|
||||||
|
|
||||||
|
Blockquotes can contain headings.
|
||||||
|
|
||||||
|
> # Heading Level 1
|
||||||
|
>
|
||||||
|
> ## Heading Level 2
|
||||||
|
>
|
||||||
|
> ### Heading Level 3
|
||||||
|
|
||||||
|
## Blockquotes with Horizontal Rules
|
||||||
|
|
||||||
|
Blockquotes can contain horizontal rules.
|
||||||
|
|
||||||
|
> This is text before the rule.
|
||||||
|
>
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> This is text after the rule.
|
||||||
|
|
||||||
|
## Multiple Blockquotes
|
||||||
|
|
||||||
|
Multiple blockquotes can appear consecutively.
|
||||||
|
|
||||||
|
> This is the first blockquote.
|
||||||
|
|
||||||
|
> This is the second blockquote.
|
||||||
|
|
||||||
|
> This is the third blockquote.
|
||||||
|
|
||||||
|
## Blockquotes in Context
|
||||||
|
|
||||||
|
Blockquotes can appear alongside regular paragraphs and other elements.
|
||||||
|
|
||||||
|
This is a regular paragraph before the blockquote.
|
||||||
|
|
||||||
|
> This is a blockquote between paragraphs.
|
||||||
|
|
||||||
|
This is a regular paragraph after the blockquote.
|
||||||
|
|
||||||
|
## Empty Blockquotes
|
||||||
|
|
||||||
|
An empty blockquote can be created, though it may not render visibly.
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
## Blockquotes with Special Characters
|
||||||
|
|
||||||
|
Blockquotes can contain special characters and punctuation.
|
||||||
|
|
||||||
|
> This blockquote has numbers: 123, 456, 789.
|
||||||
|
|
||||||
|
> This blockquote has symbols: !@#$%^&*().
|
||||||
|
|
||||||
|
> This blockquote has quotes: "Hello" and 'World'.
|
||||||
|
|
||||||
|
> This blockquote has parentheses (like this) and brackets [like this].
|
||||||
|
|
||||||
|
## Long Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can contain very long text that wraps across multiple lines.
|
||||||
|
|
||||||
|
> This is a very long blockquote that contains a substantial amount of text. It demonstrates how blockquotes handle extended content that might wrap across multiple visual lines in the rendered output. The blockquote should maintain its styling and indentation even when the text extends beyond a single line.
|
||||||
|
|
||||||
|
## Blockquotes with Mixed Content
|
||||||
|
|
||||||
|
Blockquotes can contain a mix of different content types.
|
||||||
|
|
||||||
|
> This blockquote contains **bold text**, *italic text*, and `code`.
|
||||||
|
>
|
||||||
|
> It also contains a [link](https://example.com).
|
||||||
|
>
|
||||||
|
> - And a list item
|
||||||
|
> - Another list item
|
||||||
|
>
|
||||||
|
> And more regular text.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Blockquote with Only Spaces
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
### Blockquote with Trailing Spaces
|
||||||
|
|
||||||
|
> Blockquote with trailing spaces.
|
||||||
|
|
||||||
|
### Very Short Blockquote
|
||||||
|
|
||||||
|
> A
|
||||||
|
|
||||||
|
### Blockquote with Only Special Characters
|
||||||
|
|
||||||
|
> !@#$%^&*()
|
||||||
|
|
||||||
|
### Blockquote Marker Without Space
|
||||||
|
|
||||||
|
>This might not render correctly in some processors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-blockquotes.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-blockquotes.md)
|
||||||
|
|
||||||
310
test/markdown/basic-code.md
Normal file
310
test/markdown/basic-code.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Basic Code Test
|
||||||
|
|
||||||
|
This file tests inline code and code block syntax in markdown.
|
||||||
|
|
||||||
|
## Inline Code
|
||||||
|
|
||||||
|
Inline code is created using backticks (`` ` ``) around the text.
|
||||||
|
|
||||||
|
This paragraph contains `inline code`.
|
||||||
|
|
||||||
|
You can use `inline code` anywhere in a sentence.
|
||||||
|
|
||||||
|
`Code` can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with `code`.
|
||||||
|
|
||||||
|
## Code Blocks
|
||||||
|
|
||||||
|
Code blocks are created using triple backticks (``` ``` ```) on lines before and after the code.
|
||||||
|
|
||||||
|
```
|
||||||
|
This is a code block.
|
||||||
|
It can contain multiple lines.
|
||||||
|
Each line is preserved as written.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Language
|
||||||
|
|
||||||
|
Code blocks can specify a programming language for syntax highlighting.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function example() {
|
||||||
|
return "Hello, World!";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def example():
|
||||||
|
return "Hello, World!"
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<p>Hello, World!</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.example {
|
||||||
|
color: blue;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Code Blocks
|
||||||
|
|
||||||
|
Multiple code blocks can appear consecutively.
|
||||||
|
|
||||||
|
```
|
||||||
|
First code block
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Second code block
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Third code block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Formatting
|
||||||
|
|
||||||
|
Code blocks preserve all formatting, including spaces and indentation.
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block
|
||||||
|
has indentation
|
||||||
|
that should be preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function example() {
|
||||||
|
if (condition) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Special Characters
|
||||||
|
|
||||||
|
Code blocks can contain special characters and symbols.
|
||||||
|
|
||||||
|
```
|
||||||
|
!@#$%^&*()
|
||||||
|
[]{}()
|
||||||
|
<>
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function test() {
|
||||||
|
console.log("Hello, World!");
|
||||||
|
return 123;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Code with Special Characters
|
||||||
|
|
||||||
|
Inline code can contain special characters.
|
||||||
|
|
||||||
|
This has `code with !@#$%` in it.
|
||||||
|
|
||||||
|
This has `code with ()[]{}` in it.
|
||||||
|
|
||||||
|
This has `code with <>&` in it.
|
||||||
|
|
||||||
|
## Escaping Backticks in Inline Code
|
||||||
|
|
||||||
|
To include a backtick in inline code, use double backticks.
|
||||||
|
|
||||||
|
This contains `` `backtick` `` in the code.
|
||||||
|
|
||||||
|
This contains `` `code with backticks` `` in it.
|
||||||
|
|
||||||
|
## Code Blocks with Empty Lines
|
||||||
|
|
||||||
|
Code blocks can contain empty lines.
|
||||||
|
|
||||||
|
```
|
||||||
|
First line
|
||||||
|
|
||||||
|
Third line
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Line one
|
||||||
|
|
||||||
|
Line three
|
||||||
|
|
||||||
|
Line five
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Only Whitespace
|
||||||
|
|
||||||
|
Code blocks can contain only whitespace.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Code in Different Contexts
|
||||||
|
|
||||||
|
Inline code can appear in various contexts.
|
||||||
|
|
||||||
|
### In Paragraphs
|
||||||
|
|
||||||
|
This paragraph has `inline code` in it.
|
||||||
|
|
||||||
|
### In Lists
|
||||||
|
|
||||||
|
- Item with `inline code`
|
||||||
|
- Another item with `code`
|
||||||
|
|
||||||
|
1. Ordered item with `inline code`
|
||||||
|
2. Another ordered item with `code`
|
||||||
|
|
||||||
|
### In Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains `inline code`.
|
||||||
|
|
||||||
|
### In Headings
|
||||||
|
|
||||||
|
### Heading with `Code`
|
||||||
|
|
||||||
|
## Code Blocks in Different Contexts
|
||||||
|
|
||||||
|
### Code Blocks After Paragraphs
|
||||||
|
|
||||||
|
This is a paragraph.
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block after paragraph
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Blocks Before Paragraphs
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block before paragraph
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a paragraph.
|
||||||
|
|
||||||
|
### Code Blocks Between Paragraphs
|
||||||
|
|
||||||
|
This is the first paragraph.
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block in the middle
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the second paragraph.
|
||||||
|
|
||||||
|
### Code Blocks in Lists
|
||||||
|
|
||||||
|
- List item before code block
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block in list
|
||||||
|
```
|
||||||
|
|
||||||
|
- List item after code block
|
||||||
|
|
||||||
|
### Code Blocks in Blockquotes
|
||||||
|
|
||||||
|
> This is a blockquote with a code block:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Code block in blockquote
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Long Code Blocks
|
||||||
|
|
||||||
|
Code blocks can contain very long lines of code.
|
||||||
|
|
||||||
|
```
|
||||||
|
This is a very long line of code that extends far beyond the normal width and should demonstrate how code blocks handle extended content that might require horizontal scrolling or wrapping depending on the rendering implementation.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function veryLongFunctionNameThatExtendsBeyondNormalWidth(parameterOne, parameterTwo, parameterThree, parameterFour) {
|
||||||
|
return parameterOne + parameterTwo + parameterThree + parameterFour;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Many Lines
|
||||||
|
|
||||||
|
Code blocks can contain many lines of code.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function example() {
|
||||||
|
let x = 1;
|
||||||
|
let y = 2;
|
||||||
|
let z = 3;
|
||||||
|
let a = 4;
|
||||||
|
let b = 5;
|
||||||
|
let c = 6;
|
||||||
|
let d = 7;
|
||||||
|
let e = 8;
|
||||||
|
let f = 9;
|
||||||
|
let g = 10;
|
||||||
|
return x + y + z + a + b + c + d + e + f + g;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Inline Code with Only Spaces
|
||||||
|
|
||||||
|
` `
|
||||||
|
|
||||||
|
### Inline Code with Only Special Characters
|
||||||
|
|
||||||
|
`!@#$%^&*()`
|
||||||
|
|
||||||
|
### Very Short Inline Code
|
||||||
|
|
||||||
|
`` `a` ``
|
||||||
|
|
||||||
|
### Inline Code at Word Boundaries
|
||||||
|
|
||||||
|
`code`word
|
||||||
|
|
||||||
|
word`code`
|
||||||
|
|
||||||
|
### Code Block with Only One Line
|
||||||
|
|
||||||
|
```
|
||||||
|
Single line code block
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Block with Trailing Spaces
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block with trailing spaces
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unclosed Code Block
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block is not closed properly
|
||||||
|
|
||||||
|
### Code Block with Language but No Code
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Code with Backticks
|
||||||
|
|
||||||
|
`` `code` ``
|
||||||
|
|
||||||
|
`` ``code`` ``
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-code.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-code.md)
|
||||||
|
|
||||||
194
test/markdown/basic-emphasis.md
Normal file
194
test/markdown/basic-emphasis.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Basic Emphasis Test
|
||||||
|
|
||||||
|
This file tests bold and italic text formatting using asterisks and underscores.
|
||||||
|
|
||||||
|
## Bold Text
|
||||||
|
|
||||||
|
Bold text is created using two asterisks or two underscores before and after the text.
|
||||||
|
|
||||||
|
I just love **bold text**.
|
||||||
|
|
||||||
|
I also love __bold text with underscores__.
|
||||||
|
|
||||||
|
**Bold text** can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with **bold text**.
|
||||||
|
|
||||||
|
A sentence can have **bold text** in the middle.
|
||||||
|
|
||||||
|
## Italic Text
|
||||||
|
|
||||||
|
Italic text is created using one asterisk or one underscore before and after the text.
|
||||||
|
|
||||||
|
Italicized text is the *cat's meow*.
|
||||||
|
|
||||||
|
Italicized text is also the _cat's meow_.
|
||||||
|
|
||||||
|
*Italic text* can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with *italic text*.
|
||||||
|
|
||||||
|
A sentence can have *italic text* in the middle.
|
||||||
|
|
||||||
|
## Bold and Italic Together
|
||||||
|
|
||||||
|
To emphasize text with both bold and italics, use three asterisks or three underscores.
|
||||||
|
|
||||||
|
This text is ***bold and italic***.
|
||||||
|
|
||||||
|
This text is also ___bold and italic___.
|
||||||
|
|
||||||
|
***Bold and italic*** can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with ***bold and italic***.
|
||||||
|
|
||||||
|
A sentence can have ***bold and italic*** in the middle.
|
||||||
|
|
||||||
|
## Mid-Word Emphasis
|
||||||
|
|
||||||
|
You can emphasize the middle of a word for emphasis.
|
||||||
|
|
||||||
|
Love**is**bold
|
||||||
|
|
||||||
|
Love*is*italic
|
||||||
|
|
||||||
|
Love***is***bolditalic
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Use Asterisks for Mid-Word Emphasis
|
||||||
|
|
||||||
|
For compatibility, use asterisks when emphasizing the middle of a word.
|
||||||
|
|
||||||
|
Love**is**bold (correct)
|
||||||
|
|
||||||
|
Love__is__bold (may not work in all processors)
|
||||||
|
|
||||||
|
A*cat*meow (correct)
|
||||||
|
|
||||||
|
A_cat_meow (may not work in all processors)
|
||||||
|
|
||||||
|
### Spacing Around Emphasis
|
||||||
|
|
||||||
|
Emphasis markers should be directly adjacent to the text being emphasized.
|
||||||
|
|
||||||
|
This is **correct** spacing.
|
||||||
|
|
||||||
|
This is ** incorrect ** spacing.
|
||||||
|
|
||||||
|
This is *correct* spacing.
|
||||||
|
|
||||||
|
This is * incorrect * spacing.
|
||||||
|
|
||||||
|
## Multiple Emphasis in One Paragraph
|
||||||
|
|
||||||
|
A single paragraph can contain multiple instances of bold, italic, and combined emphasis.
|
||||||
|
|
||||||
|
This paragraph has **bold text**, *italic text*, and ***bold italic text*** all together. You can use **multiple bold** sections and *multiple italic* sections in the same paragraph.
|
||||||
|
|
||||||
|
## Emphasis with Punctuation
|
||||||
|
|
||||||
|
Emphasis works correctly with adjacent punctuation marks.
|
||||||
|
|
||||||
|
**Bold text**, with a comma.
|
||||||
|
|
||||||
|
**Bold text.** With a period.
|
||||||
|
|
||||||
|
**Bold text!** With an exclamation.
|
||||||
|
|
||||||
|
**Bold text?** With a question mark.
|
||||||
|
|
||||||
|
*Italic text*, with a comma.
|
||||||
|
|
||||||
|
*Italic text.* With a period.
|
||||||
|
|
||||||
|
*Italic text!* With an exclamation.
|
||||||
|
|
||||||
|
*Italic text?* With a question mark.
|
||||||
|
|
||||||
|
## Emphasis at Word Boundaries
|
||||||
|
|
||||||
|
Emphasis can appear at the start or end of words.
|
||||||
|
|
||||||
|
**Start** of a word.
|
||||||
|
|
||||||
|
End of a **word**.
|
||||||
|
|
||||||
|
*Start* of a word.
|
||||||
|
|
||||||
|
End of a *word*.
|
||||||
|
|
||||||
|
## Emphasis with Links
|
||||||
|
|
||||||
|
Emphasis can be combined with links.
|
||||||
|
|
||||||
|
This is a [**bold link**](https://example.com).
|
||||||
|
|
||||||
|
This is a [*italic link*](https://example.com).
|
||||||
|
|
||||||
|
This is a [***bold italic link***](https://example.com).
|
||||||
|
|
||||||
|
## Emphasis with Code
|
||||||
|
|
||||||
|
Emphasis cannot be used inside code blocks, but can appear alongside inline code.
|
||||||
|
|
||||||
|
This has `code` and **bold** together.
|
||||||
|
|
||||||
|
This has `code` and *italic* together.
|
||||||
|
|
||||||
|
## Nested Emphasis
|
||||||
|
|
||||||
|
You cannot nest emphasis of the same type, but you can combine different types.
|
||||||
|
|
||||||
|
***Bold and italic*** is valid.
|
||||||
|
|
||||||
|
**Bold with *italic inside* bold** is valid.
|
||||||
|
|
||||||
|
*Italic with **bold inside** italic* is valid.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Emphasis with Only Spaces
|
||||||
|
|
||||||
|
** **
|
||||||
|
|
||||||
|
* *
|
||||||
|
|
||||||
|
### Emphasis with Special Characters
|
||||||
|
|
||||||
|
**Bold with !@#$%**
|
||||||
|
|
||||||
|
*Italic with !@#$%*
|
||||||
|
|
||||||
|
### Very Short Emphasis
|
||||||
|
|
||||||
|
****
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
**
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
### Emphasis Markers Without Closing
|
||||||
|
|
||||||
|
**Bold text without closing
|
||||||
|
|
||||||
|
*Italic text without closing
|
||||||
|
|
||||||
|
### Emphasis with Numbers
|
||||||
|
|
||||||
|
**123**
|
||||||
|
|
||||||
|
*456*
|
||||||
|
|
||||||
|
### Emphasis with Only Punctuation
|
||||||
|
|
||||||
|
**!!!**
|
||||||
|
|
||||||
|
*???*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-emphasis.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-emphasis.md)
|
||||||
|
|
||||||
219
test/markdown/basic-escaping.md
Normal file
219
test/markdown/basic-escaping.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Basic Escaping Test
|
||||||
|
|
||||||
|
This file tests character escaping in markdown using backslashes to display literal characters that would otherwise have special meaning.
|
||||||
|
|
||||||
|
## Escaping Special Characters
|
||||||
|
|
||||||
|
You can escape special markdown characters by placing a backslash (`\`) before them.
|
||||||
|
|
||||||
|
### Backslash
|
||||||
|
|
||||||
|
To display a literal backslash, escape it: \\
|
||||||
|
|
||||||
|
### Backtick
|
||||||
|
|
||||||
|
To display a literal backtick, escape it: \`
|
||||||
|
|
||||||
|
### Asterisk
|
||||||
|
|
||||||
|
To display a literal asterisk, escape it: \*
|
||||||
|
|
||||||
|
### Underscore
|
||||||
|
|
||||||
|
To display a literal underscore, escape it: \_
|
||||||
|
|
||||||
|
### Curly Braces
|
||||||
|
|
||||||
|
To display literal curly braces, escape them: \{ \}
|
||||||
|
|
||||||
|
### Square Brackets
|
||||||
|
|
||||||
|
To display literal square brackets, escape them: \[ \]
|
||||||
|
|
||||||
|
### Angle Brackets
|
||||||
|
|
||||||
|
To display literal angle brackets, escape them: \< \>
|
||||||
|
|
||||||
|
### Parentheses
|
||||||
|
|
||||||
|
To display literal parentheses, escape them: \( \)
|
||||||
|
|
||||||
|
### Pound Sign
|
||||||
|
|
||||||
|
To display a literal pound sign (hash), escape it: \#
|
||||||
|
|
||||||
|
### Plus Sign
|
||||||
|
|
||||||
|
To display a literal plus sign, escape it: \+
|
||||||
|
|
||||||
|
### Minus Sign
|
||||||
|
|
||||||
|
To display a literal minus sign (hyphen), escape it: \-
|
||||||
|
|
||||||
|
### Dot
|
||||||
|
|
||||||
|
To display a literal dot (period), escape it: \.
|
||||||
|
|
||||||
|
### Exclamation Mark
|
||||||
|
|
||||||
|
To display a literal exclamation mark, escape it: \!
|
||||||
|
|
||||||
|
### Pipe
|
||||||
|
|
||||||
|
To display a literal pipe character, escape it: \|
|
||||||
|
|
||||||
|
## Escaping in Different Contexts
|
||||||
|
|
||||||
|
### Escaping in Paragraphs
|
||||||
|
|
||||||
|
This paragraph contains escaped characters: \*asterisk\*, \_underscore\_, \`backtick\`.
|
||||||
|
|
||||||
|
### Escaping in Headings
|
||||||
|
|
||||||
|
#### Heading with \*Escaped\* Characters
|
||||||
|
|
||||||
|
#### Heading with \_Escaped\_ Characters
|
||||||
|
|
||||||
|
### Escaping in Lists
|
||||||
|
|
||||||
|
- Item with \*escaped asterisk\*
|
||||||
|
- Item with \_escaped underscore\_
|
||||||
|
- Item with \`escaped backtick\`
|
||||||
|
|
||||||
|
1. Ordered item with \*escaped\*
|
||||||
|
2. Another item with \_escaped\_
|
||||||
|
|
||||||
|
### Escaping in Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains \*escaped\* characters.
|
||||||
|
|
||||||
|
> This blockquote has \_escaped\_ underscores.
|
||||||
|
|
||||||
|
### Escaping in Links
|
||||||
|
|
||||||
|
You cannot escape characters inside link syntax, but you can escape them in the link text context.
|
||||||
|
|
||||||
|
This is a [link with \*escaped\* text](https://example.com).
|
||||||
|
|
||||||
|
## Multiple Escaped Characters
|
||||||
|
|
||||||
|
You can escape multiple characters in sequence.
|
||||||
|
|
||||||
|
\*\*This would be bold if not escaped\*\*
|
||||||
|
|
||||||
|
\*\*\*This would be bold and italic if not escaped\*\*\*
|
||||||
|
|
||||||
|
\`\`This would be code if not escaped\`\`
|
||||||
|
|
||||||
|
## Escaping vs. Not Escaping
|
||||||
|
|
||||||
|
### Without Escaping
|
||||||
|
|
||||||
|
This text has **bold** and *italic* formatting.
|
||||||
|
|
||||||
|
### With Escaping
|
||||||
|
|
||||||
|
This text has \*\*escaped bold\*\* and \*escaped italic\* markers.
|
||||||
|
|
||||||
|
## Escaping Special Characters in Code
|
||||||
|
|
||||||
|
Inside code blocks and inline code, characters are already literal and don't need escaping.
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block contains *asterisks* and _underscores_ without escaping.
|
||||||
|
```
|
||||||
|
|
||||||
|
This paragraph contains `inline code with *asterisks*` that don't need escaping.
|
||||||
|
|
||||||
|
## Escaping at Word Boundaries
|
||||||
|
|
||||||
|
Escaped characters can appear at the start or end of words.
|
||||||
|
|
||||||
|
\*Start of word
|
||||||
|
|
||||||
|
End of word\*
|
||||||
|
|
||||||
|
\_Start of word
|
||||||
|
|
||||||
|
End of word\_
|
||||||
|
|
||||||
|
## Escaping with Punctuation
|
||||||
|
|
||||||
|
Escaped characters work correctly with adjacent punctuation.
|
||||||
|
|
||||||
|
\*Asterisk\*, with comma.
|
||||||
|
|
||||||
|
\*Asterisk\*. With period.
|
||||||
|
|
||||||
|
\*Asterisk\*! With exclamation.
|
||||||
|
|
||||||
|
\_Underscore\_, with comma.
|
||||||
|
|
||||||
|
\_Underscore\_. With period.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Escaping Non-Special Characters
|
||||||
|
|
||||||
|
Escaping characters that don't have special meaning in markdown typically results in a literal backslash followed by the character.
|
||||||
|
|
||||||
|
\a
|
||||||
|
|
||||||
|
\b
|
||||||
|
|
||||||
|
\c
|
||||||
|
|
||||||
|
### Multiple Backslashes
|
||||||
|
|
||||||
|
\\\\
|
||||||
|
|
||||||
|
\\\\\\
|
||||||
|
|
||||||
|
### Escaping Spaces
|
||||||
|
|
||||||
|
Escaping a space typically doesn't have a special effect: \
|
||||||
|
|
||||||
|
### Escaping Newlines
|
||||||
|
|
||||||
|
Escaping a newline (backslash at end of line) may create a line break in some processors, but this is not part of basic markdown syntax.
|
||||||
|
|
||||||
|
### Escaping in Different Positions
|
||||||
|
|
||||||
|
Start: \*text
|
||||||
|
|
||||||
|
Middle: text\*text
|
||||||
|
|
||||||
|
End: text\*
|
||||||
|
|
||||||
|
### Escaping Special Character Sequences
|
||||||
|
|
||||||
|
\*\*\*
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\-\-\-
|
||||||
|
|
||||||
|
## Real-World Examples
|
||||||
|
|
||||||
|
### Escaping in Documentation
|
||||||
|
|
||||||
|
When writing documentation about markdown, you often need to escape characters to show the syntax.
|
||||||
|
|
||||||
|
To create bold text, use \*\*two asterisks\*\*.
|
||||||
|
|
||||||
|
To create italic text, use \*one asterisk\*.
|
||||||
|
|
||||||
|
To create inline code, use \`backticks\`.
|
||||||
|
|
||||||
|
### Escaping in Examples
|
||||||
|
|
||||||
|
Here's how to escape a backtick: \`
|
||||||
|
|
||||||
|
Here's how to escape an asterisk: \*
|
||||||
|
|
||||||
|
Here's how to escape an underscore: \_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-escaping.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-escaping.md)
|
||||||
|
|
||||||
140
test/markdown/basic-headings.md
Normal file
140
test/markdown/basic-headings.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Basic Headings Test
|
||||||
|
|
||||||
|
This file tests markdown heading syntax, including all heading levels and alternate syntax forms.
|
||||||
|
|
||||||
|
## Heading Levels
|
||||||
|
|
||||||
|
Headings are created using number signs (`#`) followed by a space and the heading text. The number of `#` symbols determines the heading level.
|
||||||
|
|
||||||
|
# Heading Level 1
|
||||||
|
|
||||||
|
## Heading Level 2
|
||||||
|
|
||||||
|
### Heading Level 3
|
||||||
|
|
||||||
|
#### Heading Level 4
|
||||||
|
|
||||||
|
##### Heading Level 5
|
||||||
|
|
||||||
|
###### Heading Level 6
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
Always include a space between the number signs and the heading text for compatibility across markdown processors.
|
||||||
|
|
||||||
|
# Correct: Space after #
|
||||||
|
|
||||||
|
#Incorrect: No space after #
|
||||||
|
|
||||||
|
## Blank Lines
|
||||||
|
|
||||||
|
For best compatibility, include blank lines before and after headings.
|
||||||
|
|
||||||
|
This paragraph is before the heading.
|
||||||
|
|
||||||
|
# Heading with blank lines
|
||||||
|
|
||||||
|
This paragraph is after the heading.
|
||||||
|
|
||||||
|
Without blank lines, this might not render correctly.
|
||||||
|
# Heading without blank lines
|
||||||
|
This text might be treated as part of the heading.
|
||||||
|
|
||||||
|
## Alternate Syntax (Setext)
|
||||||
|
|
||||||
|
Heading level 1 can also be created using equals signs (`=`) on the line below the text.
|
||||||
|
|
||||||
|
Heading Level 1
|
||||||
|
===============
|
||||||
|
|
||||||
|
Heading level 2 can be created using hyphens (`-`) on the line below the text.
|
||||||
|
|
||||||
|
Heading Level 2
|
||||||
|
---------------
|
||||||
|
|
||||||
|
## Headings with Formatting
|
||||||
|
|
||||||
|
Headings can contain inline formatting like bold and italic text.
|
||||||
|
|
||||||
|
### Heading with **Bold** Text
|
||||||
|
|
||||||
|
### Heading with *Italic* Text
|
||||||
|
|
||||||
|
### Heading with ***Bold and Italic*** Text
|
||||||
|
|
||||||
|
### Heading with `Code` Text
|
||||||
|
|
||||||
|
## Headings with Links
|
||||||
|
|
||||||
|
Headings can contain links.
|
||||||
|
|
||||||
|
### Heading with [Link](https://example.com)
|
||||||
|
|
||||||
|
### Heading with [Reference Link][ref]
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
## Long Headings
|
||||||
|
|
||||||
|
This tests how headings handle very long text that might wrap across multiple lines on smaller screens or in narrow containers.
|
||||||
|
|
||||||
|
# This is a very long heading that contains many words and should demonstrate how the markdown processor handles headings that extend beyond a single line of text
|
||||||
|
|
||||||
|
## Special Characters in Headings
|
||||||
|
|
||||||
|
Headings can contain various special characters and punctuation.
|
||||||
|
|
||||||
|
### Heading with Numbers: 123
|
||||||
|
|
||||||
|
### Heading with Symbols: !@#$%^&*()
|
||||||
|
|
||||||
|
### Heading with Quotes: "Hello World"
|
||||||
|
|
||||||
|
### Heading with Parentheses (Like This)
|
||||||
|
|
||||||
|
### Heading with Brackets [Like This]
|
||||||
|
|
||||||
|
### Heading with Braces {Like This}
|
||||||
|
|
||||||
|
## Multiple Headings
|
||||||
|
|
||||||
|
Multiple headings of the same or different levels can appear consecutively.
|
||||||
|
|
||||||
|
# First H1
|
||||||
|
|
||||||
|
# Second H1
|
||||||
|
|
||||||
|
## First H2
|
||||||
|
|
||||||
|
## Second H2
|
||||||
|
|
||||||
|
### First H3
|
||||||
|
|
||||||
|
### Second H3
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Heading with Only Spaces
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
### Heading with Trailing Spaces
|
||||||
|
|
||||||
|
# Heading with trailing spaces
|
||||||
|
|
||||||
|
### Heading Starting with Number Sign
|
||||||
|
|
||||||
|
# #Heading that starts with a number sign
|
||||||
|
|
||||||
|
### Very Short Heading
|
||||||
|
|
||||||
|
# A
|
||||||
|
|
||||||
|
### Heading with Only Special Characters
|
||||||
|
|
||||||
|
# !@#$%^&*()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-headings.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-headings.md)
|
||||||
|
|
||||||
194
test/markdown/basic-horizontal-rules.md
Normal file
194
test/markdown/basic-horizontal-rules.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Basic Horizontal Rules Test
|
||||||
|
|
||||||
|
This file tests horizontal rule syntax using hyphens, asterisks, and underscores.
|
||||||
|
|
||||||
|
## Basic Horizontal Rules
|
||||||
|
|
||||||
|
Horizontal rules are created using three or more hyphens, asterisks, or underscores on their own line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Minimum Characters
|
||||||
|
|
||||||
|
At least three characters are required, but more can be used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Different Characters
|
||||||
|
|
||||||
|
Horizontal rules can be created with hyphens, asterisks, or underscores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Horizontal Rules in Context
|
||||||
|
|
||||||
|
Horizontal rules can appear between paragraphs and other elements.
|
||||||
|
|
||||||
|
This is a paragraph before the horizontal rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This is a paragraph after the horizontal rule.
|
||||||
|
|
||||||
|
## Multiple Horizontal Rules
|
||||||
|
|
||||||
|
Multiple horizontal rules can appear consecutively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Formatting
|
||||||
|
|
||||||
|
Horizontal rules are standalone elements and cannot contain formatting, but they can appear alongside formatted content.
|
||||||
|
|
||||||
|
This paragraph has **bold text**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This paragraph has *italic text*.
|
||||||
|
|
||||||
|
## Horizontal Rules with Lists
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item
|
||||||
|
- Third item
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Blockquotes
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after blockquotes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> This is a blockquote.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Code Blocks
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after code blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Headings
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after headings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heading Level 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Heading Level 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Around Horizontal Rules
|
||||||
|
|
||||||
|
Horizontal rules should be on their own line with blank lines before and after for best compatibility.
|
||||||
|
|
||||||
|
Paragraph before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Paragraph after.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Horizontal Rule with Spaces
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
|
* * *
|
||||||
|
|
||||||
|
_ _ _
|
||||||
|
|
||||||
|
### Horizontal Rule with Only Two Characters
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
**
|
||||||
|
|
||||||
|
__
|
||||||
|
|
||||||
|
### Horizontal Rule with Mixed Characters
|
||||||
|
|
||||||
|
-*_
|
||||||
|
|
||||||
|
*-_
|
||||||
|
|
||||||
|
_*-
|
||||||
|
|
||||||
|
### Horizontal Rule with Trailing Spaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Horizontal Rule with Leading Spaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Very Long Horizontal Rules
|
||||||
|
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
***********************************
|
||||||
|
|
||||||
|
___________________________________
|
||||||
|
|
||||||
|
### Horizontal Rule Between Other Elements
|
||||||
|
|
||||||
|
# Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Another Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Third Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Paragraph text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-horizontal-rules.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-horizontal-rules.md)
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user