mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
386 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40c49edb0 | ||
|
|
ce5d97fb1f | ||
|
|
ffb8031a05 | ||
|
|
d54e1072b8 | ||
|
|
55defb645c | ||
|
|
1ba9595542 | ||
|
|
340913f15f | ||
|
|
1d6595f754 | ||
|
|
6099e3c6a4 | ||
|
|
ed75bc6059 | ||
|
|
dcfc08287e | ||
|
|
35b2168f9a | ||
|
|
f8a9079e5f | ||
|
|
780996c7c5 | ||
|
|
809437faa6 | ||
|
|
36f14811ae | ||
|
|
8b95af9c49 | ||
|
|
236ade3d2f | ||
|
|
c2e882ec31 | ||
|
|
0a382e77b9 | ||
|
|
a1fd4bfc94 | ||
|
|
530cc20cba | ||
|
|
a275c0a8e3 | ||
|
|
cb43b748e4 | ||
|
|
ff9ce46448 | ||
|
|
1e6718fe1e | ||
|
|
d6a913f2a6 | ||
|
|
8030e2fa00 | ||
|
|
1ff2f28566 | ||
|
|
78457335c6 | ||
|
|
553feb10df | ||
|
|
ba5d7df3bd | ||
|
|
cf3ca2d527 | ||
|
|
06763d5307 | ||
|
|
a08e4fdc24 | ||
|
|
bc7b4ae42d | ||
|
|
4dc1894ef3 | ||
|
|
f00f26dfe0 | ||
|
|
2e59bc9375 | ||
|
|
0d50d05245 | ||
|
|
90c74a8e9d | ||
|
|
a4bad34a90 | ||
|
|
84ff24e06a | ||
|
|
aaf8a9d4fc | ||
|
|
efa6d13726 | ||
|
|
6116dd12bc | ||
|
|
210cdd41ec | ||
|
|
9378b3c9a9 | ||
|
|
973409e82a | ||
|
|
5d6f48b9a8 | ||
|
|
4921427ad4 | ||
|
|
ad8cad29d3 | ||
|
|
8d4a4a04a3 | ||
|
|
1dc44930b4 | ||
|
|
c77907f87a | ||
|
|
9345228e66 | ||
|
|
811362175c | ||
|
|
3d22e7a3cb | ||
|
|
0b0d3c2859 | ||
|
|
1f8d18071c | ||
|
|
a4afe59437 | ||
|
|
1fe3786a3d | ||
|
|
42d265731f | ||
|
|
e4b4b97874 | ||
|
|
1870c307da | ||
|
|
bcb6cfbe97 | ||
|
|
6ba1ce27b7 | ||
|
|
2f620265f4 | ||
|
|
61ae31c6a2 | ||
|
|
b0fcb0e897 | ||
|
|
3b08cd5d23 | ||
|
|
a3a00b8456 | ||
|
|
7fecc0c0c3 | ||
|
|
93d0284fd6 | ||
|
|
94d5089e33 | ||
|
|
5965bc1747 | ||
|
|
0fbf80b04f | ||
|
|
2004ce76c9 | ||
|
|
90c79e34eb | ||
|
|
6ea0fd292c | ||
|
|
193c1f45d4 | ||
|
|
4da3a0347f | ||
|
|
795ef5016e | ||
|
|
83693f7fb0 | ||
|
|
c55e20f341 | ||
|
|
1430d2fc47 | ||
|
|
3f24ccff74 | ||
|
|
51b7e53385 | ||
|
|
8dbb18b1c8 | ||
|
|
88bc7f690e | ||
|
|
29ef21a1fa | ||
|
|
7a75982715 | ||
|
|
f95f8f4bf1 | ||
|
|
9eef5855a9 | ||
|
|
2e70745bab | ||
|
|
8a971dfe52 | ||
|
|
a004e96eca | ||
|
|
ce2432632c | ||
|
|
56b3100c8e | ||
|
|
327d65a128 | ||
|
|
e5a7a07deb | ||
|
|
5bd57573be | ||
|
|
c2223e6b08 | ||
|
|
d1ffc8c3f9 | ||
|
|
5a5cd14df5 | ||
|
|
2fb25da9d6 | ||
|
|
21228cd212 | ||
|
|
e0b86a84ba | ||
|
|
c3a4e41968 | ||
|
|
f3205843ac | ||
|
|
9a03dd312f | ||
|
|
b711b21048 | ||
|
|
8eaba04d91 | ||
|
|
0785b034e4 | ||
|
|
47e698f197 | ||
|
|
3a752a761a | ||
|
|
f6cc49c07a | ||
|
|
5c4fca9cc9 | ||
|
|
536a7ce1fa | ||
|
|
61072aef40 | ||
|
|
b7ec1fcf06 | ||
|
|
d2fd8fb8fe | ||
|
|
68ee1b3122 | ||
|
|
a37735fc1c | ||
|
|
de0f587174 | ||
|
|
f977561779 | ||
|
|
043ea168fb | ||
|
|
5336bafed4 | ||
|
|
c51291bf81 | ||
|
|
489e48fe4d | ||
|
|
744a145e9f | ||
|
|
7ad925dbd3 | ||
|
|
a69298a3a9 | ||
|
|
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 | ||
|
|
63f58e010f | ||
|
|
85649ae283 | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
||||
# Reference Projects
|
||||
applesauce
|
||||
primal-web-app
|
||||
Amber
|
||||
|
||||
|
||||
155
Amber.md
Normal file
155
Amber.md
Normal file
@@ -0,0 +1,155 @@
|
||||
## 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`.
|
||||
|
||||
- **Account queue disabling (CRITICAL)**
|
||||
- `applesauce-accounts` `BaseAccount` queues requests by default - each request waits for the previous one to complete before being sent.
|
||||
- This caused batch decrypt operations to hang: first request would timeout waiting for user interaction, blocking all subsequent requests in the queue.
|
||||
- **Solution**: Set `accounts.disableQueue = true` globally on the `AccountManager` in `App.tsx` during initialization. This applies to all accounts.
|
||||
- Without this, Amber never sees decrypt requests because they're stuck in the account's internal queue.
|
||||
- Reference: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
|
||||
|
||||
- **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.
|
||||
|
||||
## Performance improvements (post-debugging)
|
||||
|
||||
### Non-blocking publish wiring
|
||||
- **Problem**: Awaiting `pool.publish()` completion blocks until all relay sends finish (can take 30s+ with timeouts).
|
||||
- **Solution**: Wrapped `NostrConnectSigner.publishMethod` at app startup to fire-and-forget publish Observable/Promise; responses still arrive via signer subscription.
|
||||
- **Result**: Encrypt/decrypt operations complete in <2s as seen in `/debug` page (NIP-44: ~900ms enc, ~700ms dec; NIP-04: ~1s enc, ~2s dec).
|
||||
|
||||
### Bookmark decryption optimization
|
||||
- **Problem #1**: Sequential decrypt of encrypted bookmark events blocks UI and takes long with multiple events.
|
||||
- **Problem #2**: 30-second timeouts on `nip44.decrypt` meant waiting 30s per event if bunker didn't support nip44.
|
||||
- **Problem #3**: Account request queue blocked all decrypt requests until first one completed (waiting for user interaction).
|
||||
- **Solution**:
|
||||
- Removed all artificial timeouts - let decrypt fail naturally like debug page does.
|
||||
- Added smart encryption detection (NIP-04 has `?iv=`, NIP-44 doesn't) to try the right method first.
|
||||
- **Disabled account queue globally** (`accounts.disableQueue = true`) in `App.tsx` so all requests are sent immediately.
|
||||
- Process sequentially (removed concurrent `mapWithConcurrency` hack).
|
||||
- **Result**: Bookmark decryption is near-instant, limited only by bunker response time and user approval speed.
|
||||
|
||||
## Amethyst-style bookmarks (kind:30001)
|
||||
|
||||
**Important**: Amethyst bookmarks are stored in a **SINGLE** `kind:30001` event with d-tag `"bookmark"` that contains BOTH public AND private bookmarks in different parts of the event.
|
||||
|
||||
### Event structure:
|
||||
- **Event kind**: `30001` (NIP-51 bookmark set)
|
||||
- **d-tag**: `"bookmark"` (identifies this as the Amethyst bookmark list)
|
||||
- **Public bookmarks**: Stored in event `tags` (e.g., `["e", "..."]`, `["a", "..."]`)
|
||||
- **Private bookmarks**: Stored in encrypted `content` field (NIP-04 or NIP-44)
|
||||
|
||||
### Example event:
|
||||
```json
|
||||
{
|
||||
"kind": 30001,
|
||||
"tags": [
|
||||
["d", "bookmark"], // Identifies this as Amethyst bookmarks
|
||||
["e", "102a2fe..."], // Public bookmark (76 total)
|
||||
["a", "30023:..."] // Public bookmark
|
||||
],
|
||||
"content": "lvOfl7Qb...?iv=5KzDXv09..." // NIP-04 encrypted (416 private bookmarks)
|
||||
}
|
||||
```
|
||||
|
||||
### Processing:
|
||||
When this single event is processed:
|
||||
1. **Public tags** → 76 bookmark items with `sourceKind: 30001, isPrivate: false, setName: "bookmark"`
|
||||
2. **Encrypted content** → 416 bookmark items with `sourceKind: 30001, isPrivate: true, setName: "bookmark"`
|
||||
3. Total: 492 bookmarks from one event
|
||||
|
||||
### Encryption detection:
|
||||
- The encrypted `content` field contains a JSON array of private bookmark tags
|
||||
- `Helpers.hasHiddenContent()` from `applesauce-core` only detects **NIP-44** encrypted content
|
||||
- **NIP-04** encrypted content must be detected explicitly by checking for `?iv=` in the content string
|
||||
- Both detection methods are needed in:
|
||||
1. **Display logic** (`Debug.tsx` - `hasEncryptedContent()`) - to show padlock emoji and decrypt button
|
||||
2. **Decryption logic** (`bookmarkProcessing.ts`) - to schedule decrypt jobs
|
||||
|
||||
### Grouping:
|
||||
In the UI, these are separated into two groups:
|
||||
- **Amethyst Lists**: `sourceKind === 30001 && !isPrivate && setName === 'bookmark'` (public items)
|
||||
- **Amethyst Private**: `sourceKind === 30001 && isPrivate && setName === 'bookmark'` (private items)
|
||||
|
||||
Both groups come from the same event, separated by whether they were in public tags or encrypted content.
|
||||
|
||||
### Why this matters:
|
||||
This dual-storage format (public + private in one event) is why we need explicit NIP-04 detection. Without it, `Helpers.hasHiddenContent()` returns `false` and the encrypted content is never decrypted, resulting in 0 private bookmarks despite having encrypted data.
|
||||
|
||||
## Current conclusion
|
||||
|
||||
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||
- Non-blocking publish keeps operations fast (~1-2s for encrypt/decrypt).
|
||||
- **Account queue is GLOBALLY DISABLED** - this was the primary cause of hangs/timeouts.
|
||||
- Smart encryption detection (both NIP-04 and NIP-44) and no artificial timeouts make operations instant.
|
||||
- Sequential processing is cleaner and more predictable than concurrent hacks.
|
||||
- Relay queries now trust EOSE signals instead of arbitrary timeouts, completing in 1-2s instead of 6s.
|
||||
- The missing DECRYPT activity in Amber was partially due to requests never being sent (stuck in queue). With queue disabled globally, Amber receives all decrypt requests immediately.
|
||||
- **Amethyst-style bookmarks** require explicit NIP-04 detection (`?iv=` check) since `Helpers.hasHiddenContent()` only detects NIP-44.
|
||||
|
||||
|
||||
321
CHANGELOG.md
321
CHANGELOG.md
@@ -7,6 +7,318 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 2025-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Login with Bunker (NIP-46) authentication support
|
||||
- Support for remote signing via Nostr Connect protocol
|
||||
- Bunker URI input with validation and error handling
|
||||
- Automatic reconnection on app restore with proper permissions
|
||||
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||
- Debug page (`/debug`) for diagnostics and testing
|
||||
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||
- Live performance timing with stopwatch display
|
||||
- Bookmark loading and decryption diagnostics
|
||||
- Real-time bunker logs with filtering and clearing
|
||||
- Version and git commit footer
|
||||
- Progressive bookmark loading with streaming updates
|
||||
- Non-blocking, progressive bookmark updates via callback pattern
|
||||
- Batched background hydration using EventLoader and AddressLoader
|
||||
- Auto-decrypt bookmarks as they arrive from relays
|
||||
- Individual decrypt buttons for encrypted bookmark events
|
||||
- Bookmark grouping toggle (grouped by source vs flat chronological)
|
||||
- Toggle between grouped view and flat chronological list
|
||||
- Amethyst-style bookmark detection and grouping
|
||||
- Display bookmarks even when they only have IDs (content loads in background)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved login UI with better copy and modern design
|
||||
- Personable title and nostr-native language
|
||||
- Highlighted 'your own highlights' in login copy
|
||||
- Simplified button text to single words (Extension, Signer)
|
||||
- Hide login button and user icon when logged out
|
||||
- Hide Extension button when Bunker input is shown
|
||||
- Auto-load bookmarks on login and page mount
|
||||
- Enhanced bunker error messages
|
||||
- Formatted error messages with signer suggestions
|
||||
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||
- Better error handling for missing signer extensions
|
||||
- Centered and constrained bunker input field
|
||||
- Centralized bookmark loading architecture
|
||||
- Single shared bookmark controller for consistent loading
|
||||
- Unified bookmark loading with streaming and auto-decrypt
|
||||
- Consolidated bookmark loading into single centralized function
|
||||
- Bookmarks passed as props throughout component tree
|
||||
- Renamed UI elements for clarity
|
||||
- "Bunker" button renamed to "Signer"
|
||||
- Hide bookmark controls when logged out
|
||||
- Settings version footer improvements
|
||||
- Separate links for version (to GitHub release) and commit (to commit page)
|
||||
- Proper spacing around middot separator
|
||||
|
||||
### Fixed
|
||||
|
||||
- NIP-46 bunker signing and decryption
|
||||
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||
- Bunker relays added to relay pool for signing requests
|
||||
- Proper setup of pool and relays before bunker reconnection
|
||||
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||
- Wait for bunker relay connections before marking signer ready
|
||||
- Validate bunker URI (remote must differ from user pubkey)
|
||||
- Accept remote===pubkey for Amber compatibility
|
||||
- Bookmark loading and decryption
|
||||
- Bookmarks load and complete properly with streaming
|
||||
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||
- Include decrypted private bookmarks in sidebar
|
||||
- Skip background event fetching when there are too many IDs
|
||||
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||
- Make controller onEvent non-blocking for queryEvents completion
|
||||
- Proper timeout handling for bookmark decryption (no hanging)
|
||||
- Smart encryption detection with consistent padlock display
|
||||
- Sequential decryption instead of concurrent to avoid queue issues
|
||||
- Add extraRelays to EventLoader and AddressLoader
|
||||
- PWA cache limit increased to 3 MiB for larger bundles
|
||||
- Extension login error messages with nos2x link
|
||||
- TypeScript and linting errors throughout
|
||||
- Replace empty catch blocks with warnings
|
||||
- Fix explicit any types
|
||||
- Add missing useEffect dependencies
|
||||
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||
|
||||
### Performance
|
||||
|
||||
- Non-blocking NIP-46 operations
|
||||
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||
- Non-blocking bookmark decryption with sequential processing
|
||||
- Make controller onEvent non-blocking for queryEvents completion
|
||||
- Optimized bookmark loading
|
||||
- Batched background hydration using EventLoader and AddressLoader
|
||||
- Progressive, non-blocking bookmark loading with streaming
|
||||
- Shorter timeouts for debug page bookmark loading
|
||||
- Remove artificial delays from bookmark decryption
|
||||
|
||||
### Refactored
|
||||
|
||||
- Centralized bookmark controller architecture
|
||||
- Extract bookmark streaming helpers and centralize loading
|
||||
- Consolidated bookmark loading into single function
|
||||
- Remove deprecated bookmark service files
|
||||
- Share bookmark controller between components
|
||||
- Debug page organization
|
||||
- Extract VersionFooter component to eliminate duplication
|
||||
- Structured sections with proper layout and styling
|
||||
- Apply settings page styling structure
|
||||
- Simplified bunker implementation following applesauce patterns
|
||||
- Clean up bunker implementation for better maintainability
|
||||
- Import RELAYS from central config (DRY principle)
|
||||
- Update RELAYS list with relay.nsec.app
|
||||
|
||||
### Documentation
|
||||
|
||||
- Comprehensive Amber.md documentation
|
||||
- Amethyst-style bookmarks section
|
||||
- Bunker decrypt investigation summary
|
||||
- Critical queue disabling requirement
|
||||
- NIP-46 setup and troubleshooting
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Bookmark filter buttons by content type (articles, videos, images, web links)
|
||||
- Filter bookmarks by their content type on bookmarks sidebar
|
||||
- Filters also available on `/me` page bookmarks tab
|
||||
- Separate filter for external articles with link icon
|
||||
- Multiple filters can be active simultaneously
|
||||
- Private Bookmarks section for encrypted legacy bookmarks
|
||||
- Encrypted legacy bookmarks now grouped in separate section
|
||||
- Better organization and clarity for different bookmark types
|
||||
|
||||
### Changed
|
||||
|
||||
- Bookmark section labels improved for clarity
|
||||
- More descriptive section headings throughout
|
||||
- Better categorization of bookmark types
|
||||
- Bookmark filter button styling refined
|
||||
- Reduced whitespace around bookmark filters for cleaner layout
|
||||
- Dramatically reduced whitespace on both sidebar and `/me` page
|
||||
- Lock icon removed from individual bookmarks
|
||||
- Encryption status now indicated by section grouping
|
||||
- Cleaner bookmark item appearance
|
||||
- External article icon changed to link icon (`faLink`)
|
||||
- More intuitive icon for external content
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight button positioning and visibility
|
||||
- Fixed to viewport for consistent placement
|
||||
- Sticky and always visible when needed
|
||||
- Properly positioned inside reader pane
|
||||
|
||||
## [0.6.19] - 2025-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights disappearing on external URLs after a few seconds
|
||||
- Fixed `useBookmarksData` from fetching general highlights when viewing external URLs
|
||||
- External URL highlights now managed exclusively by `useExternalUrlLoader`
|
||||
- Removed redundant `setHighlights` call that was overwriting streamed highlights
|
||||
- Improved error handling in `fetchHighlightsForUrl` to prevent silent failures
|
||||
- Isolated rebroadcast errors so they don't break highlight display
|
||||
- Added logging to help diagnose highlight fetching issues
|
||||
|
||||
## [0.6.18] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Zap split labels simplified and terminology updated
|
||||
- Removed redundant "Weight: xy" label to save space
|
||||
- Changed "Author(s) Share" to "Author's Share" (possessive singular)
|
||||
- Changed "Support Boris" to "Boris' Share" for consistency
|
||||
- Weight value now shown directly in label (e.g., "Your Share: 50")
|
||||
- Share and percentage now displayed on same line for cleaner layout
|
||||
- Zap preset buttons on desktop now expand to match slider width
|
||||
- Added `flex: 1` to buttons for equal width distribution
|
||||
- Buttons still wrap properly on smaller screens
|
||||
- PWA install section now always visible in settings
|
||||
- Section shows regardless of installation or device capability status
|
||||
- Button adapts with proper disabled states and visual feedback
|
||||
- "Installed" state shows checkmark icon and disabled button
|
||||
- Non-installable state shows disabled button
|
||||
|
||||
### Fixed
|
||||
|
||||
- PWA install button now properly disabled when installation is not possible on device
|
||||
- Button only enabled when browser fires `beforeinstallprompt` event
|
||||
- Removed hardcoded testing state that always showed button as installable
|
||||
- App & Airplane Mode section now always visible regardless of PWA status
|
||||
- Image cache and local relay settings always accessible
|
||||
- Previously entire section was hidden if PWA not installable/installed
|
||||
- Only PWA-specific install button is conditionally affected
|
||||
|
||||
## [0.6.17] - 2025-10-15
|
||||
|
||||
### Added
|
||||
@@ -1566,7 +1878,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.17...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
|
||||
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||
[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.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.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
|
||||
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
|
||||
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
|
||||
|
||||
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: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:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
@@ -25,6 +26,7 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.18",
|
||||
"version": "0.7.2",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"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 |
436
src/App.tsx
436
src/App.tsx
@@ -1,19 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Debug from './components/Debug'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
import { DebugBus } from './utils/debugBus'
|
||||
import { Bookmark } from './types/bookmarks'
|
||||
import { bookmarkController } from './services/bookmarkController'
|
||||
import { contactsController } from './services/contactsController'
|
||||
import { highlightsController } from './services/highlightsController'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -21,15 +30,104 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
// AppRoutes component that has access to hooks
|
||||
function AppRoutes({
|
||||
relayPool,
|
||||
eventStore,
|
||||
showToast
|
||||
}: {
|
||||
relayPool: RelayPool
|
||||
eventStore: EventStore | null
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
// Centralized bookmark state (fed by controller)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(false)
|
||||
|
||||
// Centralized contacts state (fed by controller)
|
||||
const [contacts, setContacts] = useState<Set<string>>(new Set())
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
|
||||
// Subscribe to bookmark controller
|
||||
useEffect(() => {
|
||||
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
|
||||
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
|
||||
setBookmarks(bookmarks)
|
||||
})
|
||||
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||
console.log('[bookmark] 📥 Loading state:', loading)
|
||||
setBookmarksLoading(loading)
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
|
||||
unsubBookmarks()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to contacts controller
|
||||
useEffect(() => {
|
||||
console.log('[contacts] 🎧 Subscribing to contacts controller')
|
||||
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||
console.log('[contacts] 📥 Received contacts:', contacts.size)
|
||||
setContacts(contacts)
|
||||
})
|
||||
const unsubLoading = contactsController.onLoading((loading) => {
|
||||
console.log('[contacts] 📥 Loading state:', loading)
|
||||
setContactsLoading(loading)
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
|
||||
unsubContacts()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
|
||||
useEffect(() => {
|
||||
if (activeAccount && relayPool) {
|
||||
const pubkey = (activeAccount as { pubkey?: string }).pubkey
|
||||
|
||||
// Load bookmarks
|
||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
}
|
||||
|
||||
// Load contacts
|
||||
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
|
||||
contactsController.start({ relayPool, pubkey })
|
||||
}
|
||||
|
||||
// Load highlights (controller manages its own state)
|
||||
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
}
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||
|
||||
// Manual refresh (for sidebar button)
|
||||
const handleRefreshBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) {
|
||||
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
|
||||
return
|
||||
}
|
||||
console.log('[bookmark] 🔄 Manual refresh triggered')
|
||||
bookmarkController.reset()
|
||||
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
}, [relayPool, activeAccount, accountManager])
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.clearActive()
|
||||
bookmarkController.reset() // Clear bookmarks via controller
|
||||
contactsController.reset() // Clear contacts via controller
|
||||
highlightsController.reset() // Clear highlights via controller
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -41,6 +139,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -50,6 +151,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -59,6 +163,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -68,6 +175,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -77,6 +187,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -86,6 +199,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -99,6 +215,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -108,15 +227,45 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
path="/me/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -126,6 +275,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -135,6 +287,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -144,6 +299,22 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/debug"
|
||||
element={
|
||||
<Debug
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -165,23 +336,68 @@ function App() {
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Disable request queueing globally - makes all operations instant
|
||||
// Queue causes requests to wait for user interaction which blocks batch operations
|
||||
accounts.disableQueue = true
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
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) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = pool.publish(relays, event as any)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (result && typeof (result as any).subscribe === 'function') {
|
||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||
}
|
||||
// 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
|
||||
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)
|
||||
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
|
||||
const activeId = localStorage.getItem('active')
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('Restored active account:', activeId)
|
||||
console.log('[bunker] Active ID from localStorage:', 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) {
|
||||
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
|
||||
@@ -198,12 +414,198 @@ 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
|
||||
pool.group(RELAYS)
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||
console.log('[bunker] Active account changed:', {
|
||||
hasAccount: !!account,
|
||||
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') {
|
||||
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||
}
|
||||
// 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
|
||||
// This prevents disconnection when no other subscriptions are active
|
||||
@@ -233,6 +635,7 @@ function App() {
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
bunkerReconnectSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
@@ -249,7 +652,7 @@ function App() {
|
||||
return () => {
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
}, [isOnline, showToast])
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
@@ -284,7 +687,8 @@ function App() {
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
|
||||
<RouteDebug />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
|
||||
47
src/components/ArchiveFilters.tsx
Normal file
47
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||
|
||||
interface ArchiveFiltersProps {
|
||||
selectedFilter: ArchiveFilterType
|
||||
onFilterChange: (filter: ArchiveFilterType) => void
|
||||
}
|
||||
|
||||
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||
]
|
||||
|
||||
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 ArchiveFilters
|
||||
|
||||
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
@@ -23,6 +24,16 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
// Calculate progress percentage and determine color (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)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
@@ -47,7 +58,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
|
||||
{/* Reading progress indicator - replaces the dividing line */}
|
||||
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||
<div
|
||||
className="blog-post-reading-progress"
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '1rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: 'var(--color-border)',
|
||||
marginTop: '1rem'
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
|
||||
44
src/components/BookmarkFilters.tsx
Normal file
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||
|
||||
interface BookmarkFiltersProps {
|
||||
selectedFilter: BookmarkFilterType
|
||||
onFilterChange: (filter: BookmarkFilterType) => void
|
||||
}
|
||||
|
||||
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||
selectedFilter,
|
||||
onFilterChange
|
||||
}) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BookmarkFilters
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -70,7 +70,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = (): IconDefinition => {
|
||||
if (isArticle) return faNewspaper
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassification) {
|
||||
@@ -81,7 +81,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faNewspaper
|
||||
return faLink // External article
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (!hasUrls) return faStickyNote // Just a text note
|
||||
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||
return faFileLines
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -19,6 +19,9 @@ import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import LoginOptions from './LoginOptions'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -61,8 +64,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||
return saved === 'flat' ? 'flat' : 'grouped'
|
||||
})
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
const toggleGroupingMode = () => {
|
||||
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||
setGroupingMode(newMode)
|
||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
@@ -87,18 +101,25 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(allIndividualBookmarks)
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Group non-set bookmarks as before
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// Group non-set bookmarks by source or flatten based on mode
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||
]
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
||||
: [
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
||||
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections
|
||||
bookmarkSets.forEach(set => {
|
||||
@@ -140,7 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
{allIndividualBookmarks.length > 0 && (
|
||||
<BookmarkFilters
|
||||
selectedFilter={selectedFilter}
|
||||
onFilterChange={setSelectedFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeAccount ? (
|
||||
<LoginOptions />
|
||||
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks match this filter.</p>
|
||||
</div>
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
@@ -153,7 +187,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -205,40 +238,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
{onRefresh && (
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
@@ -91,9 +91,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
@@ -54,9 +53,6 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
>
|
||||
<span className="bookmark-type-compact">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
{displayText && (
|
||||
<div className="compact-text">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
@@ -24,6 +23,7 @@ interface LargeViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -39,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
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 handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -93,12 +104,31 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</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">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
<span className="large-author">
|
||||
<Link
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
@@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
interface BookmarksProps {
|
||||
relayPool: RelayPool | null
|
||||
onLogout: () => void
|
||||
bookmarks: Bookmark[]
|
||||
bookmarksLoading: boolean
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
relayPool,
|
||||
onLogout,
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks
|
||||
}) => {
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
@@ -52,7 +62,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/archive' ? 'archive' :
|
||||
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||
location.pathname === '/me/links' ? 'links' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
@@ -151,8 +162,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -165,11 +174,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -232,6 +243,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -315,10 +327,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -129,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||
|
||||
// Generate article identifier for saving/loading position
|
||||
const articleIdentifier = useMemo(() => {
|
||||
if (!selectedUrl) return null
|
||||
return generateArticleIdentifier(selectedUrl)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
await saveReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
factory,
|
||||
articleIdentifier,
|
||||
{
|
||||
position,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
@@ -141,6 +196,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Load saved reading position when article loads
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||
isTextContent,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||
} else {
|
||||
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadPosition()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNow) {
|
||||
saveNow()
|
||||
}
|
||||
}
|
||||
}, [saveNow, selectedUrl])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
1202
src/components/Debug.tsx
Normal file
1202
src/components/Debug.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,19 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { nip19, NostrEvent } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
@@ -22,6 +23,12 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -42,13 +49,41 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Visibility filters (defaults from settings, or friends only)
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||
|
||||
// Load cached content from event store (instant display)
|
||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||
|
||||
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||
|
||||
// Visibility filters (defaults from settings)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
})
|
||||
|
||||
// Subscribe to highlights controller
|
||||
useEffect(() => {
|
||||
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
||||
return () => {
|
||||
unsubHighlights()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -68,14 +103,34 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
setLoading(true)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
// Use functional update to check current state without creating dependency
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Seed with cached content from event store (instant display)
|
||||
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
||||
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
||||
setHighlights(prev => {
|
||||
const all = dedupeHighlightsById([...prev, ...merged])
|
||||
return all.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
|
||||
// Seed with cached writings from event store
|
||||
if (cachedWritings.length > 0) {
|
||||
setBlogPosts(prev => {
|
||||
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
||||
return all.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -97,8 +152,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
relayUrls,
|
||||
(post) => {
|
||||
setBlogPosts((prev) => {
|
||||
const exists = prev.some(p => p.event.id === post.event.id)
|
||||
if (exists) return prev
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existingIndex = prev.findIndex(p => {
|
||||
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
return `${p.author}:${pDTag}` === key
|
||||
})
|
||||
|
||||
// If exists, only replace if this one is newer
|
||||
if (existingIndex >= 0) {
|
||||
const existing = prev[existingIndex]
|
||||
if (post.event.created_at <= existing.event.created_at) {
|
||||
return prev // Keep existing (newer or same)
|
||||
}
|
||||
// Replace with newer version
|
||||
const next = [...prev]
|
||||
next[existingIndex] = post
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
// New post, add it
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
@@ -110,9 +188,27 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
).then((all) => {
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of all) byId.set(post.event.id, post)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||
// Deduplicate by author:d-tag (replaceable event key)
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
|
||||
// Add existing posts
|
||||
for (const p of prev) {
|
||||
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${p.author}:${dTag}`
|
||||
byKey.set(key, p)
|
||||
}
|
||||
|
||||
// Merge in new posts (keeping newer versions)
|
||||
for (const post of all) {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
byKey.set(key, post)
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(byKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
@@ -160,33 +256,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||
fetchNostrverseHighlights(relayPool, 100)
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
|
||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||
])
|
||||
|
||||
// Merge and deduplicate all posts
|
||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||
const postsByKey = new Map<string, BlogPostPreview>()
|
||||
for (const post of allPosts) {
|
||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||
const existing = postsByKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
postsByKey.set(key, post)
|
||||
}
|
||||
}
|
||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// Merge and deduplicate all highlights
|
||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||
const highlightsByKey = new Map<string, Highlight>()
|
||||
for (const highlight of allHighlights) {
|
||||
highlightsByKey.set(highlight.id, highlight)
|
||||
}
|
||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
|
||||
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
|
||||
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Fetch profiles for all blog post authors to cache them
|
||||
if (uniquePosts.length > 0) {
|
||||
@@ -211,7 +295,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
|
||||
|
||||
// Pull-to-refresh
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
@@ -237,35 +321,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
@@ -357,7 +412,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -370,7 +424,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
const showSkeletons = loading && !hasData
|
||||
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
@@ -452,7 +506,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
<div key={activeTab}>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
@@ -206,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -274,8 +276,34 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}, [showMenu, showDeleteConfirm])
|
||||
|
||||
const handleItemClick = () => {
|
||||
// If onHighlightClick is provided, use it (legacy behavior)
|
||||
if (onHighlightClick) {
|
||||
onHighlightClick(highlight.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, navigate to the article that this highlight references
|
||||
if (highlight.eventReference) {
|
||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||
const parts = highlight.eventReference.split(':')
|
||||
|
||||
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||
if (parts.length === 3) {
|
||||
const [kind, pubkey, identifier] = parts
|
||||
|
||||
if (kind === '30023') {
|
||||
// Encode as naddr and navigate
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
// Navigate to external URL
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
|
||||
209
src/components/LoginOptions.tsx
Normal file
209
src/components/LoginOptions.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPuzzlePiece, faShieldHalved, faCircleInfo } 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'
|
||||
|
||||
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<React.ReactNode | 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)
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Check if extension is not installed
|
||||
if (errorMessage.includes('Signer extension missing') || errorMessage.includes('window.nostr') || errorMessage.includes('not found') || errorMessage.includes('undefined') || errorMessage.toLowerCase().includes('extension missing')) {
|
||||
setError(
|
||||
<>
|
||||
No browser extension found. Please install{' '}
|
||||
<a href="https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp" target="_blank" rel="noopener noreferrer">
|
||||
nos2x
|
||||
</a>
|
||||
{' '}or another nostr extension.
|
||||
</>
|
||||
)
|
||||
} else if (errorMessage.includes('denied') || errorMessage.includes('rejected') || errorMessage.includes('cancel')) {
|
||||
setError('Authentication was cancelled or denied.')
|
||||
} else {
|
||||
setError(`Authentication failed: ${errorMessage}`)
|
||||
}
|
||||
} 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://. Don't have a signer? Give{' '}
|
||||
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||
Amber
|
||||
</a>
|
||||
{' '}or{' '}
|
||||
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||
Aegis
|
||||
</a>
|
||||
{' '}a try.
|
||||
</>
|
||||
)
|
||||
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 {
|
||||
// Show helpful message for bunker connection failures
|
||||
setError(
|
||||
<>
|
||||
Failed: {errorMessage}
|
||||
<br /><br />
|
||||
Don't have a signer? Give{' '}
|
||||
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||
Amber
|
||||
</a>
|
||||
{' '}or{' '}
|
||||
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||
Aegis
|
||||
</a>
|
||||
{' '}a try.
|
||||
</>
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="empty-state login-container">
|
||||
<div className="login-content">
|
||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||
<p className="login-description">
|
||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
||||
</p>
|
||||
|
||||
<div className="login-buttons">
|
||||
{!showBunkerInput && (
|
||||
<button
|
||||
onClick={handleExtensionLogin}
|
||||
disabled={isLoading}
|
||||
className="login-button login-button-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} />
|
||||
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!showBunkerInput ? (
|
||||
<button
|
||||
onClick={() => setShowBunkerInput(true)}
|
||||
disabled={isLoading}
|
||||
className="login-button login-button-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShieldHalved} />
|
||||
<span>Signer</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="bunker-input-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="bunker://..."
|
||||
value={bunkerUri}
|
||||
onChange={(e) => setBunkerUri(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="bunker-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBunkerLogin()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="bunker-actions">
|
||||
<button
|
||||
onClick={handleBunkerLogin}
|
||||
disabled={isLoading || !bunkerUri.trim()}
|
||||
className="bunker-button bunker-connect"
|
||||
>
|
||||
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBunkerInput(false)
|
||||
setBunkerUri('')
|
||||
setError(null)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="bunker-button bunker-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="login-footer">
|
||||
New to nostr? Start here:{' '}
|
||||
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||
nstart.me
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginOptions
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
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, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { IEventStore, Helpers } from 'applesauce-core'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { nip19, NostrEvent } from 'nostr-tools'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -19,35 +21,122 @@ import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
activeTab?: TabType
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
// Valid reading progress filters
|
||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||
|
||||
const Me: React.FC<MeProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeTab: propActiveTab,
|
||||
pubkey: propPubkey,
|
||||
bookmarks
|
||||
}) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [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 [loading, setLoading] = useState(true)
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||
|
||||
// Load cached data from event store for OTHER profiles (not own)
|
||||
const cachedHighlights = useStoreTimeline(
|
||||
eventStore,
|
||||
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
|
||||
eventToHighlight,
|
||||
[viewingPubkey, isOwnProfile]
|
||||
)
|
||||
|
||||
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}), [])
|
||||
|
||||
const cachedWritings = useStoreTimeline(
|
||||
eventStore,
|
||||
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
|
||||
toBlogPostPreview,
|
||||
[viewingPubkey, isOwnProfile]
|
||||
)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||
return saved === 'flat' ? 'flat' : 'grouped'
|
||||
})
|
||||
|
||||
const toggleGroupingMode = () => {
|
||||
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||
setGroupingMode(newMode)
|
||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Subscribe to highlights controller
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setMyHighlights(highlightsController.getHighlights())
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
||||
return () => {
|
||||
unsubHighlights()
|
||||
unsubLoading()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
@@ -56,72 +145,240 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
// Sync filter state with URL changes
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setLoading(false)
|
||||
return
|
||||
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
? (urlFilter as ReadingProgressFilterType)
|
||||
: 'all'
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
|
||||
// For own profile, highlights come from controller subscription (sync effect handles it)
|
||||
// For viewing other users, seed with cached data then fetch fresh
|
||||
if (!isOwnProfile) {
|
||||
// Seed with cached highlights first
|
||||
if (cachedHighlights.length > 0) {
|
||||
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
|
||||
// Fetch highlights and writings (public data)
|
||||
const [userHighlights, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, viewingPubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
])
|
||||
|
||||
|
||||
// Fetch fresh highlights
|
||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||
setHighlights(userHighlights)
|
||||
setWritings(userWritings)
|
||||
}
|
||||
|
||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load highlights:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch private data for own profile
|
||||
if (isOwnProfile && activeAccount) {
|
||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||
setReadArticles(userReadArticles)
|
||||
const loadWritingsTab = async () => {
|
||||
if (!viewingPubkey) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('writings')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Seed with cached writings first
|
||||
if (!isOwnProfile && cachedWritings.length > 0) {
|
||||
setWritings(cachedWritings.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
}))
|
||||
}
|
||||
|
||||
// Fetch fresh writings
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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([])
|
||||
const loadReadingListTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
// Bookmarks come from centralized loading in App.tsx
|
||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading list:', err)
|
||||
} finally {
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReadsTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('reads')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||
setReadsMap(initialMap)
|
||||
setReads(initialReads)
|
||||
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// Only update items that are already in our map
|
||||
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
||||
console.log('📈 [Reads] Enrichment item received:', {
|
||||
id: item.id.slice(0, 20) + '...',
|
||||
progress: item.readingProgress,
|
||||
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
||||
})
|
||||
|
||||
setReadsMap(prevMap => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) {
|
||||
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||
return prevMap
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} else {
|
||||
setBookmarks([])
|
||||
setReadArticles([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const loadLinksTab = async () => {
|
||||
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||
|
||||
const hasBeenLoaded = loadedTabs.has('links')
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
||||
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
|
||||
}
|
||||
|
||||
// Load cached data immediately if available
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||
setReads(cached.reads || [])
|
||||
setLinks(cached.links || [])
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
// 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
|
||||
// Sync myHighlights from controller when viewing own profile
|
||||
useEffect(() => {
|
||||
if (isOwnProfile) {
|
||||
setHighlights(myHighlights)
|
||||
}
|
||||
}, [isOwnProfile, myHighlights])
|
||||
|
||||
// Pull-to-refresh - reload active tab without clearing state
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
// Just trigger refresh - loaders will merge new data
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
maximumPullLength: 240,
|
||||
@@ -150,6 +407,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
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 }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
@@ -172,17 +472,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
const groups = groupIndividualBookmarks(allIndividualBookmarks)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Apply bookmark filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||
|
||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||
|
||||
// Apply reading progress filter
|
||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||
: [
|
||||
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
||||
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||
]
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
const showSkeletons = loading && !hasData
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
@@ -196,9 +508,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 ? (
|
||||
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
|
||||
<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 className="highlights-list me-highlights-list">
|
||||
@@ -225,13 +537,24 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</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)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
No bookmarks yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{sections.filter(s => s.items.length > 0).map(section => (
|
||||
{allIndividualBookmarks.length > 0 && (
|
||||
<BookmarkFilters
|
||||
selectedFilter={bookmarkFilter}
|
||||
onFilterChange={setBookmarkFilter}
|
||||
/>
|
||||
)}
|
||||
{filteredBookmarks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No bookmarks match this filter.
|
||||
</div>
|
||||
) : (
|
||||
sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
@@ -246,7 +569,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)))}
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@@ -255,6 +578,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
@@ -280,8 +610,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
if (showSkeletons) {
|
||||
case 'reads':
|
||||
// Show loading skeletons only while initially loading
|
||||
if (loading && !loadedTabs.has('reads')) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -290,20 +621,87 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
// Show empty state if loaded but no reads
|
||||
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||
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 (
|
||||
<>
|
||||
<ReadingProgressFilters
|
||||
selectedFilter={readingProgressFilter}
|
||||
onFilterChange={handleReadingProgressFilterChange}
|
||||
/>
|
||||
{filteredReads.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles match this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredReads.map((item) => (
|
||||
<BlogPostCard
|
||||
key={item.id}
|
||||
post={convertReadItemToBlogPostPreview(item)}
|
||||
href={getReadItemUrl(item)}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
@@ -316,9 +714,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
</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)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
No articles written yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -366,12 +764,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||
data-tab="reads"
|
||||
onClick={() => navigate('/me/reads')}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
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))
|
||||
|
||||
// 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)
|
||||
const leftOffset = isSidebarCollapsed
|
||||
? 'var(--sidebar-collapsed-width)'
|
||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: ''
|
||||
}`}
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||
style={{
|
||||
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]" />
|
||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : ''
|
||||
}`}
|
||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
||||
style={{
|
||||
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||
}}
|
||||
>
|
||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||
</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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import ExploreSettings from './Settings/ExploreSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import VersionFooter from './VersionFooter'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -28,12 +30,16 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
defaultExploreScopeNostrverse: false,
|
||||
defaultExploreScopeFriends: true,
|
||||
defaultExploreScopeMine: false,
|
||||
zapSplitHighlighterWeight: 50,
|
||||
zapSplitBorisWeight: 2.1,
|
||||
zapSplitAuthorWeight: 50,
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -161,11 +167,13 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
</div>
|
||||
<VersionFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
59
src/components/Settings/ExploreSettings.tsx
Normal file
59
src/components/Settings/ExploreSettings.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface ExploreSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Explore</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Explore Scope</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
|
||||
title="Nostrverse content"
|
||||
ariaLabel="Toggle nostrverse content by default in explore"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
|
||||
title="Friends content"
|
||||
ariaLabel="Toggle friends content by default in explore"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
|
||||
title="My content"
|
||||
ariaLabel="Toggle my content by default in explore"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExploreSettings
|
||||
|
||||
@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="syncReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.syncReadingPosition ?? false}
|
||||
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Sync reading position across devices</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import IconButton from './IconButton'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
}
|
||||
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount ? (
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={faRightToBracket}
|
||||
onClick={isConnecting ? () => {} : handleLogin}
|
||||
title={isConnecting ? "Connecting..." : "Login"}
|
||||
ariaLabel="Login"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.hasActiveAccount && (
|
||||
{props.hasActiveAccount && props.readerContent && (
|
||||
<HighlightButton
|
||||
ref={props.highlightButtonRef}
|
||||
onHighlight={props.onCreateHighlight}
|
||||
|
||||
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 = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869',
|
||||
'wss://relay.nsec.app',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
|
||||
@@ -1,60 +1,83 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
import { useStoreTimeline } from './useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
externalUrl?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
eventStore?: IEventStore | null
|
||||
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
|
||||
const handleFetchContacts = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleFetchBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
// merge-friendly: updater form that preserves visible list until replacement
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
||||
setBookmarks(() => next)
|
||||
}, settings)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
// Load cached article-specific highlights from event store
|
||||
const articleFilter = useMemo(() => {
|
||||
if (!currentArticleCoordinate) return null
|
||||
return {
|
||||
kinds: [KINDS.Highlights],
|
||||
'#a': [currentArticleCoordinate],
|
||||
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||
}
|
||||
}, [relayPool, activeAccount, accountManager, settings])
|
||||
}, [currentArticleCoordinate, currentArticleEventId])
|
||||
|
||||
const cachedArticleHighlights = useStoreTimeline(
|
||||
eventStore || null,
|
||||
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||
eventToHighlight,
|
||||
[currentArticleCoordinate, currentArticleEventId]
|
||||
)
|
||||
|
||||
// Subscribe to centralized controllers
|
||||
useEffect(() => {
|
||||
// Get initial state immediately
|
||||
setMyHighlights(highlightsController.getHighlights())
|
||||
setFollowedPubkeys(new Set(contactsController.getContacts()))
|
||||
|
||||
// Subscribe to updates
|
||||
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||
setFollowedPubkeys(new Set(contacts))
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubHighlights()
|
||||
unsubContacts()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFetchHighlights = useCallback(async () => {
|
||||
if (!relayPool) return
|
||||
@@ -62,7 +85,16 @@ export const useBookmarksData = ({
|
||||
setHighlightsLoading(true)
|
||||
try {
|
||||
if (currentArticleCoordinate) {
|
||||
// Seed with cached highlights first
|
||||
if (cachedArticleHighlights.length > 0) {
|
||||
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
|
||||
// Fetch fresh article-specific highlights (from all users)
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
// Seed map with cached highlights
|
||||
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
@@ -72,66 +104,67 @@ export const useBookmarksData = ({
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
},
|
||||
settings
|
||||
settings,
|
||||
false, // force
|
||||
eventStore || undefined
|
||||
)
|
||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
||||
} else if (activeAccount) {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
||||
setHighlights(fetchedHighlights)
|
||||
} else {
|
||||
// No article selected - clear article highlights
|
||||
setArticleHighlights([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
||||
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||
|
||||
const handleRefreshAll = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await onRefreshBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
// Contacts and own highlights are managed by controllers
|
||||
setLastFetchTime(Date.now())
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh data:', err)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
|
||||
|
||||
// Load initial data (avoid clearing on route-only changes)
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||
handleFetchBookmarks()
|
||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||
|
||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!naddr) {
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (currentArticleCoordinate && !externalUrl) {
|
||||
handleFetchHighlights()
|
||||
} else if (!naddr && !externalUrl) {
|
||||
// Clear article highlights when not viewing an article
|
||||
setArticleHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
|
||||
// Merge highlights from controller with article-specific highlights
|
||||
const highlights = [...myHighlights, ...articleHighlights]
|
||||
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
|
||||
highlightsLoading,
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { useStoreTimeline } from './useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
// Helper to extract filename from URL
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
|
||||
export function useExternalUrlLoader({
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -42,6 +48,19 @@ export function useExternalUrlLoader({
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
// Load cached URL-specific highlights from event store
|
||||
const urlFilter = useMemo(() => {
|
||||
if (!url) return null
|
||||
return { kinds: [KINDS.Highlights], '#r': [url] }
|
||||
}, [url])
|
||||
|
||||
const cachedUrlHighlights = useStoreTimeline(
|
||||
eventStore || null,
|
||||
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
|
||||
eventToHighlight,
|
||||
[url]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!relayPool || !url) return
|
||||
|
||||
@@ -66,12 +85,21 @@ export function useExternalUrlLoader({
|
||||
// Fetch highlights for this URL asynchronously
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
|
||||
// Seed with cached highlights first
|
||||
if (cachedUrlHighlights.length > 0) {
|
||||
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||
} else {
|
||||
setHighlights([])
|
||||
}
|
||||
|
||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||
if (typeof fetchHighlightsForUrl === 'function') {
|
||||
const seen = new Set<string>()
|
||||
const highlightsList = await fetchHighlightsForUrl(
|
||||
// Seed with cached IDs
|
||||
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||
|
||||
await fetchHighlightsForUrl(
|
||||
relayPool,
|
||||
url,
|
||||
(highlight) => {
|
||||
@@ -82,13 +110,11 @@ export function useExternalUrlLoader({
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
},
|
||||
undefined, // settings
|
||||
false, // force
|
||||
eventStore || undefined
|
||||
)
|
||||
// Ensure final list is sorted and contains all items
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
} else {
|
||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
@@ -109,6 +135,6 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
||||
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { useToast } from './useToast'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
activeAccount: IAccount | undefined
|
||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
||||
settings
|
||||
}: UseHighlightCreationParams) => {
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
@@ -92,10 +94,19 @@ export const useHighlightCreation = ({
|
||||
})
|
||||
} catch (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
|
||||
throw error
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||
|
||||
return {
|
||||
highlightButtonRef,
|
||||
|
||||
@@ -1,21 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
readingCompleteThreshold = 0.9,
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounced save function
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Don't save if position is too low (< 5%)
|
||||
if (currentPosition < 0.05) return
|
||||
|
||||
// Don't save if position hasn't changed significantly (less than 1%)
|
||||
// But always save if we've reached 100% (completion)
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||
|
||||
// Clear existing timer
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
onSave(currentPosition)
|
||||
}, autoSaveInterval)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
|
||||
// Immediate save function
|
||||
const saveNow = useCallback(() => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Cancel any pending saves
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Save if position is meaningful (>= 5%)
|
||||
if (position >= 0.05) {
|
||||
lastSavedPosition.current = position
|
||||
onSave(position)
|
||||
}
|
||||
}, [syncEnabled, onSave, position])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||
const maxScroll = documentHeight - windowHeight
|
||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
|
||||
// If we're within 5px of the bottom, consider it 100%
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
|
||||
// Clear save timer on unmount
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow
|
||||
}
|
||||
}
|
||||
|
||||
33
src/hooks/useStoreTimeline.ts
Normal file
33
src/hooks/useStoreTimeline.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useObservableMemo } from 'applesauce-react/hooks'
|
||||
import { startWith } from 'rxjs'
|
||||
import type { IEventStore } from 'applesauce-core'
|
||||
import type { Filter, NostrEvent } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Subscribe to EventStore timeline and map events to app types
|
||||
* Provides instant cached results, then updates reactively
|
||||
*
|
||||
* @param eventStore - The applesauce event store
|
||||
* @param filter - Nostr filter to query
|
||||
* @param mapEvent - Function to transform NostrEvent to app type
|
||||
* @param deps - Dependencies for memoization
|
||||
* @returns Array of mapped results
|
||||
*/
|
||||
export function useStoreTimeline<T>(
|
||||
eventStore: IEventStore | null,
|
||||
filter: Filter,
|
||||
mapEvent: (event: NostrEvent) => T,
|
||||
deps: unknown[] = []
|
||||
): T[] {
|
||||
const events = useObservableMemo(
|
||||
() => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined,
|
||||
[eventStore, ...deps]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => events?.map(mapEvent) ?? [],
|
||||
[events, mapEvent]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/components/pull-to-refresh.css';
|
||||
@import './styles/components/skeletons.css';
|
||||
@import './styles/components/login.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
@import './styles/utils/legacy.css';
|
||||
|
||||
472
src/services/bookmarkController.ts
Normal file
472
src/services/bookmarkController.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers, EventStore } from 'applesauce-core'
|
||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { EventPointer } from 'nostr-tools/nip19'
|
||||
import { merge } from 'rxjs'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
hydrateItems,
|
||||
dedupeBookmarksById,
|
||||
extractUrlsFromContent
|
||||
} from './bookmarkHelpers'
|
||||
|
||||
/**
|
||||
* Get unique key for event deduplication (from Debug)
|
||||
*/
|
||||
function getEventKey(evt: NostrEvent): string {
|
||||
if (evt.kind === 30003 || evt.kind === 30001) {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
return `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||
} else if (evt.kind === 10003) {
|
||||
return `${evt.kind}:${evt.pubkey}`
|
||||
}
|
||||
return evt.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event has encrypted content (from Debug)
|
||||
*/
|
||||
function hasEncryptedContent(evt: NostrEvent): boolean {
|
||||
if (Helpers.hasHiddenContent(evt)) return true
|
||||
if (evt.content && evt.content.includes('?iv=')) return true
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
type RawEventCallback = (event: NostrEvent) => void
|
||||
type BookmarksCallback = (bookmarks: Bookmark[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
type DecryptCompleteCallback = (eventId: string, publicCount: number, privateCount: number) => void
|
||||
|
||||
/**
|
||||
* Shared bookmark streaming controller
|
||||
* Encapsulates the Debug flow: stream events, dedupe, decrypt, build bookmarks
|
||||
*/
|
||||
class BookmarkController {
|
||||
private rawEventListeners: RawEventCallback[] = []
|
||||
private bookmarksListeners: BookmarksCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
private decryptCompleteListeners: DecryptCompleteCallback[] = []
|
||||
|
||||
private currentEvents: Map<string, NostrEvent> = new Map()
|
||||
private decryptedResults: Map<string, {
|
||||
publicItems: IndividualBookmark[]
|
||||
privateItems: IndividualBookmark[]
|
||||
newestCreatedAt?: number
|
||||
latestContent?: string
|
||||
allTags?: string[][]
|
||||
}> = new Map()
|
||||
private isLoading = false
|
||||
private hydrationGeneration = 0
|
||||
|
||||
// Event loaders for efficient batching
|
||||
private eventStore = new EventStore()
|
||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||
|
||||
onRawEvent(cb: RawEventCallback): () => void {
|
||||
this.rawEventListeners.push(cb)
|
||||
return () => {
|
||||
this.rawEventListeners = this.rawEventListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onBookmarks(cb: BookmarksCallback): () => void {
|
||||
this.bookmarksListeners.push(cb)
|
||||
return () => {
|
||||
this.bookmarksListeners = this.bookmarksListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onDecryptComplete(cb: DecryptCompleteCallback): () => void {
|
||||
this.decryptCompleteListeners.push(cb)
|
||||
return () => {
|
||||
this.decryptCompleteListeners = this.decryptCompleteListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.hydrationGeneration++
|
||||
this.currentEvents.clear()
|
||||
this.decryptedResults.clear()
|
||||
this.setLoading(false)
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
if (this.isLoading !== loading) {
|
||||
this.isLoading = loading
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
}
|
||||
|
||||
private emitRawEvent(evt: NostrEvent): void {
|
||||
this.rawEventListeners.forEach(cb => cb(evt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
||||
*/
|
||||
private hydrateByIds(
|
||||
ids: string[],
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.eventLoader) {
|
||||
console.warn('[bookmark] ⚠️ EventLoader not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to unique IDs not already hydrated
|
||||
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
||||
if (unique.length === 0) {
|
||||
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
|
||||
|
||||
// Convert IDs to EventPointers
|
||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||
|
||||
// Use EventLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('[bookmark] ❌ EventLoader error:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
||||
*/
|
||||
private hydrateByCoordinates(
|
||||
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
if (coords.length === 0) return
|
||||
|
||||
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
|
||||
|
||||
// Convert coordinates to AddressPointers
|
||||
const pointers = coords.map(c => ({
|
||||
kind: c.kind,
|
||||
pubkey: c.pubkey,
|
||||
identifier: c.identifier
|
||||
}))
|
||||
|
||||
// Use AddressLoader - it auto-batches and streams results
|
||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('[bookmark] ❌ AddressLoader error:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async buildAndEmitBookmarks(
|
||||
activeAccount: AccountWithExtension,
|
||||
signerCandidate: unknown
|
||||
): Promise<void> {
|
||||
const allEvents = Array.from(this.currentEvents.values())
|
||||
|
||||
// Include unencrypted events OR encrypted events that have been decrypted
|
||||
const readyEvents = allEvents.filter(evt => {
|
||||
const isEncrypted = hasEncryptedContent(evt)
|
||||
if (!isEncrypted) return true // Include unencrypted
|
||||
// Include encrypted if already decrypted
|
||||
return this.decryptedResults.has(getEventKey(evt))
|
||||
})
|
||||
|
||||
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
|
||||
const decryptedCount = readyEvents.length - unencryptedCount
|
||||
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
|
||||
|
||||
if (readyEvents.length === 0) {
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Separate unencrypted and decrypted events
|
||||
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
||||
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
||||
|
||||
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
|
||||
// Process unencrypted events
|
||||
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
||||
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
||||
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
|
||||
|
||||
// Merge in decrypted results
|
||||
let publicItemsAll = [...publicUnencrypted]
|
||||
let privateItemsAll = [...privateUnencrypted]
|
||||
|
||||
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
|
||||
decryptedEvents.forEach(evt => {
|
||||
const eventKey = getEventKey(evt)
|
||||
const decrypted = this.decryptedResults.get(eventKey)
|
||||
if (decrypted) {
|
||||
publicItemsAll = [...publicItemsAll, ...decrypted.publicItems]
|
||||
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
|
||||
|
||||
// Separate hex IDs from coordinates
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to build and emit bookmarks
|
||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||
console.log('[bookmark] 🔧 Building final bookmarks list...')
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
|
||||
|
||||
console.log('[bookmark] 🔧 Enriching and sorting...')
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
content: b.content || ''
|
||||
}))
|
||||
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
|
||||
|
||||
console.log('[bookmark] 🔧 Creating final Bookmark object...')
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||
individualBookmarks: sortedBookmarks,
|
||||
isPrivate: privateItemsAll.length > 0,
|
||||
encryptedContent: undefined
|
||||
}
|
||||
|
||||
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
|
||||
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
|
||||
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
||||
}
|
||||
|
||||
// Emit immediately with empty metadata (show placeholders)
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
|
||||
emitBookmarks(idToEvent)
|
||||
|
||||
// Now fetch events progressively in background using batched hydrators
|
||||
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
|
||||
|
||||
const generation = this.hydrationGeneration
|
||||
const onProgress = () => emitBookmarks(idToEvent)
|
||||
|
||||
// Parse coordinates from strings to objects
|
||||
const coordObjs = coordinates.map(c => {
|
||||
const parts = c.split(':')
|
||||
return {
|
||||
kind: parseInt(parts[0]),
|
||||
pubkey: parts[1],
|
||||
identifier: parts[2] || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Kick off batched hydration (streaming, non-blocking)
|
||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||
} catch (error) {
|
||||
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
|
||||
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
|
||||
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
activeAccount: unknown
|
||||
accountManager: { getActive: () => unknown }
|
||||
}): Promise<void> {
|
||||
const { relayPool, activeAccount, accountManager } = options
|
||||
|
||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||
console.error('[bookmark] Invalid activeAccount')
|
||||
return
|
||||
}
|
||||
|
||||
const account = activeAccount as { pubkey: string; [key: string]: unknown }
|
||||
|
||||
// Increment generation to cancel any in-flight hydration
|
||||
this.hydrationGeneration++
|
||||
|
||||
// Initialize loaders for this session
|
||||
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
this.addressLoader = createAddressLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
// Get signer for auto-decryption
|
||||
const fullAccount = accountManager.getActive() as AccountWithExtension | null
|
||||
const maybeAccount = (fullAccount || account) as AccountWithExtension
|
||||
let signerCandidate: unknown = maybeAccount
|
||||
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
||||
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
||||
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
||||
signerCandidate = maybeAccount.signer
|
||||
}
|
||||
|
||||
// Stream events with live deduplication (same as Debug)
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [account.pubkey] },
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
const key = getEventKey(evt)
|
||||
const existing = this.currentEvents.get(key)
|
||||
|
||||
if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) {
|
||||
return // Keep existing (it's newer)
|
||||
}
|
||||
|
||||
// Add/update event
|
||||
this.currentEvents.set(key, evt)
|
||||
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
|
||||
|
||||
// Emit raw event for Debug UI
|
||||
this.emitRawEvent(evt)
|
||||
|
||||
// Build bookmarks immediately for unencrypted events
|
||||
const isEncrypted = hasEncryptedContent(evt)
|
||||
if (!isEncrypted) {
|
||||
// For unencrypted events, build bookmarks immediately (progressive update)
|
||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
|
||||
}
|
||||
|
||||
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
||||
if (isEncrypted) {
|
||||
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
|
||||
// Don't await - let it run in background
|
||||
collectBookmarksFromEvents([evt], account, signerCandidate)
|
||||
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
||||
const eventKey = getEventKey(evt)
|
||||
// Store the actual decrypted items, not just counts
|
||||
this.decryptedResults.set(eventKey, {
|
||||
publicItems: publicItemsAll,
|
||||
privateItems: privateItemsAll,
|
||||
newestCreatedAt,
|
||||
latestContent,
|
||||
allTags
|
||||
})
|
||||
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
|
||||
public: publicItemsAll.length,
|
||||
private: privateItemsAll.length
|
||||
})
|
||||
|
||||
// Emit decrypt complete for Debug UI
|
||||
this.decryptCompleteListeners.forEach(cb =>
|
||||
cb(evt.id, publicItemsAll.length, privateItemsAll.length)
|
||||
)
|
||||
|
||||
// Rebuild bookmarks with newly decrypted content (progressive update)
|
||||
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Final update after EOSE
|
||||
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||
console.log('[bookmark] ✅ Bookmark load complete')
|
||||
} catch (error) {
|
||||
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const bookmarkController = new BookmarkController()
|
||||
|
||||
@@ -11,6 +11,96 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
|
||||
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
||||
|
||||
/**
|
||||
* Decrypt/unlock a single event and return private bookmarks
|
||||
*/
|
||||
async function decryptEvent(
|
||||
evt: NostrEvent,
|
||||
activeAccount: ActiveAccount,
|
||||
signerCandidate: unknown,
|
||||
metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string }
|
||||
): Promise<IndividualBookmark[]> {
|
||||
const { dTag, setTitle, setDescription, setImage } = metadata
|
||||
const privateItems: IndividualBookmark[] = []
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
||||
} catch {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
} else if (evt.content && evt.content.length > 0) {
|
||||
let decryptedContent: string | undefined
|
||||
|
||||
// Try to detect encryption method from content format
|
||||
// NIP-44 starts with version byte (currently 0x02), NIP-04 is base64
|
||||
const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=')
|
||||
|
||||
// Try the likely method first (no timeout - let it fail naturally like debug page)
|
||||
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
||||
try {
|
||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to nip04 if nip44 failed or content looks like nip04
|
||||
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
||||
try {
|
||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
||||
} catch (err) {
|
||||
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptedContent) {
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||
privateItems.push(
|
||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItems.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
}
|
||||
|
||||
return privateItems
|
||||
}
|
||||
|
||||
export async function collectBookmarksFromEvents(
|
||||
bookmarkListEvents: NostrEvent[],
|
||||
activeAccount: ActiveAccount,
|
||||
@@ -23,21 +113,23 @@ export async function collectBookmarksFromEvents(
|
||||
allTags: string[][]
|
||||
}> {
|
||||
const publicItemsAll: IndividualBookmark[] = []
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
let newestCreatedAt = 0
|
||||
let latestContent = ''
|
||||
let allTags: string[][] = []
|
||||
|
||||
// Build list of events needing decrypt and collect public items immediately
|
||||
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
|
||||
|
||||
for (const evt of bookmarkListEvents) {
|
||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||
const metadata = { dTag, setTitle, setDescription, setImage }
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
@@ -73,69 +165,22 @@ export async function collectBookmarksFromEvents(
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
||||
} catch {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
||||
let decryptedContent: string | undefined
|
||||
try {
|
||||
if (hasNip44Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!decryptedContent) {
|
||||
try {
|
||||
if (hasNip04Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptedContent) {
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||
privateItemsAll.push(
|
||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule decrypt if needed
|
||||
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||
const needsDecrypt = signerCandidate && (
|
||||
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||
Helpers.hasHiddenContent(evt) ||
|
||||
hasNip04Content
|
||||
)
|
||||
|
||||
if (needsDecrypt) {
|
||||
decryptJobs.push({ evt, metadata })
|
||||
} else {
|
||||
// Check for already-unlocked hidden bookmarks
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItemsAll.push(
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
@@ -146,8 +191,17 @@ export async function collectBookmarksFromEvents(
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt events sequentially
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
if (decryptJobs.length > 0 && signerCandidate) {
|
||||
for (const job of decryptJobs) {
|
||||
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
|
||||
if (privateItems && privateItems.length > 0) {
|
||||
privateItemsAll.push(...privateItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
dedupeNip51Events,
|
||||
hydrateItems,
|
||||
isAccountWithExtension,
|
||||
hasNip04Decrypt,
|
||||
hasNip44Decrypt,
|
||||
dedupeBookmarksById,
|
||||
extractUrlsFromContent
|
||||
} from './bookmarkHelpers'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
|
||||
|
||||
export const fetchBookmarks = async (
|
||||
relayPool: RelayPool,
|
||||
activeAccount: unknown, // Full account object with extension capabilities
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
||||
settings?: UserSettings
|
||||
) => {
|
||||
try {
|
||||
|
||||
if (!isAccountWithExtension(activeAccount)) {
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events')
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||
{}
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
// Check for events with potentially encrypted content
|
||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
||||
if (eventsWithContent.length > 0) {
|
||||
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
|
||||
eventsWithContent.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
|
||||
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
|
||||
})
|
||||
}
|
||||
|
||||
rawEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
|
||||
// Log which events made it through deduplication
|
||||
bookmarkListEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
||||
})
|
||||
|
||||
// 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'))
|
||||
if (primalReads) {
|
||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||
} else {
|
||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
||||
}
|
||||
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||
return
|
||||
}
|
||||
// Aggregate across events
|
||||
const maybeAccount = activeAccount as AccountWithExtension
|
||||
console.log('🔐 Account object:', {
|
||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
||||
hasSigner: !!maybeAccount?.signer,
|
||||
accountType: typeof maybeAccount,
|
||||
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
|
||||
})
|
||||
|
||||
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
|
||||
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
|
||||
let signerCandidate: unknown = maybeAccount
|
||||
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
||||
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
||||
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
||||
// Fallback to the raw signer if account doesn't have nip04/nip44
|
||||
signerCandidate = maybeAccount.signer
|
||||
}
|
||||
|
||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||
if (signerCandidate) {
|
||||
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
||||
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
||||
}
|
||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
||||
bookmarkListEvents,
|
||||
activeAccount,
|
||||
signerCandidate
|
||||
)
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
|
||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
// Check if it's a hex ID (64 character hex string)
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
// Coordinate format: kind:pubkey:identifier
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
})
|
||||
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Fetch regular events by ID
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: Array.from(new Set(noteIds)) },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
events.forEach((e: NostrEvent) => {
|
||||
idToEvent.set(e.id, e)
|
||||
// Also store by coordinate if it's an addressable event
|
||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events by ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch addressable events by coordinates
|
||||
if (coordinates.length > 0) {
|
||||
try {
|
||||
// Group by kind for more efficient querying
|
||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
||||
|
||||
coordinates.forEach(coord => {
|
||||
const parts = coord.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
if (!byKind.has(kind)) {
|
||||
byKind.set(kind, [])
|
||||
}
|
||||
byKind.get(kind)!.push({ pubkey, identifier })
|
||||
})
|
||||
|
||||
// Query each kind group
|
||||
for (const [kind, items] of byKind.entries()) {
|
||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [kind], authors, '#d': identifiers },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
|
||||
events.forEach((e: NostrEvent) => {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
// Also store by event ID
|
||||
idToEvent.set(e.id, e)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch addressable events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
|
||||
// Sort individual bookmarks by "added" timestamp first (most recently added first),
|
||||
// falling back to event created_at when unknown.
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
content: b.content || ''
|
||||
}))
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||
individualBookmarks: sortedBookmarks,
|
||||
isPrivate: privateItemsAll.length > 0,
|
||||
encryptedContent: undefined
|
||||
}
|
||||
|
||||
setBookmarks([bookmark])
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bookmarks:', error)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -24,7 +23,6 @@ export const fetchContacts = async (
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
{
|
||||
relayUrls,
|
||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||
// Stream partials as we see any contact list
|
||||
for (const tag of event.tags) {
|
||||
|
||||
114
src/services/contactsController.ts
Normal file
114
src/services/contactsController.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchContacts } from './contactService'
|
||||
|
||||
type ContactsCallback = (contacts: Set<string>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
/**
|
||||
* Shared contacts/friends controller
|
||||
* Manages the user's follow list centrally, similar to bookmarkController
|
||||
*/
|
||||
class ContactsController {
|
||||
private contactsListeners: ContactsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentContacts: Set<string> = new Set()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
|
||||
onContacts(cb: ContactsCallback): () => void {
|
||||
this.contactsListeners.push(cb)
|
||||
return () => {
|
||||
this.contactsListeners = this.contactsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitContacts(contacts: Set<string>): void {
|
||||
this.contactsListeners.forEach(cb => cb(contacts))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current contacts without triggering a reload
|
||||
*/
|
||||
getContacts(): Set<string> {
|
||||
return new Set(this.currentContacts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if contacts are loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.currentContacts.clear()
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitContacts(this.currentContacts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load contacts for a user
|
||||
* Streams partial results and caches the final list
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, pubkey, force = false } = options
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitContacts(this.currentContacts)
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const contacts = await fetchContacts(
|
||||
relayPool,
|
||||
pubkey,
|
||||
(partial) => {
|
||||
// Stream partial updates
|
||||
this.currentContacts = new Set(partial)
|
||||
this.emitContacts(this.currentContacts)
|
||||
console.log('[contacts] 📥 Partial contacts:', partial.size)
|
||||
}
|
||||
)
|
||||
|
||||
// Store final result
|
||||
this.currentContacts = new Set(contacts)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitContacts(this.currentContacts)
|
||||
|
||||
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
|
||||
} catch (error) {
|
||||
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||
this.currentContacts.clear()
|
||||
this.emitContacts(this.currentContacts)
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const contactsController = new ContactsController()
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { Observable, merge, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
export interface QueryOptions {
|
||||
relayUrls?: string[]
|
||||
localTimeoutMs?: number
|
||||
remoteTimeoutMs?: number
|
||||
onEvent?: (event: NostrEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified local-first query helper with optional streaming callback.
|
||||
* Returns all collected events (deduped by id) after both streams complete or time out.
|
||||
* Returns all collected events (deduped by id) after both streams complete (EOSE).
|
||||
* Trusts relay EOSE signals - no artificial timeouts.
|
||||
*/
|
||||
export async function queryEvents(
|
||||
relayPool: RelayPool,
|
||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
||||
): Promise<NostrEvent[]> {
|
||||
const {
|
||||
relayUrls,
|
||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||
onEvent
|
||||
} = options
|
||||
|
||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(localTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(remoteTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
|
||||
@@ -46,7 +46,8 @@ export async function createHighlight(
|
||||
}
|
||||
|
||||
// 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 context: string | undefined
|
||||
@@ -116,7 +117,9 @@ export async function createHighlight(
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
|
||||
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
|
||||
await publishEvent(relayPool, eventStore, signedEvent)
|
||||
|
||||
96
src/services/highlights/cache.ts
Normal file
96
src/services/highlights/cache.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Highlight } from '../../types/highlights'
|
||||
|
||||
interface CacheEntry {
|
||||
highlights: Highlight[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory session cache for highlight queries with TTL
|
||||
*/
|
||||
class HighlightCache {
|
||||
private cache = new Map<string, CacheEntry>()
|
||||
private ttlMs = 60000 // 60 seconds
|
||||
|
||||
/**
|
||||
* Generate cache key for article coordinate
|
||||
*/
|
||||
articleKey(coordinate: string): string {
|
||||
return `article:${coordinate}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for URL
|
||||
*/
|
||||
urlKey(url: string): string {
|
||||
// Normalize URL for consistent caching
|
||||
try {
|
||||
const normalized = new URL(url)
|
||||
normalized.hash = '' // Remove hash
|
||||
return `url:${normalized.toString()}`
|
||||
} catch {
|
||||
return `url:${url}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for author pubkey
|
||||
*/
|
||||
authorKey(pubkey: string): string {
|
||||
return `author:${pubkey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached highlights if not expired
|
||||
*/
|
||||
get(key: string): Highlight[] | null {
|
||||
const entry = this.cache.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
const now = Date.now()
|
||||
if (now - entry.timestamp > this.ttlMs) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.highlights
|
||||
}
|
||||
|
||||
/**
|
||||
* Store highlights in cache
|
||||
*/
|
||||
set(key: string, highlights: Highlight[]): void {
|
||||
this.cache.set(key, {
|
||||
highlights,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific cache entry
|
||||
*/
|
||||
clear(key: string): void {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache stats
|
||||
*/
|
||||
stats(): { size: number; keys: string[] } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const highlightCache = new HighlightCache()
|
||||
|
||||
@@ -1,60 +1,77 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
import { KINDS } from '../../config/kinds'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
import { highlightCache } from './cache'
|
||||
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
settings?: UserSettings,
|
||||
force = false,
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
// Check cache first unless force refresh
|
||||
if (!force) {
|
||||
const cacheKey = highlightCache.authorKey(pubkey)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
cached.forEach(h => onHighlight(h))
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const ordered = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
const rawEvents: NostrEvent[] = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (seenIds.has(event.id)) return
|
||||
seenIds.add(event.id)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
|
||||
|
||||
// Store all events in event store if provided
|
||||
if (eventStore) {
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
try {
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
} catch (err) {
|
||||
console.warn('Failed to rebroadcast highlight events:', err)
|
||||
}
|
||||
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
const sorted = sortHighlights(highlights)
|
||||
|
||||
// Cache the results
|
||||
const cacheKey = highlightCache.authorKey(pubkey)
|
||||
highlightCache.set(cacheKey, sorted)
|
||||
|
||||
return sorted
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,95 +1,81 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { RELAYS } from '../../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { KINDS } from '../../config/kinds'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
import { highlightCache } from './cache'
|
||||
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
settings?: UserSettings,
|
||||
force = false,
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
// Check cache first unless force refresh
|
||||
if (!force) {
|
||||
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
cached.forEach(h => onHighlight(h))
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
const onEvent = (event: NostrEvent) => {
|
||||
if (seenIds.has(event.id)) return
|
||||
seenIds.add(event.id)
|
||||
return eventToHighlight(event)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
const aLocal$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const aRemote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
||||
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
const eLocal$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const eRemote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
||||
}
|
||||
// Query for both #a and #e tags in parallel
|
||||
const [aTagEvents, eTagEvents] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
|
||||
eventId
|
||||
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
|
||||
: Promise.resolve([] as NostrEvent[])
|
||||
])
|
||||
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
|
||||
|
||||
// Store all events in event store if provided
|
||||
if (eventStore) {
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
try {
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
} catch (err) {
|
||||
console.warn('Failed to rebroadcast highlight events:', err)
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
const sorted = sortHighlights(highlights)
|
||||
|
||||
// Cache the results
|
||||
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||
highlightCache.set(cacheKey, sorted)
|
||||
|
||||
return sorted
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,55 +1,80 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { RELAYS } from '../../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { KINDS } from '../../config/kinds'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
import { highlightCache } from './cache'
|
||||
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
settings?: UserSettings,
|
||||
force = false,
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
// Check cache first unless force refresh
|
||||
if (!force) {
|
||||
const cacheKey = highlightCache.urlKey(url)
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
|
||||
// Stream cached highlights if callback provided
|
||||
if (onHighlight) {
|
||||
cached.forEach(h => onHighlight(h))
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
||||
const local$ = localRelaysUrl.length > 0
|
||||
? relayPool
|
||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelaysUrl.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const rawEvents: NostrEvent[] = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.Highlights], '#r': [url] },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (seenIds.has(event.id)) return
|
||||
seenIds.add(event.id)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
||||
|
||||
// Store all events in event store if provided
|
||||
if (eventStore) {
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
// Rebroadcast events - but don't let errors here break the highlight display
|
||||
try {
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
} catch (err) {
|
||||
console.warn('Failed to rebroadcast highlight events:', err)
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch {
|
||||
const sorted = sortHighlights(highlights)
|
||||
|
||||
// Cache the results
|
||||
const cacheKey = highlightCache.urlKey(url)
|
||||
highlightCache.set(cacheKey, sorted)
|
||||
|
||||
return sorted
|
||||
} catch (err) {
|
||||
console.error('Error fetching highlights for URL:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
@@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch'
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||
* @param eventStore - Optional event store to persist events
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchHighlightsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
@@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async (
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Store all events in event store if provided
|
||||
if (eventStore) {
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
|
||||
208
src/services/highlightsController.ts
Normal file
208
src/services/highlightsController.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||
|
||||
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
||||
|
||||
/**
|
||||
* Shared highlights controller
|
||||
* Manages the user's highlights centrally, similar to bookmarkController
|
||||
*/
|
||||
class HighlightsController {
|
||||
private highlightsListeners: HighlightsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentHighlights: Highlight[] = []
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
|
||||
onHighlights(cb: HighlightsCallback): () => void {
|
||||
this.highlightsListeners.push(cb)
|
||||
return () => {
|
||||
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitHighlights(highlights: Highlight[]): void {
|
||||
this.highlightsListeners.forEach(cb => cb(highlights))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current highlights without triggering a reload
|
||||
*/
|
||||
getHighlights(): Highlight[] {
|
||||
return [...this.currentHighlights]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if highlights are loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.generation++
|
||||
this.currentHighlights = []
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last synced timestamp for incremental loading
|
||||
*/
|
||||
private getLastSyncedAt(pubkey: string): number | null {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
if (!data) return null
|
||||
const parsed = JSON.parse(data)
|
||||
return parsed[pubkey] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last synced timestamp
|
||||
*/
|
||||
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||
try {
|
||||
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||
const parsed = data ? JSON.parse(data) : {}
|
||||
parsed[pubkey] = timestamp
|
||||
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||
} catch (err) {
|
||||
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load highlights for a user
|
||||
* Streams results and stores in event store
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, eventStore, pubkey, force = false } = options
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
return
|
||||
}
|
||||
|
||||
// Increment generation to cancel any in-flight work
|
||||
this.generation++
|
||||
const currentGeneration = this.generation
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
// Get last synced timestamp for incremental loading
|
||||
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||
kinds: [KINDS.Highlights],
|
||||
authors: [pubkey]
|
||||
}
|
||||
if (lastSyncedAt) {
|
||||
filter.since = lastSyncedAt
|
||||
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||
}
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
filter,
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
// Check if this generation is still active
|
||||
if (currentGeneration !== this.generation) return
|
||||
|
||||
if (seenIds.has(evt.id)) return
|
||||
seenIds.add(evt.id)
|
||||
|
||||
// Store in event store immediately
|
||||
eventStore.add(evt)
|
||||
|
||||
// Convert to highlight and add to map
|
||||
const highlight = eventToHighlight(evt)
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
|
||||
// Stream to listeners
|
||||
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
|
||||
this.currentHighlights = sortedHighlights
|
||||
this.emitHighlights(sortedHighlights)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Check if still active after async operation
|
||||
if (currentGeneration !== this.generation) {
|
||||
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
|
||||
return
|
||||
}
|
||||
|
||||
// Store all events in event store
|
||||
events.forEach(evt => eventStore.add(evt))
|
||||
|
||||
// Final processing
|
||||
const highlights = events.map(eventToHighlight)
|
||||
const uniqueHighlights = Array.from(
|
||||
new Map(highlights.map(h => [h.id, h])).values()
|
||||
)
|
||||
const sorted = sortHighlights(uniqueHighlights)
|
||||
|
||||
this.currentHighlights = sorted
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitHighlights(sorted)
|
||||
|
||||
// Update last synced timestamp
|
||||
if (sorted.length > 0) {
|
||||
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
||||
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||
}
|
||||
|
||||
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
|
||||
} catch (error) {
|
||||
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||
this.currentHighlights = []
|
||||
this.emitHighlights(this.currentHighlights)
|
||||
} finally {
|
||||
// Only clear loading if this generation is still active
|
||||
if (currentGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const highlightsController = new HighlightsController()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
|
||||
try {
|
||||
// Fetch kind:7 and kind:17 reactions in parallel
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
])
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
|
||||
|
||||
// Filter to only nostr-native articles (kind 30023)
|
||||
const nostrArticles = readArticles.filter(
|
||||
article => article.eventKind === 30023 && article.eventId
|
||||
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||
)
|
||||
|
||||
if (nostrArticles.length === 0) {
|
||||
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
|
||||
|
||||
const articleEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], ids: eventIds },
|
||||
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||
{ 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 { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { ReadItem } from './readsService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
readArticles: BlogPostPreview[]
|
||||
reads?: ReadItem[]
|
||||
links?: ReadItem[]
|
||||
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',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
@@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @param limit - Maximum number of posts to fetch (default: 50)
|
||||
* @param eventStore - Optional event store to persist fetched events
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchNostrverseBlogPosts = async (
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
limit = 50
|
||||
limit = 50,
|
||||
eventStore?: IEventStore
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
|
||||
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
@@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async (
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
@@ -42,7 +49,7 @@ export const fetchNostrverseBlogPosts = async (
|
||||
}
|
||||
)
|
||||
|
||||
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
|
||||
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
@@ -60,7 +67,7 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
|
||||
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
@@ -73,25 +80,43 @@ export const fetchNostrverseBlogPosts = async (
|
||||
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param limit - Maximum number of highlights to fetch (default: 100)
|
||||
* @param eventStore - Optional event store to persist fetched events
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchNostrverseHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
limit = 100
|
||||
limit = 100,
|
||||
eventStore?: IEventStore
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
|
||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], limit },
|
||||
{}
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (seenIds.has(event.id)) return
|
||||
seenIds.add(event.id)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Store all events in event store if provided (in case some were missed in streaming)
|
||||
if (eventStore) {
|
||||
rawEvents.forEach(evt => eventStore.add(evt))
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
|
||||
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
196
src/services/readingPositionService.ts
Normal file
196
src/services/readingPositionService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { publishEvent } from './writeService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||
|
||||
export interface ReadingPosition {
|
||||
position: number // 0-1 scroll progress
|
||||
timestamp: number // Unix timestamp
|
||||
scrollTop?: number // Optional: pixel position
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading position from an event
|
||||
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||
if (!event.content || event.content.length === 0) return undefined
|
||||
try {
|
||||
return JSON.parse(event.content) as ReadingPosition
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for an article
|
||||
* For Nostr articles: use the naddr directly
|
||||
* For external URLs: use base64url encoding of the URL
|
||||
*/
|
||||
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
// If it starts with "nostr:", extract the naddr
|
||||
if (naddrOrUrl.startsWith('nostr:')) {
|
||||
return naddrOrUrl.replace('nostr:', '')
|
||||
}
|
||||
// For URLs, use base64url encoding (URL-safe)
|
||||
return btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reading position to Nostr (Kind 30078)
|
||||
*/
|
||||
export async function saveReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
factory: EventFactory,
|
||||
articleIdentifier: string,
|
||||
position: ReadingPosition
|
||||
): Promise<void> {
|
||||
console.log('💾 [ReadingPosition] Saving position:', {
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
position: position.position,
|
||||
positionPercent: Math.round(position.position * 100) + '%',
|
||||
timestamp: position.timestamp,
|
||||
scrollTop: position.scrollTop
|
||||
})
|
||||
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: APP_DATA_KIND,
|
||||
content: JSON.stringify(position),
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['client', 'boris']
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
// Use unified write service
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
articleIdentifier: string
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
|
||||
console.log('📖 [ReadingPosition] Loading position:', {
|
||||
pubkey: pubkey.slice(0, 8) + '...',
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
dTag: dTag.slice(0, 50) + '...'
|
||||
})
|
||||
|
||||
// First, check if we already have the position in the local event store
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingPositionContent(localEvent)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached reading position found, fetching from relays...')
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.log('⏱️ Reading position load timeout - no position found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 3000) // Shorter timeout for reading positions
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
)
|
||||
if (event) {
|
||||
const content = getReadingPositionContent(event)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from relays:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
resolve(content)
|
||||
} else {
|
||||
console.log('⚠️ [ReadingPosition] Event found but no valid content')
|
||||
resolve(null)
|
||||
}
|
||||
} else {
|
||||
console.log('📭 [ReadingPosition] No position found on relays')
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading reading position:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('❌ Reading position subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ export interface UserSettings {
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
// Default explore scope
|
||||
defaultExploreScopeNostrverse?: boolean
|
||||
defaultExploreScopeFriends?: boolean
|
||||
defaultExploreScopeMine?: boolean
|
||||
// Zap split weights (treated as relative weights, not strict percentages)
|
||||
zapSplitHighlighterWeight?: number // default 50
|
||||
zapSplitBorisWeight?: number // default 2.1
|
||||
@@ -54,6 +58,8 @@ export interface UserSettings {
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
// Reading settings
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -52,6 +52,11 @@ export async function publishEvent(
|
||||
})
|
||||
.catch((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.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
261
src/styles/components/login.css
Normal file
261
src/styles/components/login.css
Normal file
@@ -0,0 +1,261 @@
|
||||
/* Login component styles */
|
||||
.login-container {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-highlight {
|
||||
background-color: var(--highlight-color-mine, #fde047);
|
||||
color: var(--color-text);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
|
||||
.login-button svg {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.login-button-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-button-primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.login-button-secondary {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-button-secondary:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.bunker-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bunker-input {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bunker-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.bunker-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bunker-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bunker-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bunker-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
|
||||
.bunker-connect {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bunker-connect:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.bunker-connect:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bunker-cancel {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bunker-cancel:hover:not(:disabled) {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bunker-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-error svg {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: rgb(251, 191, 36);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-error a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-error a:hover {
|
||||
color: var(--color-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
color: var(--color-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.login-description {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 0.875rem 1.25rem;
|
||||
}
|
||||
|
||||
.bunker-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bunker-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.me-tab-content:has(.bookmark-filters) {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
@@ -79,6 +83,15 @@
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Bookmark filters in Me page */
|
||||
.me-tab-content .bookmark-filters {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Ensure all reading list elements are left-aligned */
|
||||
.bookmarks-list .individual-bookmark,
|
||||
.bookmarks-list .individual-bookmark * {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; border-radius: 4px; }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }
|
||||
|
||||
@@ -176,3 +176,38 @@
|
||||
.read-inline-btn { background: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||
|
||||
/* Bookmark filters */
|
||||
.bookmark-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
padding: 0.375rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn.active {
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
39
src/utils/async.ts
Normal file
39
src/utils/async.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Wrap a promise with a timeout
|
||||
*/
|
||||
export async function withTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Map items through an async function with limited concurrency
|
||||
* @param items - Array of items to process
|
||||
* @param limit - Maximum number of concurrent operations
|
||||
* @param mapper - Async function to apply to each item
|
||||
* @returns Array of results in the same order as input
|
||||
*/
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex++
|
||||
results[index] = await mapper(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
const workers = new Array(Math.min(limit, items.length)).fill(0).map(() => worker())
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
42
src/utils/bookmarkTypeClassifier.ts
Normal file
42
src/utils/bookmarkTypeClassifier.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from './helpers'
|
||||
|
||||
export type BookmarkType = 'article' | 'external' | 'video' | 'note' | 'web'
|
||||
|
||||
/**
|
||||
* Classifies a bookmark into one of the content types
|
||||
*/
|
||||
export function classifyBookmarkType(bookmark: IndividualBookmark): BookmarkType {
|
||||
// Kind 30023 is always a nostr-native article
|
||||
if (bookmark.kind === 30023) return 'article'
|
||||
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||
|
||||
const extractedUrls = webBookmarkUrl
|
||||
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||
: extractUrlsFromContent(bookmark.content)
|
||||
|
||||
const firstUrl = extractedUrls[0]
|
||||
if (!firstUrl) return 'note'
|
||||
|
||||
const urlType = classifyUrl(firstUrl)?.type
|
||||
|
||||
if (urlType === 'youtube' || urlType === 'video') return 'video'
|
||||
if (urlType === 'article') return 'external' // External article links
|
||||
|
||||
return 'web'
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters bookmarks by type
|
||||
*/
|
||||
export function filterBookmarksByType(
|
||||
bookmarks: IndividualBookmark[],
|
||||
filterType: 'all' | BookmarkType
|
||||
): IndividualBookmark[] {
|
||||
if (filterType === 'all') return bookmarks
|
||||
return bookmarks.filter(bookmark => classifyBookmarkType(bookmark) === filterType)
|
||||
}
|
||||
|
||||
@@ -92,17 +92,30 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||
|
||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||
const sorted = sortIndividualBookmarks(items)
|
||||
const amethyst = sorted.filter(i => i.sourceKind === 30001)
|
||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
return { privateItems, publicItems, web, amethyst }
|
||||
|
||||
// Group by source list, not by content type
|
||||
const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
|
||||
const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
|
||||
// Amethyst bookmarks: kind:30001 (any d-tag or undefined)
|
||||
const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||
const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
|
||||
const standaloneWeb = sorted.filter(i => i.sourceKind === 39701)
|
||||
|
||||
return {
|
||||
nip51Public,
|
||||
nip51Private,
|
||||
amethystPublic,
|
||||
amethystPrivate,
|
||||
standaloneWeb
|
||||
}
|
||||
}
|
||||
|
||||
// Simple filter: only exclude bookmarks with empty/whitespace-only content
|
||||
// Simple filter: show bookmarks that have content OR just an ID (placeholder)
|
||||
export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||
return !!(bookmark.content && bookmark.content.trim().length > 0)
|
||||
// Show if has content OR has an ID (placeholder until events are fetched)
|
||||
const hasValidContent = !!(bookmark.content && bookmark.content.trim().length > 0)
|
||||
const hasId = !!(bookmark.id && bookmark.id.trim().length > 0)
|
||||
return hasValidContent || hasId
|
||||
}
|
||||
|
||||
// Bookmark sets helpers (kind 30003)
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
35
src/utils/dedupe.ts
Normal file
35
src/utils/dedupe.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
|
||||
/**
|
||||
* Deduplicate highlights by ID
|
||||
*/
|
||||
export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] {
|
||||
const byId = new Map<string, Highlight>()
|
||||
for (const highlight of highlights) {
|
||||
byId.set(highlight.id, highlight)
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate blog posts by replaceable event key (author:d-tag)
|
||||
* Keeps the newest version when duplicates exist
|
||||
*/
|
||||
export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||
const byKey = new Map<string, BlogPostPreview>()
|
||||
|
||||
for (const post of posts) {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${post.author}:${dTag}`
|
||||
const existing = byKey.get(key)
|
||||
|
||||
// Keep the newer version
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
byKey.set(key, post)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values())
|
||||
}
|
||||
|
||||
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
|
||||
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": [
|
||||
{
|
||||
"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": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
|
||||
100
vite.config.ts
100
vite.config.ts
@@ -1,8 +1,101 @@
|
||||
/* eslint-env node */
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
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({
|
||||
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: [
|
||||
react(),
|
||||
VitePWA({
|
||||
@@ -30,7 +123,8 @@ export default defineConfig({
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
|
||||
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt']
|
||||
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt'],
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024 // 3 MiB
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
@@ -48,7 +142,7 @@ export default defineConfig({
|
||||
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react'],
|
||||
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react', 'applesauce-accounts', 'applesauce-signers'],
|
||||
esbuildOptions: {
|
||||
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
|
||||
}
|
||||
@@ -65,7 +159,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
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