mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
220 Commits
sync-readi
...
bunker-enc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3aff0407 | ||
|
|
aad35d41db | ||
|
|
cc6189a5d9 | ||
|
|
18bf8f9a2c | ||
|
|
37f3a32a1c | ||
|
|
c9678564a5 | ||
|
|
721c18c509 | ||
|
|
9e30fe683b | ||
|
|
7fff50c146 | ||
|
|
fc1c845b67 | ||
|
|
c2ec1f3677 | ||
|
|
0cbd357856 | ||
|
|
26ea9ed547 | ||
|
|
9cbbecb32c | ||
|
|
db12c89731 | ||
|
|
6f413deb90 | ||
|
|
0127e2dc86 | ||
|
|
7743928702 | ||
|
|
bf76150fc1 | ||
|
|
c62107172b | ||
|
|
a253587dfa | ||
|
|
1938533d53 | ||
|
|
28943c55bd | ||
|
|
791bbb68b6 | ||
|
|
ec8adcc794 | ||
|
|
68058e7661 | ||
|
|
416c62369c | ||
|
|
a19dd53423 | ||
|
|
79ec33b79a | ||
|
|
be881b957c | ||
|
|
244872e9f2 | ||
|
|
1397f7f0f4 | ||
|
|
96424dd65c | ||
|
|
9efc5459fb | ||
|
|
7e02168e54 | ||
|
|
f8e6b3e828 | ||
|
|
c06176bfc9 | ||
|
|
e2a1701000 | ||
|
|
d7703ceef4 | ||
|
|
93daabc673 | ||
|
|
9264245944 | ||
|
|
f56423040b | ||
|
|
4b91504a50 | ||
|
|
1f0f7fef5e | ||
|
|
6aced653fb | ||
|
|
0899482869 | ||
|
|
1bdfa1e6e1 | ||
|
|
f22a8f15c0 | ||
|
|
bf6394fc7d | ||
|
|
6f08586e8f | ||
|
|
d60a4a24ad | ||
|
|
51069f3623 | ||
|
|
1407af22e3 | ||
|
|
ea6220277d | ||
|
|
fbffa03dad | ||
|
|
a74760d804 | ||
|
|
c4b0a712d2 | ||
|
|
1fecf9c7f4 | ||
|
|
7be21203d9 | ||
|
|
f65f2c6597 | ||
|
|
227def4328 | ||
|
|
b506624f57 | ||
|
|
fbb6a0a153 | ||
|
|
528de32689 | ||
|
|
230e5380ca | ||
|
|
349237d097 | ||
|
|
d4df9f0424 | ||
|
|
2f68e84002 | ||
|
|
b18dcc29cd | ||
|
|
680169e312 | ||
|
|
11753c4515 | ||
|
|
bd29dfd65f | ||
|
|
4b1ae838e5 | ||
|
|
85599d3103 | ||
|
|
4603c5a258 | ||
|
|
ec45fbc5e8 | ||
|
|
53400334b2 | ||
|
|
af4ff7081a | ||
|
|
7f21b8ed76 | ||
|
|
55e44dcc9c | ||
|
|
59dac947ab | ||
|
|
7d33c3c024 | ||
|
|
38a014ef84 | ||
|
|
f451348430 | ||
|
|
685aaf43b0 | ||
|
|
d6a20b5272 | ||
|
|
d8d7a19fa1 | ||
|
|
63626fae3a | ||
|
|
de09ef2935 | ||
|
|
bcb28a63a7 | ||
|
|
a479903ce3 | ||
|
|
567d105261 | ||
|
|
83743c5a9f | ||
|
|
0b8f88ea1d | ||
|
|
fadc755930 | ||
|
|
f67f171e64 | ||
|
|
449c59015e | ||
|
|
4d697e6a79 | ||
|
|
04ae70873a | ||
|
|
2f8a64826a | ||
|
|
11cb3542ee | ||
|
|
905296621c | ||
|
|
769484bc0d | ||
|
|
27ff4cef22 | ||
|
|
a352e2616e | ||
|
|
77cbb9394f | ||
|
|
39c8b3dfe4 | ||
|
|
7bd11e695e | ||
|
|
a76b703d36 | ||
|
|
df51173405 | ||
|
|
a79d7f9eaf | ||
|
|
1032a46456 | ||
|
|
ae997758ab | ||
|
|
91a827324d | ||
|
|
bf849c9faa | ||
|
|
118ab46ac0 | ||
|
|
d2f2b689f9 | ||
|
|
5229e45566 | ||
|
|
b17043e85d | ||
|
|
19ca909ef5 | ||
|
|
f7ff309b6e | ||
|
|
ea5a8486b9 | ||
|
|
58897b3436 | ||
|
|
6a59ecfa47 | ||
|
|
272066c6e0 | ||
|
|
0426c9d3b0 | ||
|
|
c22419ba0e | ||
|
|
8278fed2fb | ||
|
|
b24a65b490 | ||
|
|
fb509fabd8 | ||
|
|
d21285123f | ||
|
|
1029b6be0c | ||
|
|
3fff9455a1 | ||
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
85649ae283 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
|||||||
# Reference Projects
|
# Reference Projects
|
||||||
applesauce
|
applesauce
|
||||||
primal-web-app
|
primal-web-app
|
||||||
|
Amber
|
||||||
|
|
||||||
|
|||||||
77
Amber.md
Normal file
77
Amber.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
## Boris ↔ Amber bunker: current findings
|
||||||
|
|
||||||
|
- **Environment**
|
||||||
|
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
|
||||||
|
- Bunker: Amber (mobile).
|
||||||
|
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
|
||||||
|
|
||||||
|
## What we changed client-side
|
||||||
|
|
||||||
|
- **Signer wiring**
|
||||||
|
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
|
||||||
|
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
||||||
|
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
||||||
|
|
||||||
|
- **Probes and timeouts**
|
||||||
|
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
||||||
|
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
||||||
|
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
|
||||||
|
|
||||||
|
- **Logging**
|
||||||
|
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
|
||||||
|
- Confirmed NIP‑46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
|
||||||
|
|
||||||
|
## Evidence from logs (client)
|
||||||
|
|
||||||
|
```
|
||||||
|
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
|
||||||
|
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
|
||||||
|
[bunker] subscribe via signer: { relays: [...], filters: [...] }
|
||||||
|
[bunker] ✅ Signer subscription opened
|
||||||
|
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
|
||||||
|
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Amber’s).
|
||||||
|
|
||||||
|
## Evidence from Amber (device)
|
||||||
|
|
||||||
|
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
|
||||||
|
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
- Transport and publish paths are working: Boris is publishing NIP‑46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
|
||||||
|
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 10–30s windows.
|
||||||
|
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 decrypt handling or permission gating.
|
||||||
|
|
||||||
|
## Repro steps (quick)
|
||||||
|
|
||||||
|
1) Revoke Boris in Amber.
|
||||||
|
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip‑04 and nip‑44.
|
||||||
|
3) Keep Amber unlocked and foregrounded.
|
||||||
|
4) Reload Boris; observe:
|
||||||
|
- Logs showing `publish via signer` for kind 24133.
|
||||||
|
- In Amber, activity should include “Decrypt data using nip 4/44”.
|
||||||
|
|
||||||
|
If DECRYPT entries still don’t appear:
|
||||||
|
|
||||||
|
- This points to Amber’s NIP‑46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
|
||||||
|
|
||||||
|
## Suggestions for Amber-side debugging
|
||||||
|
|
||||||
|
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
|
||||||
|
- Confirm the provider recognizes NIP‑46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
|
||||||
|
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
||||||
|
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
||||||
|
|
||||||
|
## Current conclusion
|
||||||
|
|
||||||
|
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||||
|
- The missing DECRYPT activity in Amber is the blocker. Fixing Amber’s NIP‑46 decrypt handling should resolve bookmark decryption in Boris without further client changes.
|
||||||
|
|
||||||
|
|
||||||
124
CHANGELOG.md
124
CHANGELOG.md
@@ -7,6 +7,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.24] - 2025-01-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- TypeScript global declarations for build-time defines
|
||||||
|
- Added proper type declarations for `__APP_VERSION__`, `__GIT_COMMIT__`, `__GIT_BRANCH__`, `__BUILD_TIME__`, and `__GIT_COMMIT_URL__`
|
||||||
|
- Resolved ESLint no-undef errors for build-time injected variables
|
||||||
|
- Added Node.js environment hint to Vite configuration
|
||||||
|
|
||||||
|
## [0.6.23] - 2025-01-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deep-link refresh redirect issue for nostr-native articles
|
||||||
|
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
|
||||||
|
- Real browsers now hit the SPA directly, preventing redirect to root path
|
||||||
|
- Bot crawlers still receive proper OpenGraph metadata for social sharing
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Version and git commit information in Settings footer
|
||||||
|
- Displays app version and short commit hash with link to GitHub
|
||||||
|
- Build-time metadata injection via Vite configuration
|
||||||
|
- Subtle footer styling with selectable text
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article OG handler now uses proper RelayPool.request() API
|
||||||
|
- Aligned with applesauce RelayPool interface
|
||||||
|
- Removed deprecated open/close methods
|
||||||
|
- Fixed TypeScript linting errors
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Added debug logging for route state and article OG handler
|
||||||
|
- Gated by `?debug=1` query parameter for production testing
|
||||||
|
- Structured logging for troubleshooting deep-link issues
|
||||||
|
- Temporary debug components for validation
|
||||||
|
|
||||||
|
## [0.6.22] - 2025-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
|
||||||
|
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
|
||||||
|
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
|
||||||
|
- User-agent detection serves appropriate content to crawlers vs browsers
|
||||||
|
- Falls back to default social preview image when articles have no cover image
|
||||||
|
- Social preview image for homepage and article links
|
||||||
|
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
|
||||||
|
- Homepage now includes social preview image in meta tags
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article deep-links now properly preserve URL when loading in browser
|
||||||
|
- Uses `history.replaceState()` to maintain correct article path
|
||||||
|
- Browser navigation works correctly on refresh and new tab opens
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Vercel rewrite configuration for article routes
|
||||||
|
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
|
||||||
|
- Regular SPA routing preserved for browser navigation
|
||||||
|
|
||||||
|
## [0.6.21] - 2025-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
|
||||||
|
- Automatically saves and syncs reading position as you scroll
|
||||||
|
- Visual reading progress indicator on article cards
|
||||||
|
- Reading progress shown in Explore and Bookmarks sidebar
|
||||||
|
- Auto-scroll to last reading position setting (configurable in Settings)
|
||||||
|
- Reading position displayed as colored progress bar on cards
|
||||||
|
- Reading progress filters for organizing articles
|
||||||
|
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
|
||||||
|
- Filter icons colored when active (blue for most, green for completed)
|
||||||
|
- URL routing support for reading progress filters
|
||||||
|
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||||
|
- Reads and Links tabs on `/me` page
|
||||||
|
- Reads tab shows nostr-native articles with reading progress
|
||||||
|
- Links tab shows external URLs with reading progress
|
||||||
|
- Both tabs populate instantly from bookmarks for fast loading
|
||||||
|
- Lazy loading for improved performance
|
||||||
|
- Auto-mark as read at 100% reading progress
|
||||||
|
- Articles automatically marked as read when scrolled to end
|
||||||
|
- Marked-as-read articles treated as 100% progress
|
||||||
|
- Fancy checkmark animation on Mark as Read button
|
||||||
|
- Click-to-open article navigation on highlights
|
||||||
|
- Clicking highlights in Explore and Me pages opens the source article
|
||||||
|
- Automatically scrolls to highlighted text position
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed Archive to Reads with expanded functionality
|
||||||
|
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
|
||||||
|
- Simplified filter icon colors to blue (except green for completed)
|
||||||
|
- Started reading progress state (0-10%) uses neutral text color
|
||||||
|
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
|
||||||
|
- Removed unused IEventStore import in ContentPanel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reading position calculation now accurately reaches 100%
|
||||||
|
- Reading position filters work correctly in bookmarks sidebar
|
||||||
|
- Filter out reads without timestamps or 'Untitled' items
|
||||||
|
- Show skeleton placeholders correctly during initial tab load
|
||||||
|
- External URLs in Reads tab only shown if they have reading progress
|
||||||
|
- Reading progress merges even when timestamp is older than bookmark
|
||||||
|
- Resolved all linter errors and TypeScript type issues
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Renamed ArchiveFilters component to ReadingProgressFilters
|
||||||
|
- Extracted shared utilities from readsFromBookmarks for DRY code
|
||||||
|
- Use setState callback pattern for background enrichment
|
||||||
|
- Use naddr format for article IDs to match reading positions
|
||||||
|
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
|
||||||
|
|
||||||
## [0.6.20] - 2025-10-15
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1641,7 +1760,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD
|
||||||
|
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||||
|
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||||
|
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
|
|||||||
304
api/article-og.ts
Normal file
304
api/article-og.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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://relay.current.fyi',
|
||||||
|
'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 {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
|
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 – Nostr Bookmarks'
|
||||||
|
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) {
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!naddr) {
|
||||||
|
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'
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] request', JSON.stringify({
|
||||||
|
naddr,
|
||||||
|
ua: userAgent || null,
|
||||||
|
isCrawlerRequest,
|
||||||
|
path: req.url || null
|
||||||
|
}))
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||||
|
// Use history.replaceState to set the URL before the SPA boots
|
||||||
|
if (!isCrawlerRequest) {
|
||||||
|
const articlePath = `/a/${naddr}`
|
||||||
|
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<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')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache for bots/crawlers
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(naddr)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(cached.html)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<meta property="og:url" content="https://read.withboris.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
<meta property="og:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.20",
|
"version": "0.6.24",
|
||||||
"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",
|
||||||
|
|||||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
276
src/App.tsx
276
src/App.tsx
@@ -4,16 +4,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||||
import { EventStore } from 'applesauce-core'
|
import { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
import { DebugBus } from './utils/debugBus'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -112,7 +117,25 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/me/reads"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reads/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -147,6 +170,7 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/debug" element={<Debug />} />
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
@@ -168,20 +192,57 @@ function App() {
|
|||||||
// Register common account types (needed for deserialization)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
|
// Create relay pool and set it up BEFORE loading accounts
|
||||||
|
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||||
|
const pool = new RelayPool()
|
||||||
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
|
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
const result: any = pool.publish(relays, event as any)
|
||||||
|
if (result && typeof (result as any).subscribe === 'function') {
|
||||||
|
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||||
|
}
|
||||||
|
// Return an already-resolved promise so upstream await finishes immediately
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
|
||||||
|
|
||||||
|
// Create a relay group for better event deduplication and management
|
||||||
|
pool.group(RELAYS)
|
||||||
|
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
|
||||||
|
|
||||||
// Load persisted accounts from localStorage
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
|
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
||||||
|
|
||||||
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
|
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
|
||||||
|
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
if (activeId && accounts.getAccount(activeId)) {
|
console.log('[bunker] Active ID from localStorage:', activeId)
|
||||||
accounts.setActive(activeId)
|
|
||||||
console.log('Restored active account:', activeId)
|
if (activeId) {
|
||||||
|
const account = accounts.getAccount(activeId)
|
||||||
|
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
accounts.setActive(activeId)
|
||||||
|
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
||||||
|
} else {
|
||||||
|
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] No active account ID in localStorage')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', err)
|
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
// Subscribe to accounts changes and persist to localStorage
|
||||||
@@ -198,12 +259,197 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const pool = new RelayPool()
|
// Reconnect bunker signers when active account changes
|
||||||
|
// Keep track of which accounts we've already reconnected to avoid double-connecting
|
||||||
|
const reconnectedAccounts = new Set<string>()
|
||||||
|
|
||||||
// Create a relay group for better event deduplication and management
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
pool.group(RELAYS)
|
console.log('[bunker] Active account changed:', {
|
||||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
hasAccount: !!account,
|
||||||
console.log('Relay URLs:', RELAYS)
|
type: account?.type,
|
||||||
|
id: account?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (account && account.type === 'nostr-connect') {
|
||||||
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||||
|
try {
|
||||||
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
|
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
|
||||||
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
|
// Skip if we've already reconnected this account
|
||||||
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
|
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[bunker] Account detected. Status:', {
|
||||||
|
listening: nostrConnectAccount.signer.listening,
|
||||||
|
isConnected: nostrConnectAccount.signer.isConnected,
|
||||||
|
hasRemote: !!nostrConnectAccount.signer.remote,
|
||||||
|
bunkerRelays: nostrConnectAccount.signer.relays
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
|
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
|
||||||
|
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||||
|
// This ensures the pool has all relays when the signer sets up its methods
|
||||||
|
const bunkerRelays = signerData.relays || []
|
||||||
|
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||||
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
|
if (newBunkerRelays.length > 0) {
|
||||||
|
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
||||||
|
pool.group(newBunkerRelays)
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] Bunker relays already in pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
|
relays: signerData.relays,
|
||||||
|
pubkey: nostrConnectAccount.pubkey,
|
||||||
|
remote: signerData.remote,
|
||||||
|
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||||
|
pool: pool
|
||||||
|
})
|
||||||
|
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||||
|
try {
|
||||||
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
|
recreatedSigner.relays = mergedRelays
|
||||||
|
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
||||||
|
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||||
|
|
||||||
|
// Replace the signer on the account
|
||||||
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
console.log('[bunker] ✅ Signer recreated with pool context')
|
||||||
|
|
||||||
|
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||||
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
try {
|
||||||
|
let method: string | undefined
|
||||||
|
const content = (event as { content?: unknown })?.content
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
||||||
|
method = parsed?.method
|
||||||
|
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
||||||
|
}
|
||||||
|
const summary = {
|
||||||
|
relays,
|
||||||
|
kind: (event as { kind?: number })?.kind,
|
||||||
|
method,
|
||||||
|
// include tags array for debugging (NIP-46 expects method tag)
|
||||||
|
tags: (event as { tags?: unknown })?.tags,
|
||||||
|
contentLength: typeof content === 'string' ? content.length : undefined
|
||||||
|
}
|
||||||
|
console.log('[bunker] publish via signer:', summary)
|
||||||
|
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||||
|
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||||
|
// Fire-and-forget publish: trigger the publish but do not return the
|
||||||
|
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||||
|
const result = originalPublish(relays, event)
|
||||||
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||||
|
}
|
||||||
|
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
||||||
|
// Return a benign object so callers that probe for a "subscribe" property
|
||||||
|
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
||||||
|
return {} as unknown as never
|
||||||
|
}
|
||||||
|
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
||||||
|
try {
|
||||||
|
console.log('[bunker] subscribe via signer:', { relays, filters })
|
||||||
|
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
||||||
|
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||||
|
return originalSubscribe(relays, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
|
// The fromBunkerURI already connected with permissions during login
|
||||||
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
|
console.log('[bunker] Opening signer subscription...')
|
||||||
|
await nostrConnectAccount.signer.open()
|
||||||
|
console.log('[bunker] ✅ Signer subscription opened')
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] ✅ Signer already listening')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
|
try {
|
||||||
|
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
|
||||||
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
|
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
|
// This ensures the signer is ready to handle and receive responses
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
console.log("[bunker] Subscription ready after startup delay")
|
||||||
|
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||||
|
try {
|
||||||
|
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||||
|
return await Promise.race([
|
||||||
|
p,
|
||||||
|
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
setTimeout(async () => {
|
||||||
|
const self = nostrConnectAccount.pubkey
|
||||||
|
// Try a roundtrip so the bunker can respond successfully
|
||||||
|
try {
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
|
||||||
|
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||||
|
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
|
||||||
|
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||||
|
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe setup failed:', err)
|
||||||
|
}
|
||||||
|
// The bunker remembers the permissions from the initial connection
|
||||||
|
nostrConnectAccount.signer.isConnected = true
|
||||||
|
|
||||||
|
console.log('[bunker] Final signer status:', {
|
||||||
|
listening: nostrConnectAccount.signer.listening,
|
||||||
|
isConnected: nostrConnectAccount.signer.isConnected,
|
||||||
|
remote: nostrConnectAccount.signer.remote,
|
||||||
|
relays: nostrConnectAccount.signer.relays
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark this account as reconnected
|
||||||
|
reconnectedAccounts.add(account.id)
|
||||||
|
console.log('[bunker] 🎉 Signer ready for signing')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
@@ -233,6 +479,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
|
bunkerReconnectSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
@@ -249,7 +496,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
// Monitor online/offline status
|
// Monitor online/offline status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -285,6 +532,7 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
@@ -22,17 +21,24 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bookmark-filters">
|
<div className="bookmark-filters">
|
||||||
{filters.map(filter => (
|
{filters.map(filter => {
|
||||||
<button
|
const isActive = selectedFilter === filter.type
|
||||||
key={filter.type}
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
onClick={() => onFilterChange(filter.type)}
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
|
||||||
title={filter.label}
|
return (
|
||||||
aria-label={`Filter by ${filter.label}`}
|
<button
|
||||||
>
|
key={filter.type}
|
||||||
<FontAwesomeIcon icon={filter.icon} />
|
onClick={() => onFilterChange(filter.type)}
|
||||||
</button>
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
))}
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate progress percentage and determine color
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RELAYS } from '../config/relays'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import LoginOptions from './LoginOptions'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -153,7 +154,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
{!activeAccount ? (
|
||||||
|
<LoginOptions />
|
||||||
|
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks match this filter.</p>
|
<p>No bookmarks match this filter.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +173,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface LargeViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
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) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
{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: `${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">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/archive' ? 'archive' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
|
location.pathname === '/me/links' ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
|
|||||||
395
src/components/Debug.tsx
Normal file
395
src/components/Debug.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
|
|
||||||
|
const Debug: React.FC = () => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [payload, setPayload] = useState<string>(defaultPayload)
|
||||||
|
const [cipher44, setCipher44] = useState<string>('')
|
||||||
|
const [cipher04, setCipher04] = useState<string>('')
|
||||||
|
const [plain44, setPlain44] = useState<string>('')
|
||||||
|
const [plain04, setPlain04] = useState<string>('')
|
||||||
|
const [tEncrypt44, setTEncrypt44] = useState<number | null>(null)
|
||||||
|
const [tEncrypt04, setTEncrypt04] = useState<number | null>(null)
|
||||||
|
const [tDecrypt44, setTDecrypt44] = useState<number | null>(null)
|
||||||
|
const [tDecrypt04, setTDecrypt04] = useState<number | null>(null)
|
||||||
|
const [logs, setLogs] = useState<DebugLogEntry[]>(DebugBus.snapshot())
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState<boolean>(() => localStorage.getItem('debug') === '*')
|
||||||
|
|
||||||
|
// Bunker login state
|
||||||
|
const [bunkerUri, setBunkerUri] = useState<string>('')
|
||||||
|
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
||||||
|
const [bunkerError, setBunkerError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Live timing state
|
||||||
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live timer effect - triggers re-renders for live timing updates
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// Force re-render to update live timing display
|
||||||
|
setLiveTiming(prev => prev)
|
||||||
|
}, 16) // ~60fps for smooth updates
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const signer = useMemo(() => (activeAccount as unknown as { signer?: unknown })?.signer, [activeAccount])
|
||||||
|
const pubkey = (activeAccount as unknown as { pubkey?: string })?.pubkey
|
||||||
|
|
||||||
|
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
|
||||||
|
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
|
||||||
|
|
||||||
|
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { encrypt: (pubkey: string, message: string) => Promise<string> } })[mode]
|
||||||
|
DebugBus.info('debug', `encrypt start ${mode}`, { pubkey, len: payload.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'encrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const cipher = await api.encrypt(pubkey, payload)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `encrypt done ${mode}`, { len: typeof cipher === 'string' ? cipher.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setCipher44(cipher)
|
||||||
|
else setCipher04(cipher)
|
||||||
|
if (mode === 'nip44') setTEncrypt44(ms)
|
||||||
|
else setTEncrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `encrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDecrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } })[mode]
|
||||||
|
const cipher = mode === 'nip44' ? cipher44 : cipher04
|
||||||
|
if (!cipher) {
|
||||||
|
DebugBus.warn('debug', `no cipher to decrypt for ${mode}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugBus.info('debug', `decrypt start ${mode}`, { len: cipher.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'decrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const plain = await api.decrypt(pubkey, cipher)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `decrypt done ${mode}`, { len: typeof plain === 'string' ? plain.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setPlain44(String(plain))
|
||||||
|
else setPlain04(String(plain))
|
||||||
|
if (mode === 'nip44') setTDecrypt44(ms)
|
||||||
|
else setTDecrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `decrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDebug = () => {
|
||||||
|
const next = !debugEnabled
|
||||||
|
setDebugEnabled(next)
|
||||||
|
if (next) localStorage.setItem('debug', '*')
|
||||||
|
else localStorage.removeItem('debug')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setBunkerError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setBunkerError('Invalid bunker URI. Must start with bunker://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsBunkerLoading(true)
|
||||||
|
setBunkerError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setBunkerError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
setBunkerError(errorMessage)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsBunkerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeBox = ({ value }: { value: string }) => (
|
||||||
|
<div className="h-20 overflow-y-auto font-mono text-xs leading-relaxed p-2 bg-gray-100 dark:bg-gray-800 rounded whitespace-pre-wrap break-all">
|
||||||
|
{value || '—'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLiveTiming = (mode: 'nip44' | 'nip04', type: 'encrypt' | 'decrypt') => {
|
||||||
|
const timing = liveTiming[mode]
|
||||||
|
if (timing && timing.type === type) {
|
||||||
|
const elapsed = Math.round(performance.now() - timing.startTime)
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stat = ({ label, value, mode, type }: {
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null;
|
||||||
|
mode?: 'nip44' | 'nip04';
|
||||||
|
type?: 'encrypt' | 'decrypt';
|
||||||
|
}) => {
|
||||||
|
const liveValue = mode && type ? getLiveTiming(mode, type) : null
|
||||||
|
const isLive = !!liveValue
|
||||||
|
|
||||||
|
let displayValue: string
|
||||||
|
if (isLive) {
|
||||||
|
displayValue = ''
|
||||||
|
} else if (value !== null && value !== undefined) {
|
||||||
|
displayValue = `${value}ms`
|
||||||
|
} else {
|
||||||
|
displayValue = '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="badge" style={{ marginRight: 8 }}>
|
||||||
|
<FontAwesomeIcon icon={faClock} style={{ marginRight: 4, fontSize: '0.8em' }} />
|
||||||
|
{label}: {isLive ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" style={{ fontSize: '0.8em' }} />
|
||||||
|
) : (
|
||||||
|
displayValue
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-view">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h2>Debug</h2>
|
||||||
|
<div className="settings-header-actions">
|
||||||
|
<span className="opacity-70">Active pubkey:</span> <code className="text-sm">{pubkey || 'none'}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
|
||||||
|
{/* Bunker Login Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Bunker Connection</h3>
|
||||||
|
{!activeAccount ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isBunkerLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isBunkerLoading || !bunkerUri.trim()}
|
||||||
|
>
|
||||||
|
{isBunkerLoading ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bunkerError && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 mb-2">{bunkerError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70">Connected to bunker</div>
|
||||||
|
<div className="text-sm font-mono">{pubkey}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'rgb(220 38 38)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgb(220 38 38)',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgb(185 28 28)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'rgb(220 38 38)'}
|
||||||
|
onClick={() => accountManager.removeAccount(activeAccount)}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encryption Tools Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Encryption Tools</h3>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Payload</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
|
||||||
|
value={payload}
|
||||||
|
onChange={e => setPayload(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-3 justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setPayload(defaultPayload)}>Reset</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => { setCipher44(''); setCipher04(''); setPlain44(''); setPlain04(''); setTEncrypt44(null); setTEncrypt04(null); setTDecrypt44(null); setTDecrypt04(null) }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip44')} disabled={!hasNip44}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip44')} disabled={!cipher44}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher44} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain44} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip04')} disabled={!hasNip04}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip04')} disabled={!cipher04}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher04} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain04} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Timing Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Performance Timing</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Encryption and decryption operation durations</div>
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt44} mode="nip44" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt44} mode="nip44" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt04} mode="nip04" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt04} mode="nip04" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug Logs Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Debug Logs</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Recent bunker logs:</div>
|
||||||
|
<div className="max-h-192 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-sm opacity-50 italic">No logs yet</div>
|
||||||
|
) : (
|
||||||
|
logs.slice(-200).map((l, i) => (
|
||||||
|
<div key={i} className="mb-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<span className="opacity-70">[{new Date(l.ts).toLocaleTimeString()}]</span> <span className="font-semibold">{l.level.toUpperCase()}</span> {l.source}: {l.message}
|
||||||
|
{l.data !== undefined && (
|
||||||
|
<span className="opacity-70"> — {typeof l.data === 'string' ? l.data : JSON.stringify(l.data)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={toggleDebug}
|
||||||
|
className="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Show all applesauce debug logs</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setLogs([])}>Clear logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VersionFooter />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Debug
|
||||||
179
src/components/LoginOptions.tsx
Normal file
179
src/components/LoginOptions.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
|
||||||
|
const LoginOptions: React.FC = () => {
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||||
|
const [bunkerUri, setBunkerUri] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleExtensionLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Extension login failed:', err)
|
||||||
|
setError('Login failed. Please install a nostr browser extension and try again.')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setError('Invalid bunker URI. Must start with bunker://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
setError(errorMessage)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p style={{ marginBottom: '1rem' }}>Login with:</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleExtensionLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: isLoading ? 'wait' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!showBunkerInput ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBunkerInput(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: isLoading ? 'wait' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bunker
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBunkerLogin()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isLoading || !bunkerUri.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
flex: 1,
|
||||||
|
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
setBunkerUri('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>
|
||||||
|
If you aren't on nostr yet, start here:{' '}
|
||||||
|
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||||
|
nstart.me
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginOptions
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -19,15 +20,18 @@ import BlogPostCard from './BlogPostCard'
|
|||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -35,12 +39,15 @@ interface MeProps {
|
|||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
|
// Valid reading progress filters
|
||||||
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const eventStore = Hooks.useEventStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
// Use provided pubkey or fall back to active account
|
// Use provided pubkey or fall back to active account
|
||||||
@@ -48,14 +55,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
|
||||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
// Initialize reading progress filter from URL param
|
||||||
|
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
|
: 'all'
|
||||||
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,131 +79,246 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
if (!viewingPubkey) {
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
setLoading(false)
|
: 'all'
|
||||||
return
|
setReadingProgressFilter(filterFromUrl)
|
||||||
}
|
}, [urlFilter])
|
||||||
|
|
||||||
|
// Handler to change reading progress filter and update URL
|
||||||
|
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
|
||||||
|
setReadingProgressFilter(filter)
|
||||||
|
if (activeTab === 'reads') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/me/reads', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/me/reads/${filter}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-specific loading functions
|
||||||
|
const loadHighlightsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
// Only show loading skeleton if tab hasn't been loaded yet
|
||||||
|
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWritingsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('writings')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
setWritings(userWritings)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load writings:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReadingListTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
// Seed from cache if available to avoid empty flash (own profile only)
|
})
|
||||||
if (isOwnProfile) {
|
|
||||||
const cached = getCachedMeData(viewingPubkey)
|
|
||||||
if (cached) {
|
|
||||||
setHighlights(cached.highlights)
|
|
||||||
setBookmarks(cached.bookmarks)
|
|
||||||
setReadArticles(cached.readArticles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch highlights and writings (public data)
|
|
||||||
const [userHighlights, userWritings] = await Promise.all([
|
|
||||||
fetchHighlights(relayPool, viewingPubkey),
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
|
||||||
])
|
|
||||||
|
|
||||||
setHighlights(userHighlights)
|
|
||||||
setWritings(userWritings)
|
|
||||||
|
|
||||||
// Only fetch private data for own profile
|
|
||||||
if (isOwnProfile && activeAccount) {
|
|
||||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
|
||||||
setReadArticles(userReadArticles)
|
|
||||||
|
|
||||||
// Fetch bookmarks using callback pattern
|
|
||||||
let fetchedBookmarks: Bookmark[] = []
|
|
||||||
try {
|
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
|
||||||
fetchedBookmarks = newBookmarks
|
|
||||||
setBookmarks(newBookmarks)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load bookmarks:', err)
|
|
||||||
setBookmarks([])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache with all fetched data
|
|
||||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
|
||||||
} else {
|
|
||||||
setBookmarks([])
|
|
||||||
setReadArticles([])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.warn('Failed to load bookmarks:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
setBookmarks([])
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reading list:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadData()
|
const loadReadsTab = async () => {
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
// Load reading positions for read articles (only for own profile)
|
const hasBeenLoaded = loadedTabs.has('reads')
|
||||||
useEffect(() => {
|
|
||||||
const loadPositions = async () => {
|
try {
|
||||||
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
console.log('🔍 [Archive] Skipping position load:', {
|
|
||||||
isOwnProfile,
|
// Ensure bookmarks are loaded
|
||||||
hasAccount: !!activeAccount,
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
hasRelayPool: !!relayPool,
|
if (bookmarks.length === 0) {
|
||||||
hasEventStore: !!eventStore,
|
try {
|
||||||
articlesCount: readArticles.length
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
})
|
fetchedBookmarks = newBookmarks
|
||||||
return
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
// Derive reads from bookmarks immediately
|
||||||
|
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
|
||||||
const positions = new Map<string, number>()
|
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||||
|
setReadsMap(initialMap)
|
||||||
// Load positions for all read articles
|
setReads(initialReads)
|
||||||
await Promise.all(
|
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||||
readArticles.map(async (post) => {
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
try {
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
const naddr = nip19.naddrEncode({
|
// Only update items that are already in our map
|
||||||
kind: 30023,
|
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
|
||||||
pubkey: post.author,
|
console.log('📈 [Reads] Enrichment item received:', {
|
||||||
identifier: dTag
|
id: item.id.slice(0, 20) + '...',
|
||||||
})
|
progress: item.readingProgress,
|
||||||
const articleUrl = `nostr:${naddr}`
|
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
||||||
const identifier = generateArticleIdentifier(articleUrl)
|
})
|
||||||
|
|
||||||
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
setReadsMap(prevMap => {
|
||||||
|
// Only update if item exists in our current map
|
||||||
const savedPosition = await loadReadingPosition(
|
if (!prevMap.has(item.id)) {
|
||||||
relayPool,
|
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||||
eventStore,
|
return prevMap
|
||||||
activeAccount.pubkey,
|
|
||||||
identifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0) {
|
|
||||||
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
|
||||||
positions.set(post.event.id, savedPosition.position)
|
|
||||||
} else {
|
|
||||||
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMap = new Map(prevMap)
|
||||||
|
const merged = mergeReadItem(newMap, item)
|
||||||
|
if (merged) {
|
||||||
|
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||||
|
// Update reads array after map is updated
|
||||||
|
setReads(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
})
|
})
|
||||||
)
|
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reads:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
const loadLinksTab = async () => {
|
||||||
setReadingPositions(positions)
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('links')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
|
// Ensure bookmarks are loaded
|
||||||
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
|
if (bookmarks.length === 0) {
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
fetchedBookmarks = newBookmarks
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive links from bookmarks immediately
|
||||||
|
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
|
||||||
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
|
setLinksMap(initialMap)
|
||||||
|
setLinks(initialLinks)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('links'))
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
|
// Only update items that are already in our map
|
||||||
|
fetchLinks(relayPool, viewingPubkey, (item) => {
|
||||||
|
setLinksMap(prevMap => {
|
||||||
|
// Only update if item exists in our current map
|
||||||
|
if (!prevMap.has(item.id)) return prevMap
|
||||||
|
|
||||||
|
const newMap = new Map(prevMap)
|
||||||
|
if (mergeReadItem(newMap, item)) {
|
||||||
|
// Update links array after map is updated
|
||||||
|
setLinks(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
|
})
|
||||||
|
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load links:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active tab data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewingPubkey || !activeTab) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPositions()
|
// Load cached data immediately if available
|
||||||
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
if (isOwnProfile) {
|
||||||
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
|
if (cached) {
|
||||||
|
setHighlights(cached.highlights)
|
||||||
|
setBookmarks(cached.bookmarks)
|
||||||
|
setReads(cached.reads || [])
|
||||||
|
setLinks(cached.links || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Load data for active tab (refresh in background if already loaded)
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
loadHighlightsTab()
|
||||||
|
break
|
||||||
|
case 'writings':
|
||||||
|
loadWritingsTab()
|
||||||
|
break
|
||||||
|
case 'reading-list':
|
||||||
|
loadReadingListTab()
|
||||||
|
break
|
||||||
|
case 'reads':
|
||||||
|
loadReadsTab()
|
||||||
|
break
|
||||||
|
case 'links':
|
||||||
|
loadLinksTab()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
|
|
||||||
|
|
||||||
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
|
// Just trigger refresh - loaders will merge new data
|
||||||
setRefreshTrigger(prev => prev + 1)
|
setRefreshTrigger(prev => prev + 1)
|
||||||
},
|
},
|
||||||
maximumPullLength: 240,
|
maximumPullLength: 240,
|
||||||
@@ -217,6 +347,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
|
if (item.type === 'article') {
|
||||||
|
// ID is already in naddr format
|
||||||
|
return `/a/${item.id}`
|
||||||
|
} else if (item.url) {
|
||||||
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||||
|
if (item.event) {
|
||||||
|
return {
|
||||||
|
event: item.event,
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || item.event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock event for external URLs
|
||||||
|
const mockEvent = {
|
||||||
|
id: item.id,
|
||||||
|
pubkey: item.author || '',
|
||||||
|
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [] as string[][],
|
||||||
|
content: item.title || item.url || 'Untitled',
|
||||||
|
sig: ''
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||||
|
title: item.title || item.url || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
// For kind:30023 articles, navigate to the article route
|
||||||
@@ -245,29 +418,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Apply archive filter
|
// Apply reading progress filter
|
||||||
const filteredReadArticles = readArticles.filter(post => {
|
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||||
const position = readingPositions.get(post.event.id)
|
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||||
|
|
||||||
switch (archiveFilter) {
|
|
||||||
case 'to-read':
|
|
||||||
// No position or 0% progress
|
|
||||||
return !position || position === 0
|
|
||||||
case 'reading':
|
|
||||||
// Has some progress but not completed (0 < position < 1)
|
|
||||||
return position !== undefined && position > 0 && position < 0.95
|
|
||||||
case 'completed':
|
|
||||||
// 95% or more read (we consider 95%+ as completed)
|
|
||||||
return position !== undefined && position >= 0.95
|
|
||||||
case 'marked':
|
|
||||||
// Manually marked as read (in archive but no reading position data)
|
|
||||||
// These are articles that were marked via the emoji reaction
|
|
||||||
return !position || position === 0
|
|
||||||
case 'all':
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
@@ -276,7 +429,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -291,9 +444,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 ? (
|
return highlights.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No highlights yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list me-highlights-list">
|
<div className="highlights-list me-highlights-list">
|
||||||
@@ -320,9 +473,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return allIndividualBookmarks.length === 0 ? (
|
return allIndividualBookmarks.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No bookmarks yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -386,8 +539,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'archive':
|
case 'reads':
|
||||||
if (showSkeletons) {
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('reads')) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -396,32 +550,84 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
// Show empty state if loaded but no reads
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||||
</div>
|
return (
|
||||||
) : (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles read yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show reads with filters
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{readArticles.length > 0 && (
|
<ReadingProgressFilters
|
||||||
<ArchiveFilters
|
selectedFilter={readingProgressFilter}
|
||||||
selectedFilter={archiveFilter}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
onFilterChange={setArchiveFilter}
|
/>
|
||||||
/>
|
{filteredReads.length === 0 ? (
|
||||||
)}
|
|
||||||
{filteredReadArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles match this filter.
|
No articles match this filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{filteredReadArticles.map((post) => (
|
{filteredReads.map((item) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={item.id}
|
||||||
post={post}
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
href={getPostUrl(post)}
|
href={getReadItemUrl(item)}
|
||||||
readingProgress={readingPositions.get(post.event.id)}
|
readingProgress={item.readingProgress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if loaded but no links
|
||||||
|
if (links.length === 0 && loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links with reading progress yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show links with filters
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadingProgressFilters
|
||||||
|
selectedFilter={readingProgressFilter}
|
||||||
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
|
/>
|
||||||
|
{filteredLinks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -437,9 +643,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 ? (
|
return writings.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -487,12 +693,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
data-tab="archive"
|
data-tab="reads"
|
||||||
onClick={() => navigate('/me/archive')}
|
onClick={() => navigate('/me/reads')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Archive</span>
|
<span className="tab-label">Reads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
|
data-tab="links"
|
||||||
|
onClick={() => navigate('/me/links')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLink} />
|
||||||
|
<span className="tab-label">Links</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
47
src/components/ReadingProgressFilters.tsx
Normal file
47
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
const leftOffset = isSidebarCollapsed
|
const leftOffset = isSidebarCollapsed
|
||||||
? 'var(--sidebar-collapsed-width)'
|
? 'var(--sidebar-collapsed-width)'
|
||||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
style={{ backgroundColor: 'var(--color-border)' }}
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
isComplete
|
|
||||||
? 'bg-green-500'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${clampedProgress}%`,
|
width: `${clampedProgress}%`,
|
||||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
backgroundColor: barColorStyle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
isComplete ? 'text-green-500' : ''
|
isComplete ? 'text-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
console.debug('[RouteDebug]', info)
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import ZapSettings from './Settings/ZapSettings'
|
|||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -167,6 +168,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/components/VersionFooter.tsx
Normal file
32
src/components/VersionFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const VersionFooter: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>
|
||||||
|
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
|
||||||
|
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·{' '}
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionFooter
|
||||||
15
src/config/kinds.ts
Normal file
15
src/config/kinds.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-?? user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data (reading positions)
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
export const RELAYS = [
|
export const RELAYS = [
|
||||||
'ws://localhost:10547',
|
'ws://localhost:10547',
|
||||||
'ws://localhost:4869',
|
'ws://localhost:4869',
|
||||||
|
'wss://relay.nsec.app',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.nostr.band',
|
'wss://relay.nostr.band',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
|
|||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
}: UseHighlightCreationParams) => {
|
}: UseHighlightCreationParams) => {
|
||||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const handleTextSelection = useCallback((text: string) => {
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
highlightButtonRef.current?.updateSelection(text)
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
@@ -92,10 +94,19 @@ export const useHighlightCreation = ({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
showToast('Reconnect bunker and approve signing permissions to create highlights')
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to create highlight: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
|
|||||||
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a decrypt promise with a timeout to prevent hanging (using 30s timeout for bunker)
|
||||||
|
*/
|
||||||
|
function withDecryptTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectBookmarksFromEvents(
|
export async function collectBookmarksFromEvents(
|
||||||
bookmarkListEvents: NostrEvent[],
|
bookmarkListEvents: NostrEvent[],
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
@@ -80,7 +92,8 @@ export async function collectBookmarksFromEvents(
|
|||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,24 +101,26 @@ export async function collectBookmarksFromEvents(
|
|||||||
let decryptedContent: string | undefined
|
let decryptedContent: string | undefined
|
||||||
try {
|
try {
|
||||||
if (hasNip44Decrypt(signerCandidate)) {
|
if (hasNip44Decrypt(signerCandidate)) {
|
||||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
decryptedContent = await withDecryptTimeout((signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
||||||
evt.pubkey,
|
evt.pubkey,
|
||||||
evt.content
|
evt.content
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decryptedContent) {
|
if (!decryptedContent) {
|
||||||
try {
|
try {
|
||||||
if (hasNip04Decrypt(signerCandidate)) {
|
if (hasNip04Decrypt(signerCandidate)) {
|
||||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
decryptedContent = await withDecryptTimeout((signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
||||||
evt.pubkey,
|
evt.pubkey,
|
||||||
evt.content
|
evt.content
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +142,7 @@ export async function collectBookmarksFromEvents(
|
|||||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
|||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export const fetchBookmarks = async (
|
|||||||
|
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||||
@@ -71,7 +72,7 @@ export const fetchBookmarks = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Check specifically for Primal's "reads" list
|
// Check specifically for Primal's "reads" list
|
||||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||||
if (primalReads) {
|
if (primalReads) {
|
||||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||||
} else {
|
} else {
|
||||||
@@ -84,7 +85,7 @@ export const fetchBookmarks = async (
|
|||||||
}
|
}
|
||||||
// Aggregate across events
|
// Aggregate across events
|
||||||
const maybeAccount = activeAccount as AccountWithExtension
|
const maybeAccount = activeAccount as AccountWithExtension
|
||||||
console.log('🔐 Account object:', {
|
console.log('[bunker] 🔐 Account object:', {
|
||||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
||||||
hasSigner: !!maybeAccount?.signer,
|
hasSigner: !!maybeAccount?.signer,
|
||||||
accountType: typeof maybeAccount,
|
accountType: typeof maybeAccount,
|
||||||
@@ -101,12 +102,19 @@ export const fetchBookmarks = async (
|
|||||||
signerCandidate = maybeAccount.signer
|
signerCandidate = maybeAccount.signer
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
console.log('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||||
if (signerCandidate) {
|
if (signerCandidate) {
|
||||||
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
||||||
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
||||||
}
|
}
|
||||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
|
||||||
|
// Debug relay connectivity for bunker relays
|
||||||
|
try {
|
||||||
|
const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected }))
|
||||||
|
console.log('[bunker] Relay connections:', urls)
|
||||||
|
} catch (err) { console.warn('[bunker] Failed to read relay connections', err) }
|
||||||
|
|
||||||
|
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
||||||
bookmarkListEvents,
|
bookmarkListEvents,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
signerCandidate
|
signerCandidate
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
|
|
||||||
await queryEvents(
|
await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create EventFactory with the account as signer
|
// Create EventFactory with the account as signer
|
||||||
const factory = new EventFactory({ signer: account })
|
console.log("[bunker] Creating EventFactory with signer:", { signerType: account.signer?.constructor?.name })
|
||||||
|
const factory = new EventFactory({ signer: account.signer })
|
||||||
|
|
||||||
let blueprintSource: NostrEvent | AddressPointer | string
|
let blueprintSource: NostrEvent | AddressPointer | string
|
||||||
let context: string | undefined
|
let context: string | undefined
|
||||||
@@ -116,7 +117,9 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
|
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
console.log('[bunker] ✅ Highlight signed successfully!', { id: signedEvent.id.slice(0, 8) })
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Use unified write service to store and publish
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
await publishEvent(relayPool, eventStore, signedEvent)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { KINDS } from '../../config/kinds'
|
||||||
|
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -21,7 +22,7 @@ export const fetchHighlights = async (
|
|||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const local$ = localRelays.length > 0
|
const local$ = localRelays.length > 0
|
||||||
? relayPool
|
? relayPool
|
||||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
tap((event: NostrEvent) => {
|
tap((event: NostrEvent) => {
|
||||||
@@ -36,7 +37,7 @@ export const fetchHighlights = async (
|
|||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
const remote$ = remoteRelays.length > 0
|
const remote$ = remoteRelays.length > 0
|
||||||
? relayPool
|
? relayPool
|
||||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
tap((event: NostrEvent) => {
|
tap((event: NostrEvent) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
|
|||||||
try {
|
try {
|
||||||
// Fetch kind:7 and kind:17 reactions in parallel
|
// Fetch kind:7 and kind:17 reactions in parallel
|
||||||
const [kind7Events, kind17Events] = await Promise.all([
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||||
])
|
])
|
||||||
|
|
||||||
const readArticles: ReadArticle[] = []
|
const readArticles: ReadArticle[] = []
|
||||||
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
// Filter to only nostr-native articles (kind 30023)
|
// Filter to only nostr-native articles (kind 30023)
|
||||||
const nostrArticles = readArticles.filter(
|
const nostrArticles = readArticles.filter(
|
||||||
article => article.eventKind === 30023 && article.eventId
|
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (nostrArticles.length === 0) {
|
if (nostrArticles.length === 0) {
|
||||||
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
const articleEvents = await queryEvents(
|
const articleEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], ids: eventIds },
|
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||||
{ relayUrls: RELAYS }
|
{ relayUrls: RELAYS }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
90
src/services/linksService.ts
Normal file
90
src/services/linksService.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches external URL links with reading progress from:
|
||||||
|
* - URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchLinks(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(linksMap, item)) {
|
||||||
|
onItem(linksMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
linksMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Links] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit external items
|
||||||
|
processReadingPositions(readingPositionEvents, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit external items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for external URLs only with reading progress
|
||||||
|
const links = Array.from(linksMap.values())
|
||||||
|
.filter(item => {
|
||||||
|
// Only external URLs
|
||||||
|
if (item.type !== 'external') return false
|
||||||
|
|
||||||
|
// Only include if there's reading progress or marked as read
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
return hasProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply common validation and sorting
|
||||||
|
const validLinks = filterValidItems(links)
|
||||||
|
const sortedLinks = sortByReadingActivity(validLinks)
|
||||||
|
|
||||||
|
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
||||||
|
return sortedLinks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch links:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
readArticles: BlogPostPreview[]
|
readArticles: BlogPostPreview[]
|
||||||
|
reads?: ReadItem[]
|
||||||
|
links?: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/services/nostrConnect.ts
Normal file
26
src/services/nostrConnect.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default NIP-46 permissions for bunker connections
|
||||||
|
* These permissions cover all event kinds and encryption/decryption operations Boris needs
|
||||||
|
*/
|
||||||
|
export function getDefaultBunkerPermissions(): string[] {
|
||||||
|
return [
|
||||||
|
// Signing permissions for event kinds we create
|
||||||
|
...NostrConnectSigner.buildSigningPermissions([
|
||||||
|
0, // Profile metadata
|
||||||
|
5, // Event deletion
|
||||||
|
7, // Reactions (nostr events)
|
||||||
|
17, // Reactions (websites)
|
||||||
|
9802, // Highlights
|
||||||
|
30078, // Settings & reading positions
|
||||||
|
39701, // Web bookmarks
|
||||||
|
]),
|
||||||
|
// Encryption/decryption for hidden content
|
||||||
|
'nip04_encrypt',
|
||||||
|
'nip04_decrypt',
|
||||||
|
'nip44_encrypt',
|
||||||
|
'nip44_decrypt',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
147
src/services/readingDataProcessor.ts
Normal file
147
src/services/readingDataProcessor.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
interface ReadArticle {
|
||||||
|
id: string
|
||||||
|
url?: string
|
||||||
|
eventId?: string
|
||||||
|
eventKind?: number
|
||||||
|
markedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes reading position events into ReadItems
|
||||||
|
*/
|
||||||
|
export function processReadingPositions(
|
||||||
|
events: NostrEvent[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
||||||
|
|
||||||
|
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const positionData = JSON.parse(event.content)
|
||||||
|
const position = positionData.position
|
||||||
|
const timestamp = positionData.timestamp
|
||||||
|
|
||||||
|
let itemId: string
|
||||||
|
let itemUrl: string | undefined
|
||||||
|
let itemType: 'article' | 'external' = 'external'
|
||||||
|
|
||||||
|
// Check if it's a nostr article (naddr format)
|
||||||
|
if (identifier.startsWith('naddr1')) {
|
||||||
|
itemId = identifier
|
||||||
|
itemType = 'article'
|
||||||
|
} else {
|
||||||
|
// It's a base64url-encoded URL
|
||||||
|
try {
|
||||||
|
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
itemId = itemUrl
|
||||||
|
itemType = 'external'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode URL identifier:', identifier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update the item
|
||||||
|
const existing = readsMap.get(itemId)
|
||||||
|
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||||
|
readsMap.set(itemId, {
|
||||||
|
...existing,
|
||||||
|
id: itemId,
|
||||||
|
source: 'reading-progress',
|
||||||
|
type: itemType,
|
||||||
|
url: itemUrl,
|
||||||
|
readingProgress: position,
|
||||||
|
readingTimestamp: timestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes marked-as-read articles into ReadItems
|
||||||
|
*/
|
||||||
|
export function processMarkedAsRead(
|
||||||
|
articles: ReadArticle[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const article of articles) {
|
||||||
|
const existing = readsMap.get(article.id)
|
||||||
|
|
||||||
|
if (article.eventId && article.eventKind === 30023) {
|
||||||
|
// Nostr article
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'article',
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
} else if (article.url) {
|
||||||
|
// External URL
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'external',
|
||||||
|
url: article.url,
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts ReadItems by most recent reading activity
|
||||||
|
*/
|
||||||
|
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||||
|
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out items without timestamps and enriches external items with fallback titles
|
||||||
|
*/
|
||||||
|
export function filterValidItems(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items
|
||||||
|
.filter(item => {
|
||||||
|
// Only include items that have a timestamp
|
||||||
|
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
|
||||||
|
(item.markedAt && item.markedAt > 0)
|
||||||
|
if (!hasTimestamp) return false
|
||||||
|
|
||||||
|
// For Nostr articles, we need the event to be valid
|
||||||
|
if (item.type === 'article' && !item.event) return false
|
||||||
|
|
||||||
|
// For external URLs, we need at least a URL
|
||||||
|
if (item.type === 'external' && !item.url) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(item => {
|
||||||
|
// Add fallback title for external URLs without titles
|
||||||
|
if (item.type === 'external' && !item.title && item.url) {
|
||||||
|
return { ...item, title: fallbackTitleFromUrl(item.url) }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
197
src/services/readsService.ts
Normal file
197
src/services/readsService.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
export interface ReadItem {
|
||||||
|
id: string // event ID or URL or coordinate
|
||||||
|
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||||
|
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||||
|
|
||||||
|
// Article data
|
||||||
|
event?: NostrEvent
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author?: string
|
||||||
|
|
||||||
|
// Reading metadata
|
||||||
|
readingProgress?: number // 0-1
|
||||||
|
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||||
|
markedAsRead?: boolean
|
||||||
|
markedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all reads from multiple sources:
|
||||||
|
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||||
|
* - Articles/URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchAllReads(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
bookmarks: Bookmark[],
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(readsMap, item)) {
|
||||||
|
onItem(readsMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
readsMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Reads] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length,
|
||||||
|
bookmarks: bookmarks.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit items
|
||||||
|
processReadingPositions(readingPositionEvents, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process bookmarked articles and article/website URLs
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles
|
||||||
|
if (bookmarkType === 'article') {
|
||||||
|
// Kind:30023 nostr article
|
||||||
|
const coordinate = bookmark.id // Already in coordinate format
|
||||||
|
const existing = readsMap.get(coordinate)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: coordinate,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
readsMap.set(coordinate, item)
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch full event data for nostr articles
|
||||||
|
const articleCoordinates = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article' && !item.event)
|
||||||
|
.map(item => item.id)
|
||||||
|
|
||||||
|
if (articleCoordinates.length > 0) {
|
||||||
|
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||||
|
|
||||||
|
// Parse coordinates and fetch events
|
||||||
|
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||||
|
|
||||||
|
for (const coord of articleCoordinates) {
|
||||||
|
try {
|
||||||
|
// Try to decode as naddr
|
||||||
|
if (coord.startsWith('naddr1')) {
|
||||||
|
const decoded = nip19.decode(coord)
|
||||||
|
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
identifier: decoded.data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try coordinate format (kind:pubkey:identifier)
|
||||||
|
const parts = coord.split(':')
|
||||||
|
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode article coordinate:', coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articlesToFetch.length > 0) {
|
||||||
|
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
|
||||||
|
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
||||||
|
{ relayUrls: RELAYS }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Merge event data into ReadItems and emit
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const item = readsMap.get(coordinate) || readsMap.get(event.id)
|
||||||
|
if (item) {
|
||||||
|
item.event = event
|
||||||
|
item.title = getArticleTitle(event) || 'Untitled'
|
||||||
|
item.summary = getArticleSummary(event)
|
||||||
|
item.image = getArticleImage(event)
|
||||||
|
item.published = getArticlePublished(event)
|
||||||
|
item.author = event.pubkey
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Filter for Nostr articles only and apply common validation/sorting
|
||||||
|
const articles = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article')
|
||||||
|
|
||||||
|
const validArticles = filterValidItems(articles)
|
||||||
|
const sortedReads = sortByReadingActivity(validArticles)
|
||||||
|
|
||||||
|
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||||
|
return sortedReads
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all reads:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ export async function publishEvent(
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
||||||
|
|
||||||
|
// Surface common bunker signing errors for debugging
|
||||||
|
if (error instanceof Error && error.message.includes('permission')) {
|
||||||
|
console.warn('💡 Hint: This may be a bunker permission issue. Ensure your bunker connection has signing permissions.')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
src/utils/debugBus.ts
Normal file
36
src/utils/debugBus.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type DebugLevel = 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface DebugLogEntry {
|
||||||
|
ts: number
|
||||||
|
level: DebugLevel
|
||||||
|
source: string
|
||||||
|
message: string
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (entry: DebugLogEntry) => void
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
const buffer: DebugLogEntry[] = []
|
||||||
|
const MAX_BUFFER = 300
|
||||||
|
|
||||||
|
export const DebugBus = {
|
||||||
|
log(level: DebugLevel, source: string, message: string, data?: unknown): void {
|
||||||
|
const entry: DebugLogEntry = { ts: Date.now(), level, source, message, data }
|
||||||
|
buffer.push(entry)
|
||||||
|
if (buffer.length > MAX_BUFFER) buffer.shift()
|
||||||
|
listeners.forEach(l => {
|
||||||
|
try { l(entry) } catch (err) { console.warn('[DebugBus] listener error:', err) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
info(source: string, message: string, data?: unknown): void { this.log('info', source, message, data) },
|
||||||
|
warn(source: string, message: string, data?: unknown): void { this.log('warn', source, message, data) },
|
||||||
|
error(source: string, message: string, data?: unknown): void { this.log('error', source, message, data) },
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
},
|
||||||
|
snapshot(): DebugLogEntry[] { return buffer.slice() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
69
src/utils/linksFromBookmarks.ts
Normal file
69
src/utils/linksFromBookmarks.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for external URLs:
|
||||||
|
* - Web bookmarks (kind:39701)
|
||||||
|
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||||
|
*/
|
||||||
|
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
// Web bookmarks (kind:39701) - extract from 'd' tag
|
||||||
|
if (bookmark.kind === KINDS.WebBookmark) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||||
|
urls.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract URLs from content if not already captured
|
||||||
|
if (bookmark.content) {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||||
|
const matches = bookmark.content.match(urlRegex)
|
||||||
|
if (matches) {
|
||||||
|
urls.push(...matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (for web bookmarks and other types)
|
||||||
|
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
|
||||||
|
// Create ReadItem for each unique URL
|
||||||
|
for (const url of [...new Set(urls)]) {
|
||||||
|
if (!linksMap.has(url)) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: url,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'external',
|
||||||
|
url,
|
||||||
|
title: title || fallbackTitleFromUrl(url),
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
linksMap.set(url, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(linksMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
83
src/utils/readItemMerge.ts
Normal file
83
src/utils/readItemMerge.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a ReadItem into a state map, returning whether the state changed.
|
||||||
|
* Uses most recent reading activity to determine precedence.
|
||||||
|
*/
|
||||||
|
export function mergeReadItem(
|
||||||
|
stateMap: Map<string, ReadItem>,
|
||||||
|
incoming: ReadItem
|
||||||
|
): boolean {
|
||||||
|
const existing = stateMap.get(incoming.id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
stateMap.set(incoming.id, incoming)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always merge if incoming has reading progress data
|
||||||
|
const hasNewProgress = incoming.readingProgress !== undefined &&
|
||||||
|
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
|
||||||
|
|
||||||
|
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
|
||||||
|
|
||||||
|
// Merge by taking the most recent reading activity
|
||||||
|
const existingTime = existing.readingTimestamp || existing.markedAt || 0
|
||||||
|
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
|
||||||
|
|
||||||
|
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
|
||||||
|
// Keep existing data, but update with newer reading metadata
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
// Preserve event data if incoming doesn't have it
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author,
|
||||||
|
// Always take reading progress if available
|
||||||
|
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
|
||||||
|
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timestamps are equal but incoming has additional data, merge it
|
||||||
|
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a readable title from a URL when no title is available.
|
||||||
|
* Removes protocol, www, and shows domain + path.
|
||||||
|
*/
|
||||||
|
export function fallbackTitleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
let title = parsed.hostname.replace(/^www\./, '')
|
||||||
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
const path = parsed.pathname.slice(0, 40)
|
||||||
|
title += path.length < parsed.pathname.length ? path + '...' : path
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, just return the URL truncated
|
||||||
|
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
src/utils/readingProgressUtils.ts
Normal file
30
src/utils/readingProgressUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters ReadItems by reading progress
|
||||||
|
*/
|
||||||
|
export function filterByReadingProgress(
|
||||||
|
items: ReadItem[],
|
||||||
|
filter: ReadingProgressFilterType
|
||||||
|
): ReadItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
const progress = item.readingProgress || 0
|
||||||
|
const isMarked = item.markedAsRead || false
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 'unopened':
|
||||||
|
return progress === 0 && !isMarked
|
||||||
|
case 'started':
|
||||||
|
return progress > 0 && progress <= 0.10 && !isMarked
|
||||||
|
case 'reading':
|
||||||
|
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||||
|
case 'completed':
|
||||||
|
return progress >= 0.95 || isMarked
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
71
src/utils/readsFromBookmarks.ts
Normal file
71
src/utils/readsFromBookmarks.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { classifyBookmarkType } from './bookmarkTypeClassifier'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
|
||||||
|
* Returns items with type='article', using hydrated data when available.
|
||||||
|
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
|
||||||
|
*/
|
||||||
|
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles (kind:30023)
|
||||||
|
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
|
||||||
|
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
|
||||||
|
|
||||||
|
// Extract identifier from coordinate
|
||||||
|
const parts = coordinate.split(':')
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
// Convert to naddr format (reading positions use naddr as ID)
|
||||||
|
let naddr: string
|
||||||
|
try {
|
||||||
|
naddr = nip19.naddrEncode({
|
||||||
|
kind: KINDS.BlogPost,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to encode naddr for bookmark:', coordinate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (same as BookmarkItem does)
|
||||||
|
const title = bookmark.content || 'Untitled'
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
|
||||||
|
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: naddr, // Use naddr format to match reading positions
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
published: published ? parseInt(published) : undefined,
|
||||||
|
author: bookmark.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
readsMap.set(naddr, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(readsMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -8,3 +8,11 @@ declare module '*.svg?raw' {
|
|||||||
const content: string
|
const content: string
|
||||||
export default content
|
export default content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build-time defines injected by Vite in vite.config.ts
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
declare const __GIT_COMMIT__: string
|
||||||
|
declare const __GIT_BRANCH__: string
|
||||||
|
declare const __BUILD_TIME__: string
|
||||||
|
declare const __GIT_COMMIT_URL__: string
|
||||||
|
declare const __RELEASE_URL__: string
|
||||||
|
|||||||
11
vercel.json
11
vercel.json
@@ -1,5 +1,16 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/a/:naddr",
|
||||||
|
"has": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"key": "user-agent",
|
||||||
|
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"destination": "/api/article-og?naddr=:naddr"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/(.*)",
|
"source": "/(.*)",
|
||||||
"destination": "/index.html"
|
"destination": "/index.html"
|
||||||
|
|||||||
@@ -1,8 +1,101 @@
|
|||||||
|
/* eslint-env node */
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
|
function getGitMetadata() {
|
||||||
|
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
|
||||||
|
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||||
|
let commit = envSha
|
||||||
|
let branch = envRef
|
||||||
|
try {
|
||||||
|
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { commit, branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
|
||||||
|
return pkg.version as string
|
||||||
|
} catch {
|
||||||
|
return '0.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { commit, branch } = getGitMetadata()
|
||||||
|
const version = getPackageVersion()
|
||||||
|
const buildTime = new Date().toISOString()
|
||||||
|
|
||||||
|
function getReleaseUrl(version: string): string {
|
||||||
|
if (!version) return ''
|
||||||
|
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||||
|
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||||
|
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||||
|
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||||
|
return `https://github.com/${owner}/${slug}/releases/tag/v${version}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
if (remote.includes('github.com')) {
|
||||||
|
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||||
|
const https = remote.startsWith('git@')
|
||||||
|
? `https://github.com/${remote.split(':')[1]}`
|
||||||
|
: remote
|
||||||
|
const cleaned = https.replace(/\.git$/, '')
|
||||||
|
return `${cleaned}/releases/tag/v${version}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommitUrl(commit: string): string {
|
||||||
|
if (!commit) return ''
|
||||||
|
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||||
|
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||||
|
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||||
|
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||||
|
return `https://github.com/${owner}/${slug}/commit/${commit}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
if (remote.includes('github.com')) {
|
||||||
|
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||||
|
const https = remote.startsWith('git@')
|
||||||
|
? `https://github.com/${remote.split(':')[1]}`
|
||||||
|
: remote
|
||||||
|
const cleaned = https.replace(/\.git$/, '')
|
||||||
|
return `${cleaned}/commit/${commit}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseUrl = getReleaseUrl(version)
|
||||||
|
const commitUrl = getCommitUrl(commit)
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
__GIT_COMMIT__: JSON.stringify(commit),
|
||||||
|
__GIT_BRANCH__: JSON.stringify(branch),
|
||||||
|
__BUILD_TIME__: JSON.stringify(buildTime),
|
||||||
|
__GIT_COMMIT_URL__: JSON.stringify(commitUrl),
|
||||||
|
__RELEASE_URL__: JSON.stringify(releaseUrl)
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -48,7 +141,7 @@ export default defineConfig({
|
|||||||
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
|
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react'],
|
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react', 'applesauce-accounts', 'applesauce-signers'],
|
||||||
esbuildOptions: {
|
esbuildOptions: {
|
||||||
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
|
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
|
||||||
}
|
}
|
||||||
@@ -65,7 +158,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay']
|
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-accounts', 'applesauce-signers']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user