mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
319 Commits
sync-readi
...
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
85649ae283 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
|||||||
# Reference Projects
|
# Reference Projects
|
||||||
applesauce
|
applesauce
|
||||||
primal-web-app
|
primal-web-app
|
||||||
|
Amber
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
|
|
||||||
|
|
||||||
243
CHANGELOG.md
243
CHANGELOG.md
@@ -7,6 +7,243 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1641,7 +1878,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.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.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
|
|||||||
304
api/article-og.ts
Normal file
304
api/article-og.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
// Relay configuration (from src/config/relays.ts)
|
||||||
|
const RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://relay.dergigi.com',
|
||||||
|
'wss://wot.dergigi.com',
|
||||||
|
'wss://relay.snort.social',
|
||||||
|
'wss://relay.current.fyi',
|
||||||
|
'wss://nostr-pub.wellorder.net',
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.primal.net'
|
||||||
|
]
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
html: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleMetadata {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEventsFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||||
|
|
||||||
|
// `request` emits NostrEvent objects directly
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by created_at and return most recent first
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode naddr
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
|
// Determine relay URLs
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Fetch article and profile in parallel
|
||||||
|
const [articleEvents, profileEvents] = await Promise.all([
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 5000),
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pointer.pubkey]
|
||||||
|
}, 3000)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (articleEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = articleEvents[0]
|
||||||
|
|
||||||
|
// Extract article metadata
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract author name from profile
|
||||||
|
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
if (profileEvents.length > 0) {
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvents[0].content)
|
||||||
|
authorName = profileData.display_name || profileData.name || authorName
|
||||||
|
} catch {
|
||||||
|
// Use fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata:', err)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
// No explicit close needed; pool manages connections internally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||||
|
const baseUrl = 'https://read.withboris.com'
|
||||||
|
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||||
|
|
||||||
|
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||||
|
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||||
|
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||||
|
const author = meta?.author || 'Boris'
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrawler(userAgent: string | undefined): boolean {
|
||||||
|
if (!userAgent) return false
|
||||||
|
const crawlers = [
|
||||||
|
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
||||||
|
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
||||||
|
]
|
||||||
|
const ua = userAgent.toLowerCase()
|
||||||
|
return crawlers.some(crawler => ua.includes(crawler))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = req.headers['user-agent'] as string | undefined
|
||||||
|
const isCrawlerRequest = isCrawler(userAgent)
|
||||||
|
|
||||||
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] request', JSON.stringify({
|
||||||
|
naddr,
|
||||||
|
ua: userAgent || null,
|
||||||
|
isCrawlerRequest,
|
||||||
|
path: req.url || null
|
||||||
|
}))
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||||
|
// Use history.replaceState to set the URL before the SPA boots
|
||||||
|
if (!isCrawlerRequest) {
|
||||||
|
const articlePath = `/a/${naddr}`
|
||||||
|
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Boris - Loading Article...</title>
|
||||||
|
<script>
|
||||||
|
// Set the URL to the article path before SPA loads
|
||||||
|
if (window.location.pathname !== '${articlePath}') {
|
||||||
|
history.replaceState(null, '', '${articlePath}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
|
||||||
|
<script>
|
||||||
|
// Redirect to index.html which will load the SPA
|
||||||
|
// The history state is already set, so SPA will see the correct URL
|
||||||
|
window.location.replace('/');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache for bots/crawlers
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(naddr)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(cached.html)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata
|
||||||
|
const meta = await fetchArticleMetadata(naddr)
|
||||||
|
|
||||||
|
// Generate HTML
|
||||||
|
const html = generateHtml(naddr, meta)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating article OG HTML:', err)
|
||||||
|
|
||||||
|
// Fallback to basic HTML with SPA boot
|
||||||
|
const html = generateHtml(naddr, null)
|
||||||
|
setCacheHeaders(res, 3600)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<meta property="og:url" content="https://read.withboris.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
<meta property="og:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.20",
|
"version": "0.7.1",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
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 |
386
src/App.tsx
386
src/App.tsx
@@ -1,19 +1,26 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||||
import { EventStore } from 'applesauce-core'
|
import { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
import { DebugBus } from './utils/debugBus'
|
||||||
|
import { Bookmark } from './types/bookmarks'
|
||||||
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -27,9 +34,53 @@ function AppRoutes({
|
|||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
|
// Centralized bookmark state (fed by controller)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [bookmarksLoading, setBookmarksLoading] = 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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-load bookmarks when account is ready (on login or page mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
|
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
||||||
|
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, 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 = () => {
|
const handleLogout = () => {
|
||||||
accountManager.clearActive()
|
accountManager.clearActive()
|
||||||
|
bookmarkController.reset() // Clear bookmarks via controller
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +92,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -50,6 +104,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -59,6 +116,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -68,6 +128,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -77,6 +140,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -86,6 +152,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -99,6 +168,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -108,15 +180,45 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/me/reads"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
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 +228,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -135,6 +240,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -144,6 +252,21 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debug"
|
||||||
|
element={
|
||||||
|
<Debug
|
||||||
|
relayPool={relayPool}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -165,23 +288,68 @@ function App() {
|
|||||||
const store = new EventStore()
|
const store = new EventStore()
|
||||||
const accounts = new AccountManager()
|
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)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
|
// Create relay pool and set it up BEFORE loading accounts
|
||||||
|
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||||
|
const pool = new RelayPool()
|
||||||
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
|
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
// 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
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
|
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
||||||
|
|
||||||
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
|
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
|
||||||
|
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
if (activeId && accounts.getAccount(activeId)) {
|
console.log('[bunker] Active ID from localStorage:', activeId)
|
||||||
accounts.setActive(activeId)
|
|
||||||
console.log('Restored active account:', activeId)
|
if (activeId) {
|
||||||
|
const account = accounts.getAccount(activeId)
|
||||||
|
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
accounts.setActive(activeId)
|
||||||
|
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
||||||
|
} else {
|
||||||
|
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] No active account ID in localStorage')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', err)
|
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
// Subscribe to accounts changes and persist to localStorage
|
||||||
@@ -198,12 +366,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
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
pool.group(RELAYS)
|
console.log('[bunker] Active account changed:', {
|
||||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
hasAccount: !!account,
|
||||||
console.log('Relay URLs:', RELAYS)
|
type: account?.type,
|
||||||
|
id: account?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (account && account.type === 'nostr-connect') {
|
||||||
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||||
|
try {
|
||||||
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
|
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
|
||||||
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
|
// Skip if we've already reconnected this account
|
||||||
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
|
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[bunker] Account detected. Status:', {
|
||||||
|
listening: nostrConnectAccount.signer.listening,
|
||||||
|
isConnected: nostrConnectAccount.signer.isConnected,
|
||||||
|
hasRemote: !!nostrConnectAccount.signer.remote,
|
||||||
|
bunkerRelays: nostrConnectAccount.signer.relays
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
|
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
|
||||||
|
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||||
|
// This ensures the pool has all relays when the signer sets up its methods
|
||||||
|
const bunkerRelays = signerData.relays || []
|
||||||
|
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||||
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
|
if (newBunkerRelays.length > 0) {
|
||||||
|
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
||||||
|
pool.group(newBunkerRelays)
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] Bunker relays already in pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
|
relays: signerData.relays,
|
||||||
|
pubkey: nostrConnectAccount.pubkey,
|
||||||
|
remote: signerData.remote,
|
||||||
|
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||||
|
pool: pool
|
||||||
|
})
|
||||||
|
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||||
|
try {
|
||||||
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
|
recreatedSigner.relays = mergedRelays
|
||||||
|
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
||||||
|
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||||
|
|
||||||
|
// Replace the signer on the account
|
||||||
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
console.log('[bunker] ✅ Signer recreated with pool context')
|
||||||
|
|
||||||
|
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||||
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
try {
|
||||||
|
let method: string | undefined
|
||||||
|
const content = (event as { content?: unknown })?.content
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
||||||
|
method = parsed?.method
|
||||||
|
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
||||||
|
}
|
||||||
|
const summary = {
|
||||||
|
relays,
|
||||||
|
kind: (event as { kind?: number })?.kind,
|
||||||
|
method,
|
||||||
|
// include tags array for debugging (NIP-46 expects method tag)
|
||||||
|
tags: (event as { tags?: unknown })?.tags,
|
||||||
|
contentLength: typeof content === 'string' ? content.length : undefined
|
||||||
|
}
|
||||||
|
console.log('[bunker] publish via signer:', summary)
|
||||||
|
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||||
|
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||||
|
// Fire-and-forget publish: trigger the publish but do not return the
|
||||||
|
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||||
|
const result = originalPublish(relays, event)
|
||||||
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
|
// 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
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
@@ -233,6 +587,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
|
bunkerReconnectSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
@@ -249,7 +604,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
// Monitor online/offline status
|
// Monitor online/offline status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -285,6 +640,7 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
@@ -22,17 +21,24 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bookmark-filters">
|
<div className="bookmark-filters">
|
||||||
{filters.map(filter => (
|
{filters.map(filter => {
|
||||||
<button
|
const isActive = selectedFilter === filter.type
|
||||||
key={filter.type}
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
onClick={() => onFilterChange(filter.type)}
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
|
||||||
title={filter.label}
|
return (
|
||||||
aria-label={`Filter by ${filter.label}`}
|
<button
|
||||||
>
|
key={filter.type}
|
||||||
<FontAwesomeIcon icon={filter.icon} />
|
onClick={() => onFilterChange(filter.type)}
|
||||||
</button>
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
))}
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate progress percentage and determine color
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -21,6 +21,7 @@ import { RELAYS } from '../config/relays'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import LoginOptions from './LoginOptions'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -64,8 +65,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
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 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[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
if (!activeAccount || !relayPool) {
|
if (!activeAccount || !relayPool) {
|
||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
@@ -97,14 +108,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
// Group non-set bookmarks as before
|
// Group non-set bookmarks by source or flatten based on mode
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
groupingMode === 'flat'
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
: [
|
||||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
{ 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
|
// Add bookmark sets as additional sections
|
||||||
bookmarkSets.forEach(set => {
|
bookmarkSets.forEach(set => {
|
||||||
@@ -153,7 +168,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
{!activeAccount ? (
|
||||||
|
<LoginOptions />
|
||||||
|
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks match this filter.</p>
|
<p>No bookmarks match this filter.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +187,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -222,40 +238,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
style={{ color: friendsColor }}
|
style={{ color: friendsColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="view-mode-right">
|
{activeAccount && (
|
||||||
{onRefresh && (
|
<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
|
<IconButton
|
||||||
icon={faRotate}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||||
onClick={onRefresh}
|
onClick={toggleGroupingMode}
|
||||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel="Refresh bookmarks"
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
<IconButton
|
icon={faList}
|
||||||
icon={faList}
|
onClick={() => onViewModeChange('compact')}
|
||||||
onClick={() => onViewModeChange('compact')}
|
title="Compact list view"
|
||||||
title="Compact list view"
|
ariaLabel="Compact list view"
|
||||||
ariaLabel="Compact list view"
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
icon={faThLarge}
|
||||||
icon={faThLarge}
|
onClick={() => onViewModeChange('cards')}
|
||||||
onClick={() => onViewModeChange('cards')}
|
title="Cards view"
|
||||||
title="Cards view"
|
ariaLabel="Cards view"
|
||||||
ariaLabel="Cards view"
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
icon={faImage}
|
||||||
icon={faImage}
|
onClick={() => onViewModeChange('large')}
|
||||||
onClick={() => onViewModeChange('large')}
|
title="Large preview view"
|
||||||
title="Large preview view"
|
ariaLabel="Large preview view"
|
||||||
ariaLabel="Large preview view"
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<AddBookmarkModal
|
<AddBookmarkModal
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface LargeViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
import Me from './Me'
|
import Me from './Me'
|
||||||
@@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
|||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
onLogout: () => void
|
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 { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -52,7 +62,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/archive' ? 'archive' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
|
location.pathname === '/me/links' ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
@@ -151,8 +162,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
@@ -165,12 +174,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
|
onRefreshBookmarks
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -316,10 +325,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
relayPool ? <Me relayPool={relayPool} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
support={showSupport ? (
|
support={showSupport ? (
|
||||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
|||||||
985
src/components/Debug.tsx
Normal file
985
src/components/Debug.tsx
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
|
||||||
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import type { NostrEvent } from '../services/bookmarkHelpers'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
|
import { useSettings } from '../hooks/useSettings'
|
||||||
|
|
||||||
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
|
|
||||||
|
interface DebugProps {
|
||||||
|
relayPool: RelayPool | null
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
bookmarksLoading: boolean
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Debug: React.FC<DebugProps> = ({
|
||||||
|
relayPool,
|
||||||
|
bookmarks,
|
||||||
|
bookmarksLoading,
|
||||||
|
onRefreshBookmarks,
|
||||||
|
onLogout
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const { settings, saveSettings } = useSettings({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount?.pubkey,
|
||||||
|
accountManager
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
isMobile,
|
||||||
|
isCollapsed,
|
||||||
|
setIsCollapsed,
|
||||||
|
viewMode,
|
||||||
|
setViewMode
|
||||||
|
} = useBookmarksUI({ settings })
|
||||||
|
const [payload, setPayload] = useState<string>(defaultPayload)
|
||||||
|
const [cipher44, setCipher44] = useState<string>('')
|
||||||
|
const [cipher04, setCipher04] = useState<string>('')
|
||||||
|
const [plain44, setPlain44] = useState<string>('')
|
||||||
|
const [plain04, setPlain04] = useState<string>('')
|
||||||
|
const [tEncrypt44, setTEncrypt44] = useState<number | null>(null)
|
||||||
|
const [tEncrypt04, setTEncrypt04] = useState<number | null>(null)
|
||||||
|
const [tDecrypt44, setTDecrypt44] = useState<number | null>(null)
|
||||||
|
const [tDecrypt04, setTDecrypt04] = useState<number | null>(null)
|
||||||
|
const [logs, setLogs] = useState<DebugLogEntry[]>(DebugBus.snapshot())
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState<boolean>(() => localStorage.getItem('debug') === '*')
|
||||||
|
|
||||||
|
// Bunker login state
|
||||||
|
const [bunkerUri, setBunkerUri] = useState<string>('')
|
||||||
|
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
||||||
|
const [bunkerError, setBunkerError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Bookmark loading state
|
||||||
|
const [bookmarkEvents, setBookmarkEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false)
|
||||||
|
const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null)
|
||||||
|
const [tLoadBookmarks, setTLoadBookmarks] = useState<number | null>(null)
|
||||||
|
const [tDecryptBookmarks, setTDecryptBookmarks] = useState<number | null>(null)
|
||||||
|
const [tFirstBookmark, setTFirstBookmark] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Individual event decryption results
|
||||||
|
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
|
||||||
|
|
||||||
|
// Highlight loading state
|
||||||
|
const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author')
|
||||||
|
const [highlightArticleCoord, setHighlightArticleCoord] = useState<string>('')
|
||||||
|
const [highlightUrl, setHighlightUrl] = useState<string>('')
|
||||||
|
const [highlightAuthor, setHighlightAuthor] = useState<string>('')
|
||||||
|
const [isLoadingHighlights, setIsLoadingHighlights] = useState(false)
|
||||||
|
const [highlightEvents, setHighlightEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
||||||
|
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Live timing state
|
||||||
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
loadBookmarks?: { startTime: number }
|
||||||
|
decryptBookmarks?: { startTime: number }
|
||||||
|
loadHighlights?: { startTime: number }
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live timer effect - triggers re-renders for live timing updates
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// Force re-render to update live timing display
|
||||||
|
setLiveTiming(prev => prev)
|
||||||
|
}, 16) // ~60fps for smooth updates
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const signer = useMemo(() => (activeAccount as unknown as { signer?: unknown })?.signer, [activeAccount])
|
||||||
|
const pubkey = (activeAccount as unknown as { pubkey?: string })?.pubkey
|
||||||
|
|
||||||
|
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
|
||||||
|
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
|
||||||
|
|
||||||
|
const getKindName = (kind: number): string => {
|
||||||
|
switch (kind) {
|
||||||
|
case KINDS.ListSimple: return 'Simple List (10003)'
|
||||||
|
case KINDS.ListReplaceable: return 'Replaceable List (30003)'
|
||||||
|
case KINDS.List: return 'List (30001)'
|
||||||
|
case KINDS.WebBookmark: return 'Web Bookmark (39701)'
|
||||||
|
default: return `Kind ${kind}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventSize = (evt: NostrEvent): number => {
|
||||||
|
const content = evt.content || ''
|
||||||
|
const tags = JSON.stringify(evt.tags || [])
|
||||||
|
return content.length + tags.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEncryptedContent = (evt: NostrEvent): boolean => {
|
||||||
|
// Check for NIP-44 encrypted content (detected by Helpers)
|
||||||
|
if (Helpers.hasHiddenContent(evt)) return true
|
||||||
|
|
||||||
|
// Check for NIP-04 encrypted content (base64 with ?iv= suffix)
|
||||||
|
if (evt.content && evt.content.includes('?iv=')) return true
|
||||||
|
|
||||||
|
// Check for encrypted tags
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBookmarkCount = (evt: NostrEvent): { public: number; private: number } => {
|
||||||
|
const publicTags = (evt.tags || []).filter((t: string[]) => t[0] === 'e' || t[0] === 'a')
|
||||||
|
const hasEncrypted = hasEncryptedContent(evt)
|
||||||
|
return {
|
||||||
|
public: publicTags.length,
|
||||||
|
private: hasEncrypted ? 1 : 0 // Can't know exact count until decrypted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventKey = (evt: NostrEvent): string => {
|
||||||
|
if (evt.kind === 30003 || evt.kind === 30001) {
|
||||||
|
// Replaceable: kind:pubkey:dtag
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
} else if (evt.kind === 10003) {
|
||||||
|
// Simple list: kind:pubkey
|
||||||
|
return `${evt.kind}:${evt.pubkey}`
|
||||||
|
}
|
||||||
|
// Web bookmarks: use event id (no deduplication)
|
||||||
|
return evt.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { encrypt: (pubkey: string, message: string) => Promise<string> } })[mode]
|
||||||
|
DebugBus.info('debug', `encrypt start ${mode}`, { pubkey, len: payload.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'encrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const cipher = await api.encrypt(pubkey, payload)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `encrypt done ${mode}`, { len: typeof cipher === 'string' ? cipher.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setCipher44(cipher)
|
||||||
|
else setCipher04(cipher)
|
||||||
|
if (mode === 'nip44') setTEncrypt44(ms)
|
||||||
|
else setTEncrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `encrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDecrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } })[mode]
|
||||||
|
const cipher = mode === 'nip44' ? cipher44 : cipher04
|
||||||
|
if (!cipher) {
|
||||||
|
DebugBus.warn('debug', `no cipher to decrypt for ${mode}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugBus.info('debug', `decrypt start ${mode}`, { len: cipher.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'decrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const plain = await api.decrypt(pubkey, cipher)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `decrypt done ${mode}`, { len: typeof plain === 'string' ? plain.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setPlain44(String(plain))
|
||||||
|
else setPlain04(String(plain))
|
||||||
|
if (mode === 'nip44') setTDecrypt44(ms)
|
||||||
|
else setTDecrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `decrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDebug = () => {
|
||||||
|
const next = !debugEnabled
|
||||||
|
setDebugEnabled(next)
|
||||||
|
if (next) localStorage.setItem('debug', '*')
|
||||||
|
else localStorage.removeItem('debug')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadBookmarks = async () => {
|
||||||
|
if (!relayPool || !activeAccount) {
|
||||||
|
DebugBus.warn('debug', 'Cannot load bookmarks: missing relayPool or activeAccount')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingBookmarks(true)
|
||||||
|
setBookmarkStats(null)
|
||||||
|
setBookmarkEvents([]) // Clear existing events
|
||||||
|
setDecryptedEvents(new Map())
|
||||||
|
setTFirstBookmark(null)
|
||||||
|
DebugBus.info('debug', 'Loading bookmark events...')
|
||||||
|
|
||||||
|
// Start timing
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } }))
|
||||||
|
|
||||||
|
// Import controller at runtime to avoid circular dependencies
|
||||||
|
const { bookmarkController } = await import('../services/bookmarkController')
|
||||||
|
|
||||||
|
// Subscribe to raw events for Debug UI display
|
||||||
|
const unsubscribeRaw = bookmarkController.onRawEvent((evt) => {
|
||||||
|
// Track time to first event
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstBookmark(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event immediately with live deduplication
|
||||||
|
setBookmarkEvents(prev => {
|
||||||
|
const key = getEventKey(evt)
|
||||||
|
const existingIdx = prev.findIndex(e => getEventKey(e) === key)
|
||||||
|
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
const existing = prev[existingIdx]
|
||||||
|
if ((evt.created_at || 0) > (existing.created_at || 0)) {
|
||||||
|
const newEvents = [...prev]
|
||||||
|
newEvents[existingIdx] = evt
|
||||||
|
return newEvents
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, evt]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to decrypt complete events for Debug UI display
|
||||||
|
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
|
||||||
|
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
|
||||||
|
public: publicCount,
|
||||||
|
private: privateCount
|
||||||
|
})
|
||||||
|
setDecryptedEvents(prev => new Map(prev).set(eventId, {
|
||||||
|
public: publicCount,
|
||||||
|
private: privateCount
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the controller (triggers app bookmark population too)
|
||||||
|
bookmarkController.reset()
|
||||||
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
|
||||||
|
// Clean up subscriptions
|
||||||
|
unsubscribeRaw()
|
||||||
|
unsubscribeDecrypt()
|
||||||
|
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
|
||||||
|
setTLoadBookmarks(ms)
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded bookmark events`, { ms })
|
||||||
|
} catch (error) {
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
|
||||||
|
DebugBus.error('debug', 'Failed to load bookmarks', error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBookmarks(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearBookmarks = () => {
|
||||||
|
setBookmarkEvents([])
|
||||||
|
setBookmarkStats(null)
|
||||||
|
setTLoadBookmarks(null)
|
||||||
|
setTDecryptBookmarks(null)
|
||||||
|
setTFirstBookmark(null)
|
||||||
|
setDecryptedEvents(new Map())
|
||||||
|
DebugBus.info('debug', 'Cleared bookmark data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadHighlights = async () => {
|
||||||
|
if (!relayPool) {
|
||||||
|
DebugBus.warn('debug', 'Cannot load highlights: missing relayPool')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to logged-in user's highlights if no specific query provided
|
||||||
|
const getValue = () => {
|
||||||
|
if (highlightMode === 'article') return highlightArticleCoord.trim()
|
||||||
|
if (highlightMode === 'url') return highlightUrl.trim()
|
||||||
|
const authorValue = highlightAuthor.trim()
|
||||||
|
return authorValue || pubkey || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getValue()
|
||||||
|
if (!value) {
|
||||||
|
DebugBus.warn('debug', 'Please provide a value to query or log in')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingHighlights(true)
|
||||||
|
setHighlightEvents([])
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`)
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } }))
|
||||||
|
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
|
||||||
|
// Import highlight services
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { KINDS } = await import('../config/kinds')
|
||||||
|
|
||||||
|
// Build filter based on mode
|
||||||
|
let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] }
|
||||||
|
if (highlightMode === 'article') {
|
||||||
|
filter = { kinds: [KINDS.Highlights], '#a': [value] }
|
||||||
|
} else if (highlightMode === 'url') {
|
||||||
|
filter = { kinds: [KINDS.Highlights], '#r': [value] }
|
||||||
|
} else {
|
||||||
|
filter = { kinds: [KINDS.Highlights], authors: [value] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstHighlight(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightEvents(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadHighlights(elapsed)
|
||||||
|
setLiveTiming(prev => {
|
||||||
|
const { loadHighlights, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHighlights(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearHighlights = () => {
|
||||||
|
setHighlightEvents([])
|
||||||
|
setTLoadHighlights(null)
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', 'Cleared highlight data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setBunkerError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setBunkerError('Invalid bunker URI. Must start with bunker://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsBunkerLoading(true)
|
||||||
|
setBunkerError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setBunkerError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
setBunkerError(errorMessage)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsBunkerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeBox = ({ value }: { value: string }) => (
|
||||||
|
<div className="h-20 overflow-y-auto font-mono text-xs leading-relaxed p-2 bg-gray-100 dark:bg-gray-800 rounded whitespace-pre-wrap break-all">
|
||||||
|
{value || '—'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLiveTiming = (mode: 'nip44' | 'nip04', type: 'encrypt' | 'decrypt') => {
|
||||||
|
const timing = liveTiming[mode]
|
||||||
|
if (timing && timing.type === type) {
|
||||||
|
const elapsed = Math.round(performance.now() - timing.startTime)
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => {
|
||||||
|
const timing = liveTiming[operation]
|
||||||
|
if (timing) {
|
||||||
|
const elapsed = Math.round(performance.now() - timing.startTime)
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stat = ({ label, value, mode, type, bookmarkOp }: {
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null;
|
||||||
|
mode?: 'nip44' | 'nip04';
|
||||||
|
type?: 'encrypt' | 'decrypt';
|
||||||
|
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights';
|
||||||
|
}) => {
|
||||||
|
const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null)
|
||||||
|
const isLive = !!liveValue
|
||||||
|
|
||||||
|
let displayValue: string
|
||||||
|
if (isLive) {
|
||||||
|
displayValue = ''
|
||||||
|
} else if (value !== null && value !== undefined) {
|
||||||
|
displayValue = `${value}ms`
|
||||||
|
} else {
|
||||||
|
displayValue = '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="badge" style={{ marginRight: 8 }}>
|
||||||
|
<FontAwesomeIcon icon={faClock} style={{ marginRight: 4, fontSize: '0.8em' }} />
|
||||||
|
{label}: {isLive ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" style={{ fontSize: '0.8em' }} />
|
||||||
|
) : (
|
||||||
|
displayValue
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugContent = (
|
||||||
|
<div className="settings-view">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h2>Debug</h2>
|
||||||
|
<div className="settings-header-actions">
|
||||||
|
<span className="opacity-70">Active pubkey:</span> <code className="text-sm">{pubkey || 'none'}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
|
||||||
|
{/* Account Connection Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">
|
||||||
|
{activeAccount
|
||||||
|
? activeAccount.type === 'extension'
|
||||||
|
? 'Browser Extension'
|
||||||
|
: activeAccount.type === 'nostr-connect'
|
||||||
|
? 'Bunker Connection'
|
||||||
|
: 'Account Connection'
|
||||||
|
: 'Account Connection'}
|
||||||
|
</h3>
|
||||||
|
{!activeAccount ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isBunkerLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isBunkerLoading || !bunkerUri.trim()}
|
||||||
|
>
|
||||||
|
{isBunkerLoading ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bunkerError && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 mb-2">{bunkerError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70">
|
||||||
|
{activeAccount.type === 'extension'
|
||||||
|
? 'Connected via browser extension'
|
||||||
|
: activeAccount.type === 'nostr-connect'
|
||||||
|
? 'Connected to bunker'
|
||||||
|
: 'Connected'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-mono">{pubkey}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'rgb(220 38 38)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgb(220 38 38)',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgb(185 28 28)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'rgb(220 38 38)'}
|
||||||
|
onClick={() => accountManager.removeAccount(activeAccount)}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encryption Tools Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Encryption Tools</h3>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Payload</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
|
||||||
|
value={payload}
|
||||||
|
onChange={e => setPayload(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-3 justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setPayload(defaultPayload)}>Reset</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => { setCipher44(''); setCipher04(''); setPlain44(''); setPlain04(''); setTEncrypt44(null); setTEncrypt04(null); setTDecrypt44(null); setTDecrypt04(null) }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip44')} disabled={!hasNip44}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip44')} disabled={!cipher44}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher44} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain44} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip04')} disabled={!hasNip04}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip04')} disabled={!cipher04}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher04} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain04} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Timing Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Performance Timing</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Encryption and decryption operation durations</div>
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt44} mode="nip44" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt44} mode="nip44" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt04} mode="nip04" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt04} mode="nip04" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookmark Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Bookmark Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test bookmark loading with auto-decryption (kinds: 10003, 30003, 30001, 39701)</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadBookmarks}
|
||||||
|
disabled={isLoadingBookmarks || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingBookmarks ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Bookmarks'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearBookmarks}
|
||||||
|
disabled={bookmarkEvents.length === 0 && !bookmarkStats}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
|
||||||
|
<Stat label="first event" value={tFirstBookmark} />
|
||||||
|
<Stat label="decrypt" value={tDecryptBookmarks} bookmarkOp="decryptBookmarks" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookmarkStats && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Decrypted Bookmarks:</div>
|
||||||
|
<div className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div>Public: {bookmarkStats.public}</div>
|
||||||
|
<div>Private: {bookmarkStats.private}</div>
|
||||||
|
<div className="font-semibold mt-1">Total: {bookmarkStats.public + bookmarkStats.private}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bookmarkEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Events ({bookmarkEvents.length}):</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bookmarkEvents.map((evt, idx) => {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||||
|
const titleTag = evt.tags?.find((t: string[]) => t[0] === 'title')?.[1]
|
||||||
|
const size = getEventSize(evt)
|
||||||
|
const counts = getBookmarkCount(evt)
|
||||||
|
const hasEncrypted = hasEncryptedContent(evt)
|
||||||
|
const decryptResult = decryptedEvents.get(evt.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">{getKindName(evt.kind)}</div>
|
||||||
|
{dTag && <div className="opacity-70">d-tag: {dTag}</div>}
|
||||||
|
{titleTag && <div className="opacity-70">title: {titleTag}</div>}
|
||||||
|
<div className="mt-1">
|
||||||
|
<div>Size: {formatBytes(size)}</div>
|
||||||
|
<div>Public: {counts.public}</div>
|
||||||
|
{hasEncrypted && <div>🔒 Has encrypted content</div>}
|
||||||
|
</div>
|
||||||
|
{decryptResult && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-80">
|
||||||
|
<div>✓ Decrypted: {decryptResult.public} public, {decryptResult.private} private</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Highlight Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Query Mode:</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'article'}
|
||||||
|
onChange={() => setHighlightMode('article')}
|
||||||
|
/>
|
||||||
|
<span>Article (#a)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'url'}
|
||||||
|
onChange={() => setHighlightMode('url')}
|
||||||
|
/>
|
||||||
|
<span>URL (#r)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'author'}
|
||||||
|
onChange={() => setHighlightMode('author')}
|
||||||
|
/>
|
||||||
|
<span>Author</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
{highlightMode === 'article' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="30023:pubkey:identifier"
|
||||||
|
value={highlightArticleCoord}
|
||||||
|
onChange={(e) => setHighlightArticleCoord(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{highlightMode === 'url' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
value={highlightUrl}
|
||||||
|
onChange={(e) => setHighlightUrl(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{highlightMode === 'author' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder={pubkey ? `${pubkey.slice(0, 16)}... (logged-in user)` : 'pubkey (hex)'}
|
||||||
|
value={highlightAuthor}
|
||||||
|
onChange={(e) => setHighlightAuthor(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadHighlights}
|
||||||
|
disabled={isLoadingHighlights || !relayPool}
|
||||||
|
>
|
||||||
|
{isLoadingHighlights ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Highlights'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearHighlights}
|
||||||
|
disabled={highlightEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadHighlights} bookmarkOp="loadHighlights" />
|
||||||
|
<Stat label="first event" value={tFirstHighlight} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{highlightEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Highlights ({highlightEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{highlightEvents.map((evt, idx) => {
|
||||||
|
const content = evt.content || ''
|
||||||
|
const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content
|
||||||
|
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||||
|
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||||
|
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||||
|
const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Highlight #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="font-semibold text-[11px]">Content:</div>
|
||||||
|
<div className="italic">"{shortContent}"</div>
|
||||||
|
</div>
|
||||||
|
{contextTag && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-70">
|
||||||
|
<div>Context: {contextTag.substring(0, 60)}...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aTag && <div className="mt-1 text-[11px] opacity-70">#a: {aTag}</div>}
|
||||||
|
{rTag && <div className="mt-1 text-[11px] opacity-70">#r: {rTag}</div>}
|
||||||
|
{eTag && <div className="mt-1 text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug Logs Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Debug Logs</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Recent bunker logs:</div>
|
||||||
|
<div className="max-h-192 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-sm opacity-50 italic">No logs yet</div>
|
||||||
|
) : (
|
||||||
|
logs.slice(-200).map((l, i) => (
|
||||||
|
<div key={i} className="mb-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<span className="opacity-70">[{new Date(l.ts).toLocaleTimeString()}]</span> <span className="font-semibold">{l.level.toUpperCase()}</span> {l.source}: {l.message}
|
||||||
|
{l.data !== undefined && (
|
||||||
|
<span className="opacity-70"> — {typeof l.data === 'string' ? l.data : JSON.stringify(l.data)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={toggleDebug}
|
||||||
|
className="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Show all applesauce debug logs</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setLogs([])}>Clear logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreePaneLayout
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
isHighlightsCollapsed={true}
|
||||||
|
isSidebarOpen={false}
|
||||||
|
showSettings={false}
|
||||||
|
showSupport={true}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
viewMode={viewMode}
|
||||||
|
isRefreshing={false}
|
||||||
|
lastFetchTime={null}
|
||||||
|
onToggleSidebar={isMobile ? () => {} : () => setIsCollapsed(!isCollapsed)}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
onOpenSettings={() => navigate('/settings')}
|
||||||
|
onRefresh={onRefreshBookmarks}
|
||||||
|
relayPool={relayPool}
|
||||||
|
eventStore={eventStore}
|
||||||
|
readerLoading={false}
|
||||||
|
readerContent={undefined}
|
||||||
|
selectedUrl={undefined}
|
||||||
|
settings={settings}
|
||||||
|
onSaveSettings={saveSettings}
|
||||||
|
onCloseSettings={() => navigate('/')}
|
||||||
|
classifiedHighlights={[]}
|
||||||
|
showHighlights={false}
|
||||||
|
selectedHighlightId={undefined}
|
||||||
|
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||||
|
onHighlightClick={() => {}}
|
||||||
|
onTextSelection={() => {}}
|
||||||
|
onClearSelection={() => {}}
|
||||||
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
|
followedPubkeys={new Set()}
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
currentArticle={null}
|
||||||
|
highlights={[]}
|
||||||
|
highlightsLoading={false}
|
||||||
|
onToggleHighlightsPanel={() => {}}
|
||||||
|
onSelectUrl={() => {}}
|
||||||
|
onToggleHighlights={() => {}}
|
||||||
|
onRefreshHighlights={() => {}}
|
||||||
|
onHighlightVisibilityChange={() => {}}
|
||||||
|
highlightButtonRef={{ current: null }}
|
||||||
|
onCreateHighlight={() => {}}
|
||||||
|
hasActiveAccount={!!activeAccount}
|
||||||
|
toastMessage={undefined}
|
||||||
|
toastType={undefined}
|
||||||
|
onClearToast={() => {}}
|
||||||
|
support={debugContent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Debug
|
||||||
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,16 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -19,43 +19,73 @@ import BlogPostCard from './BlogPostCard'
|
|||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
activeTab?: TabType
|
activeTab?: TabType
|
||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
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,
|
||||||
|
activeTab: propActiveTab,
|
||||||
|
pubkey: propPubkey,
|
||||||
|
bookmarks
|
||||||
|
}) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const eventStore = Hooks.useEventStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
// Use provided pubkey or fall back to active account
|
// Use provided pubkey or fall back to active account
|
||||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
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)
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,131 +94,211 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
if (!viewingPubkey) {
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
setLoading(false)
|
: 'all'
|
||||||
return
|
setReadingProgressFilter(filterFromUrl)
|
||||||
|
}, [urlFilter])
|
||||||
|
|
||||||
|
// Handler to change reading progress filter and update URL
|
||||||
|
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
|
||||||
|
setReadingProgressFilter(filter)
|
||||||
|
if (activeTab === 'reads') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/me/reads', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/me/reads/${filter}`, { replace: true })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Tab-specific loading functions
|
||||||
setLoading(true)
|
const loadHighlightsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Seed from cache if available to avoid empty flash (own profile only)
|
// Only show loading skeleton if tab hasn't been loaded yet
|
||||||
if (isOwnProfile) {
|
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||||
const cached = getCachedMeData(viewingPubkey)
|
|
||||||
if (cached) {
|
|
||||||
setHighlights(cached.highlights)
|
|
||||||
setBookmarks(cached.bookmarks)
|
|
||||||
setReadArticles(cached.readArticles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch highlights and writings (public data)
|
try {
|
||||||
const [userHighlights, userWritings] = await Promise.all([
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
fetchHighlights(relayPool, viewingPubkey),
|
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
setHighlights(userHighlights)
|
||||||
])
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setHighlights(userHighlights)
|
const loadWritingsTab = async () => {
|
||||||
setWritings(userWritings)
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Only fetch private data for own profile
|
const hasBeenLoaded = loadedTabs.has('writings')
|
||||||
if (isOwnProfile && activeAccount) {
|
|
||||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
|
||||||
setReadArticles(userReadArticles)
|
|
||||||
|
|
||||||
// Fetch bookmarks using callback pattern
|
try {
|
||||||
let fetchedBookmarks: Bookmark[] = []
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
try {
|
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
setWritings(userWritings)
|
||||||
fetchedBookmarks = newBookmarks
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
setBookmarks(newBookmarks)
|
} catch (err) {
|
||||||
})
|
console.error('Failed to load writings:', err)
|
||||||
} catch (err) {
|
} finally {
|
||||||
console.warn('Failed to load bookmarks:', err)
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache with all fetched data
|
const newMap = new Map(prevMap)
|
||||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
const merged = mergeReadItem(newMap, item)
|
||||||
} else {
|
if (merged) {
|
||||||
setBookmarks([])
|
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||||
setReadArticles([])
|
// Update reads array after map is updated
|
||||||
}
|
setReads(Array.from(newMap.values()))
|
||||||
} catch (err) {
|
return newMap
|
||||||
console.error('Failed to load data:', err)
|
}
|
||||||
// No blocking error - user can pull-to-refresh
|
return prevMap
|
||||||
} finally {
|
})
|
||||||
setLoading(false)
|
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reads:', err)
|
||||||
|
if (!hasBeenLoaded) 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()
|
// Load data for active tab (refresh in background if already loaded)
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
// Load reading positions for read articles (only for own profile)
|
loadHighlightsTab()
|
||||||
useEffect(() => {
|
break
|
||||||
const loadPositions = async () => {
|
case 'writings':
|
||||||
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
loadWritingsTab()
|
||||||
console.log('🔍 [Archive] Skipping position load:', {
|
break
|
||||||
isOwnProfile,
|
case 'reading-list':
|
||||||
hasAccount: !!activeAccount,
|
loadReadingListTab()
|
||||||
hasRelayPool: !!relayPool,
|
break
|
||||||
hasEventStore: !!eventStore,
|
case 'reads':
|
||||||
articlesCount: readArticles.length
|
loadReadsTab()
|
||||||
})
|
break
|
||||||
return
|
case 'links':
|
||||||
}
|
loadLinksTab()
|
||||||
|
break
|
||||||
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
|
||||||
|
|
||||||
const positions = new Map<string, number>()
|
|
||||||
|
|
||||||
// Load positions for all read articles
|
|
||||||
await Promise.all(
|
|
||||||
readArticles.map(async (post) => {
|
|
||||||
try {
|
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind: 30023,
|
|
||||||
pubkey: post.author,
|
|
||||||
identifier: dTag
|
|
||||||
})
|
|
||||||
const articleUrl = `nostr:${naddr}`
|
|
||||||
const identifier = generateArticleIdentifier(articleUrl)
|
|
||||||
|
|
||||||
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
|
||||||
|
|
||||||
const savedPosition = await loadReadingPosition(
|
|
||||||
relayPool,
|
|
||||||
eventStore,
|
|
||||||
activeAccount.pubkey,
|
|
||||||
identifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0) {
|
|
||||||
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
|
||||||
positions.set(post.event.id, savedPosition.position)
|
|
||||||
} else {
|
|
||||||
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
|
||||||
setReadingPositions(positions)
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
|
|
||||||
loadPositions()
|
|
||||||
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
|
// Just trigger refresh - loaders will merge new data
|
||||||
setRefreshTrigger(prev => prev + 1)
|
setRefreshTrigger(prev => prev + 1)
|
||||||
},
|
},
|
||||||
maximumPullLength: 240,
|
maximumPullLength: 240,
|
||||||
@@ -217,6 +327,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
|
if (item.type === 'article') {
|
||||||
|
// ID is already in naddr format
|
||||||
|
return `/a/${item.id}`
|
||||||
|
} else if (item.url) {
|
||||||
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||||
|
if (item.event) {
|
||||||
|
return {
|
||||||
|
event: item.event,
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || item.event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock event for external URLs
|
||||||
|
const mockEvent = {
|
||||||
|
id: item.id,
|
||||||
|
pubkey: item.author || '',
|
||||||
|
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [] as string[][],
|
||||||
|
content: item.title || item.url || 'Untitled',
|
||||||
|
sig: ''
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||||
|
title: item.title || item.url || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
// For kind:30023 articles, navigate to the article route
|
||||||
@@ -245,38 +398,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Apply archive filter
|
// Apply reading progress filter
|
||||||
const filteredReadArticles = readArticles.filter(post => {
|
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||||
const position = readingPositions.get(post.event.id)
|
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||||
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
switch (archiveFilter) {
|
groupingMode === 'flat'
|
||||||
case 'to-read':
|
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||||
// No position or 0% progress
|
: [
|
||||||
return !position || position === 0
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
case 'reading':
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
// Has some progress but not completed (0 < position < 1)
|
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
||||||
return position !== undefined && position > 0 && position < 0.95
|
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
||||||
case 'completed':
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
// 95% or more read (we consider 95%+ as completed)
|
]
|
||||||
return position !== undefined && position >= 0.95
|
|
||||||
case 'marked':
|
|
||||||
// Manually marked as read (in archive but no reading position data)
|
|
||||||
// These are articles that were marked via the emoji reaction
|
|
||||||
return !position || position === 0
|
|
||||||
case 'all':
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
|
||||||
{ 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: 'Legacy Bookmarks', items: groups.amethyst }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -291,9 +428,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 ? (
|
return highlights.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No highlights yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list me-highlights-list">
|
<div className="highlights-list me-highlights-list">
|
||||||
@@ -320,9 +457,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return allIndividualBookmarks.length === 0 ? (
|
return allIndividualBookmarks.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No bookmarks yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -361,6 +498,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
borderTop: '1px solid var(--border-color)'
|
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
|
<IconButton
|
||||||
icon={faList}
|
icon={faList}
|
||||||
onClick={() => setViewMode('compact')}
|
onClick={() => setViewMode('compact')}
|
||||||
@@ -386,8 +530,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'archive':
|
case 'reads':
|
||||||
if (showSkeletons) {
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('reads')) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -396,32 +541,84 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
// Show empty state if loaded but no reads
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||||
</div>
|
return (
|
||||||
) : (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles read yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show reads with filters
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{readArticles.length > 0 && (
|
<ReadingProgressFilters
|
||||||
<ArchiveFilters
|
selectedFilter={readingProgressFilter}
|
||||||
selectedFilter={archiveFilter}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
onFilterChange={setArchiveFilter}
|
/>
|
||||||
/>
|
{filteredReads.length === 0 ? (
|
||||||
)}
|
|
||||||
{filteredReadArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles match this filter.
|
No articles match this filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{filteredReadArticles.map((post) => (
|
{filteredReads.map((item) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={item.id}
|
||||||
post={post}
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
href={getPostUrl(post)}
|
href={getReadItemUrl(item)}
|
||||||
readingProgress={readingPositions.get(post.event.id)}
|
readingProgress={item.readingProgress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if loaded but no links
|
||||||
|
if (links.length === 0 && loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links with reading progress yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show links with filters
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadingProgressFilters
|
||||||
|
selectedFilter={readingProgressFilter}
|
||||||
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
|
/>
|
||||||
|
{filteredLinks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -437,9 +634,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 ? (
|
return writings.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -487,12 +684,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
data-tab="archive"
|
data-tab="reads"
|
||||||
onClick={() => navigate('/me/archive')}
|
onClick={() => navigate('/me/reads')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Archive</span>
|
<span className="tab-label">Reads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
|
data-tab="links"
|
||||||
|
onClick={() => navigate('/me/links')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLink} />
|
||||||
|
<span className="tab-label">Links</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
47
src/components/ReadingProgressFilters.tsx
Normal file
47
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
const leftOffset = isSidebarCollapsed
|
const leftOffset = isSidebarCollapsed
|
||||||
? 'var(--sidebar-collapsed-width)'
|
? 'var(--sidebar-collapsed-width)'
|
||||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
style={{ backgroundColor: 'var(--color-border)' }}
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
isComplete
|
|
||||||
? 'bg-green-500'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${clampedProgress}%`,
|
width: `${clampedProgress}%`,
|
||||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
backgroundColor: barColorStyle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
isComplete ? 'text-green-500' : ''
|
isComplete ? 'text-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
console.debug('[RouteDebug]', info)
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import ZapSettings from './Settings/ZapSettings'
|
|||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -167,6 +168,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
|
||||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
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 = () => {
|
const getProfileImage = () => {
|
||||||
return profile?.picture || null
|
return profile?.picture || null
|
||||||
}
|
}
|
||||||
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar-header-right">
|
<div className="sidebar-header-right">
|
||||||
<div
|
{activeAccount && (
|
||||||
className="profile-avatar"
|
<div
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
className="profile-avatar"
|
||||||
onClick={
|
title={getUserDisplayName()}
|
||||||
activeAccount
|
onClick={() => navigate('/me')}
|
||||||
? () => navigate('/me')
|
style={{ cursor: 'pointer' }}
|
||||||
: (isConnecting ? () => {} : handleLogin)
|
>
|
||||||
}
|
{profileImage ? (
|
||||||
style={{ cursor: 'pointer' }}
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
>
|
) : (
|
||||||
{profileImage ? (
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
)}
|
||||||
) : (
|
</div>
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faHome}
|
icon={faHome}
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
{activeAccount ? (
|
{activeAccount && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Logout"
|
ariaLabel="Logout"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightToBracket}
|
|
||||||
onClick={isConnecting ? () => {} : handleLogin}
|
|
||||||
title={isConnecting ? "Connecting..." : "Login"}
|
|
||||||
ariaLabel="Login"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
src/components/VersionFooter.tsx
Normal file
32
src/components/VersionFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const VersionFooter: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>
|
||||||
|
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
|
||||||
|
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·{' '}
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionFooter
|
||||||
15
src/config/kinds.ts
Normal file
15
src/config/kinds.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-?? user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data (reading positions)
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
export const RELAYS = [
|
export const RELAYS = [
|
||||||
'ws://localhost:10547',
|
'ws://localhost:10547',
|
||||||
'ws://localhost:4869',
|
'ws://localhost:4869',
|
||||||
|
'wss://relay.nsec.app',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.nostr.band',
|
'wss://relay.nostr.band',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { fetchContacts } from '../services/contactService'
|
import { fetchContacts } from '../services/contactService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -11,26 +10,26 @@ import { UserSettings } from '../services/settingsService'
|
|||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
accountManager: AccountManager
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
externalUrl?: string
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||||
|
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksData = ({
|
export const useBookmarksData = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
}: UseBookmarksDataParams) => {
|
onRefreshBookmarks
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
@@ -43,21 +42,6 @@ export const useBookmarksData = ({
|
|||||||
setFollowedPubkeys(contacts)
|
setFollowedPubkeys(contacts)
|
||||||
}, [relayPool, activeAccount])
|
}, [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)
|
|
||||||
}
|
|
||||||
}, [relayPool, activeAccount, accountManager, settings])
|
|
||||||
|
|
||||||
const handleFetchHighlights = useCallback(async () => {
|
const handleFetchHighlights = useCallback(async () => {
|
||||||
if (!relayPool) return
|
if (!relayPool) return
|
||||||
|
|
||||||
@@ -96,7 +80,7 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await handleFetchBookmarks()
|
await onRefreshBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
await handleFetchContacts()
|
||||||
setLastFetchTime(Date.now())
|
setLastFetchTime(Date.now())
|
||||||
@@ -105,16 +89,9 @@ export const useBookmarksData = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
// Load initial data (avoid clearing on route-only changes)
|
// Fetch highlights/contacts independently
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
||||||
@@ -126,8 +103,6 @@ export const useBookmarksData = ({
|
|||||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
@@ -135,7 +110,6 @@ export const useBookmarksData = ({
|
|||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
|
|||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
}: UseHighlightCreationParams) => {
|
}: UseHighlightCreationParams) => {
|
||||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const handleTextSelection = useCallback((text: string) => {
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
highlightButtonRef.current?.updateSelection(text)
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
@@ -92,10 +94,19 @@ export const useHighlightCreation = ({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
showToast('Reconnect bunker and approve signing permissions to create highlights')
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to create highlight: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@import './styles/components/me.css';
|
@import './styles/components/me.css';
|
||||||
@import './styles/components/pull-to-refresh.css';
|
@import './styles/components/pull-to-refresh.css';
|
||||||
@import './styles/components/skeletons.css';
|
@import './styles/components/skeletons.css';
|
||||||
|
@import './styles/components/login.css';
|
||||||
@import './styles/utils/animations.css';
|
@import './styles/utils/animations.css';
|
||||||
@import './styles/utils/utilities.css';
|
@import './styles/utils/utilities.css';
|
||||||
@import './styles/utils/legacy.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 HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
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(
|
export async function collectBookmarksFromEvents(
|
||||||
bookmarkListEvents: NostrEvent[],
|
bookmarkListEvents: NostrEvent[],
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
@@ -23,21 +113,23 @@ export async function collectBookmarksFromEvents(
|
|||||||
allTags: string[][]
|
allTags: string[][]
|
||||||
}> {
|
}> {
|
||||||
const publicItemsAll: IndividualBookmark[] = []
|
const publicItemsAll: IndividualBookmark[] = []
|
||||||
const privateItemsAll: IndividualBookmark[] = []
|
|
||||||
let newestCreatedAt = 0
|
let newestCreatedAt = 0
|
||||||
let latestContent = ''
|
let latestContent = ''
|
||||||
let allTags: string[][] = []
|
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) {
|
for (const evt of bookmarkListEvents) {
|
||||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
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 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 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 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 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
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
@@ -73,69 +165,22 @@ export async function collectBookmarksFromEvents(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
// Schedule decrypt if needed
|
||||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||||
try {
|
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
const needsDecrypt = signerCandidate && (
|
||||||
} catch {
|
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||||
try {
|
Helpers.hasHiddenContent(evt) ||
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
hasNip04Content
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (needsDecrypt) {
|
||||||
|
decryptJobs.push({ evt, metadata })
|
||||||
|
} else {
|
||||||
|
// Check for already-unlocked hidden bookmarks
|
||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
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 { RelayPool } from 'applesauce-relay'
|
||||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the contact list (follows) for a specific user
|
* Fetches the contact list (follows) for a specific user
|
||||||
@@ -24,7 +23,6 @@ export const fetchContacts = async (
|
|||||||
{ kinds: [3], authors: [pubkey] },
|
{ kinds: [3], authors: [pubkey] },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||||
// Stream partials as we see any contact list
|
// Stream partials as we see any contact list
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
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 { NostrEvent } from 'nostr-tools'
|
||||||
import { Filter } from 'nostr-tools/filter'
|
import { Filter } from 'nostr-tools/filter'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
export interface QueryOptions {
|
export interface QueryOptions {
|
||||||
relayUrls?: string[]
|
relayUrls?: string[]
|
||||||
localTimeoutMs?: number
|
|
||||||
remoteTimeoutMs?: number
|
|
||||||
onEvent?: (event: NostrEvent) => void
|
onEvent?: (event: NostrEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified local-first query helper with optional streaming callback.
|
* 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(
|
export async function queryEvents(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
|||||||
): Promise<NostrEvent[]> {
|
): Promise<NostrEvent[]> {
|
||||||
const {
|
const {
|
||||||
relayUrls,
|
relayUrls,
|
||||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
|
||||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent
|
onEvent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(localTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(remoteTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
|
|
||||||
await queryEvents(
|
await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create EventFactory with the account as signer
|
// Create EventFactory with the account as signer
|
||||||
const factory = new EventFactory({ signer: account })
|
console.log("[bunker] Creating EventFactory with signer:", { signerType: account.signer?.constructor?.name })
|
||||||
|
const factory = new EventFactory({ signer: account.signer })
|
||||||
|
|
||||||
let blueprintSource: NostrEvent | AddressPointer | string
|
let blueprintSource: NostrEvent | AddressPointer | string
|
||||||
let context: string | undefined
|
let context: string | undefined
|
||||||
@@ -116,7 +117,9 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
|
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
|
||||||
const signedEvent = await factory.sign(highlightEvent)
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
console.log('[bunker] ✅ Highlight signed successfully!', { id: signedEvent.id.slice(0, 8) })
|
||||||
|
|
||||||
// Use unified write service to store and publish
|
// Use unified write service to store and publish
|
||||||
await publishEvent(relayPool, eventStore, signedEvent)
|
await publishEvent(relayPool, eventStore, signedEvent)
|
||||||
|
|||||||
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,64 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { KINDS } from '../../config/kinds'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false
|
||||||
): Promise<Highlight[]> => {
|
): 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 {
|
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 seenIds = new Set<string>()
|
||||||
const local$ = localRelays.length > 0
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
? relayPool
|
relayPool,
|
||||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
.pipe(
|
{
|
||||||
onlyEvents(),
|
onEvent: (event: NostrEvent) => {
|
||||||
tap((event: NostrEvent) => {
|
if (seenIds.has(event.id)) return
|
||||||
if (!seenIds.has(event.id)) {
|
seenIds.add(event.id)
|
||||||
seenIds.add(event.id)
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
}
|
||||||
}
|
}
|
||||||
}),
|
)
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
try {
|
||||||
const remote$ = remoteRelays.length > 0
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
? relayPool
|
} catch (err) {
|
||||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
.pipe(
|
}
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
if (!seenIds.has(event.id)) {
|
|
||||||
seenIds.add(event.id)
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
|
||||||
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,68 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForArticle = async (
|
export const fetchHighlightsForArticle = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
articleCoordinate: string,
|
articleCoordinate: string,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false
|
||||||
): Promise<Highlight[]> => {
|
): 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 {
|
try {
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
const onEvent = (event: NostrEvent) => {
|
||||||
if (seenIds.has(event.id)) return null
|
if (seenIds.has(event.id)) return
|
||||||
seenIds.add(event.id)
|
seenIds.add(event.id)
|
||||||
return eventToHighlight(event)
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
// Query for both #a and #e tags in parallel
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
const [aTagEvents, eTagEvents] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
|
||||||
const aLocal$ = localRelays.length > 0
|
eventId
|
||||||
? relayPool
|
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
|
||||||
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
: Promise.resolve([] as NostrEvent[])
|
||||||
.pipe(
|
])
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
|
||||||
|
|
||||||
let eTagEvents: NostrEvent[] = []
|
|
||||||
if (eventId) {
|
|
||||||
const eLocal$ = localRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const eRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,46 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForUrl = async (
|
export const fetchHighlightsForUrl = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
url: string,
|
url: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
const seenIds = new Set<string>()
|
// Check cache first unless force refresh
|
||||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
if (!force) {
|
||||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
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 {
|
try {
|
||||||
const local$ = localRelaysUrl.length > 0
|
const seenIds = new Set<string>()
|
||||||
? relayPool
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
relayPool,
|
||||||
.pipe(
|
{ kinds: [KINDS.Highlights], '#r': [url] },
|
||||||
onlyEvents(),
|
{
|
||||||
tap((event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
seenIds.add(event.id)
|
if (seenIds.has(event.id)) return
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
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()))
|
|
||||||
|
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
||||||
|
|
||||||
@@ -58,11 +53,15 @@ export const fetchHighlightsForUrl = async (
|
|||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching highlights for URL:', err)
|
console.error('Error fetching highlights for URL:', err)
|
||||||
// Return highlights that were already streamed via callback
|
|
||||||
// Don't return empty array as that would clear already-displayed highlights
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
|
|||||||
try {
|
try {
|
||||||
// Fetch kind:7 and kind:17 reactions in parallel
|
// Fetch kind:7 and kind:17 reactions in parallel
|
||||||
const [kind7Events, kind17Events] = await Promise.all([
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||||
])
|
])
|
||||||
|
|
||||||
const readArticles: ReadArticle[] = []
|
const readArticles: ReadArticle[] = []
|
||||||
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
// Filter to only nostr-native articles (kind 30023)
|
// Filter to only nostr-native articles (kind 30023)
|
||||||
const nostrArticles = readArticles.filter(
|
const nostrArticles = readArticles.filter(
|
||||||
article => article.eventKind === 30023 && article.eventId
|
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (nostrArticles.length === 0) {
|
if (nostrArticles.length === 0) {
|
||||||
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
const articleEvents = await queryEvents(
|
const articleEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], ids: eventIds },
|
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||||
{ relayUrls: RELAYS }
|
{ relayUrls: RELAYS }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
90
src/services/linksService.ts
Normal file
90
src/services/linksService.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches external URL links with reading progress from:
|
||||||
|
* - URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchLinks(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(linksMap, item)) {
|
||||||
|
onItem(linksMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
linksMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Links] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit external items
|
||||||
|
processReadingPositions(readingPositionEvents, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit external items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for external URLs only with reading progress
|
||||||
|
const links = Array.from(linksMap.values())
|
||||||
|
.filter(item => {
|
||||||
|
// Only external URLs
|
||||||
|
if (item.type !== 'external') return false
|
||||||
|
|
||||||
|
// Only include if there's reading progress or marked as read
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
return hasProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply common validation and sorting
|
||||||
|
const validLinks = filterValidItems(links)
|
||||||
|
const sortedLinks = sortByReadingActivity(validLinks)
|
||||||
|
|
||||||
|
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
||||||
|
return sortedLinks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch links:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
readArticles: BlogPostPreview[]
|
readArticles: BlogPostPreview[]
|
||||||
|
reads?: ReadItem[]
|
||||||
|
links?: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/services/nostrConnect.ts
Normal file
26
src/services/nostrConnect.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default NIP-46 permissions for bunker connections
|
||||||
|
* These permissions cover all event kinds and encryption/decryption operations Boris needs
|
||||||
|
*/
|
||||||
|
export function getDefaultBunkerPermissions(): string[] {
|
||||||
|
return [
|
||||||
|
// Signing permissions for event kinds we create
|
||||||
|
...NostrConnectSigner.buildSigningPermissions([
|
||||||
|
0, // Profile metadata
|
||||||
|
5, // Event deletion
|
||||||
|
7, // Reactions (nostr events)
|
||||||
|
17, // Reactions (websites)
|
||||||
|
9802, // Highlights
|
||||||
|
30078, // Settings & reading positions
|
||||||
|
39701, // Web bookmarks
|
||||||
|
]),
|
||||||
|
// Encryption/decryption for hidden content
|
||||||
|
'nip04_encrypt',
|
||||||
|
'nip04_decrypt',
|
||||||
|
'nip44_encrypt',
|
||||||
|
'nip44_decrypt',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
147
src/services/readingDataProcessor.ts
Normal file
147
src/services/readingDataProcessor.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
interface ReadArticle {
|
||||||
|
id: string
|
||||||
|
url?: string
|
||||||
|
eventId?: string
|
||||||
|
eventKind?: number
|
||||||
|
markedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes reading position events into ReadItems
|
||||||
|
*/
|
||||||
|
export function processReadingPositions(
|
||||||
|
events: NostrEvent[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
||||||
|
|
||||||
|
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const positionData = JSON.parse(event.content)
|
||||||
|
const position = positionData.position
|
||||||
|
const timestamp = positionData.timestamp
|
||||||
|
|
||||||
|
let itemId: string
|
||||||
|
let itemUrl: string | undefined
|
||||||
|
let itemType: 'article' | 'external' = 'external'
|
||||||
|
|
||||||
|
// Check if it's a nostr article (naddr format)
|
||||||
|
if (identifier.startsWith('naddr1')) {
|
||||||
|
itemId = identifier
|
||||||
|
itemType = 'article'
|
||||||
|
} else {
|
||||||
|
// It's a base64url-encoded URL
|
||||||
|
try {
|
||||||
|
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
itemId = itemUrl
|
||||||
|
itemType = 'external'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode URL identifier:', identifier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update the item
|
||||||
|
const existing = readsMap.get(itemId)
|
||||||
|
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||||
|
readsMap.set(itemId, {
|
||||||
|
...existing,
|
||||||
|
id: itemId,
|
||||||
|
source: 'reading-progress',
|
||||||
|
type: itemType,
|
||||||
|
url: itemUrl,
|
||||||
|
readingProgress: position,
|
||||||
|
readingTimestamp: timestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes marked-as-read articles into ReadItems
|
||||||
|
*/
|
||||||
|
export function processMarkedAsRead(
|
||||||
|
articles: ReadArticle[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const article of articles) {
|
||||||
|
const existing = readsMap.get(article.id)
|
||||||
|
|
||||||
|
if (article.eventId && article.eventKind === 30023) {
|
||||||
|
// Nostr article
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'article',
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
} else if (article.url) {
|
||||||
|
// External URL
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'external',
|
||||||
|
url: article.url,
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts ReadItems by most recent reading activity
|
||||||
|
*/
|
||||||
|
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||||
|
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out items without timestamps and enriches external items with fallback titles
|
||||||
|
*/
|
||||||
|
export function filterValidItems(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items
|
||||||
|
.filter(item => {
|
||||||
|
// Only include items that have a timestamp
|
||||||
|
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
|
||||||
|
(item.markedAt && item.markedAt > 0)
|
||||||
|
if (!hasTimestamp) return false
|
||||||
|
|
||||||
|
// For Nostr articles, we need the event to be valid
|
||||||
|
if (item.type === 'article' && !item.event) return false
|
||||||
|
|
||||||
|
// For external URLs, we need at least a URL
|
||||||
|
if (item.type === 'external' && !item.url) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(item => {
|
||||||
|
// Add fallback title for external URLs without titles
|
||||||
|
if (item.type === 'external' && !item.title && item.url) {
|
||||||
|
return { ...item, title: fallbackTitleFromUrl(item.url) }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
197
src/services/readsService.ts
Normal file
197
src/services/readsService.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
export interface ReadItem {
|
||||||
|
id: string // event ID or URL or coordinate
|
||||||
|
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||||
|
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||||
|
|
||||||
|
// Article data
|
||||||
|
event?: NostrEvent
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author?: string
|
||||||
|
|
||||||
|
// Reading metadata
|
||||||
|
readingProgress?: number // 0-1
|
||||||
|
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||||
|
markedAsRead?: boolean
|
||||||
|
markedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all reads from multiple sources:
|
||||||
|
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||||
|
* - Articles/URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchAllReads(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
bookmarks: Bookmark[],
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(readsMap, item)) {
|
||||||
|
onItem(readsMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
readsMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Reads] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length,
|
||||||
|
bookmarks: bookmarks.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit items
|
||||||
|
processReadingPositions(readingPositionEvents, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process bookmarked articles and article/website URLs
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles
|
||||||
|
if (bookmarkType === 'article') {
|
||||||
|
// Kind:30023 nostr article
|
||||||
|
const coordinate = bookmark.id // Already in coordinate format
|
||||||
|
const existing = readsMap.get(coordinate)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: coordinate,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
readsMap.set(coordinate, item)
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch full event data for nostr articles
|
||||||
|
const articleCoordinates = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article' && !item.event)
|
||||||
|
.map(item => item.id)
|
||||||
|
|
||||||
|
if (articleCoordinates.length > 0) {
|
||||||
|
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||||
|
|
||||||
|
// Parse coordinates and fetch events
|
||||||
|
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||||
|
|
||||||
|
for (const coord of articleCoordinates) {
|
||||||
|
try {
|
||||||
|
// Try to decode as naddr
|
||||||
|
if (coord.startsWith('naddr1')) {
|
||||||
|
const decoded = nip19.decode(coord)
|
||||||
|
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
identifier: decoded.data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try coordinate format (kind:pubkey:identifier)
|
||||||
|
const parts = coord.split(':')
|
||||||
|
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode article coordinate:', coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articlesToFetch.length > 0) {
|
||||||
|
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
|
||||||
|
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
||||||
|
{ relayUrls: RELAYS }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Merge event data into ReadItems and emit
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const item = readsMap.get(coordinate) || readsMap.get(event.id)
|
||||||
|
if (item) {
|
||||||
|
item.event = event
|
||||||
|
item.title = getArticleTitle(event) || 'Untitled'
|
||||||
|
item.summary = getArticleSummary(event)
|
||||||
|
item.image = getArticleImage(event)
|
||||||
|
item.published = getArticlePublished(event)
|
||||||
|
item.author = event.pubkey
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Filter for Nostr articles only and apply common validation/sorting
|
||||||
|
const articles = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article')
|
||||||
|
|
||||||
|
const validArticles = filterValidItems(articles)
|
||||||
|
const sortedReads = sortByReadingActivity(validArticles)
|
||||||
|
|
||||||
|
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||||
|
return sortedReads
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all reads:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ export async function publishEvent(
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
||||||
|
|
||||||
|
// Surface common bunker signing errors for debugging
|
||||||
|
if (error instanceof Error && error.message.includes('permission')) {
|
||||||
|
console.warn('💡 Hint: This may be a bunker permission issue. Ensure your bunker connection has signing permissions.')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -92,19 +92,30 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
|||||||
|
|
||||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||||
const sorted = sortIndividualBookmarks(items)
|
const sorted = sortIndividualBookmarks(items)
|
||||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
|
||||||
// Only non-encrypted legacy bookmarks go to the amethyst section
|
// Group by source list, not by content type
|
||||||
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
|
||||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
|
||||||
// Private items include encrypted legacy bookmarks
|
// Amethyst bookmarks: kind:30001 (any d-tag or undefined)
|
||||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
|
const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
|
||||||
return { privateItems, publicItems, web, amethyst }
|
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 {
|
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)
|
// 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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
69
src/utils/linksFromBookmarks.ts
Normal file
69
src/utils/linksFromBookmarks.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for external URLs:
|
||||||
|
* - Web bookmarks (kind:39701)
|
||||||
|
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||||
|
*/
|
||||||
|
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
// Web bookmarks (kind:39701) - extract from 'd' tag
|
||||||
|
if (bookmark.kind === KINDS.WebBookmark) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||||
|
urls.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract URLs from content if not already captured
|
||||||
|
if (bookmark.content) {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||||
|
const matches = bookmark.content.match(urlRegex)
|
||||||
|
if (matches) {
|
||||||
|
urls.push(...matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (for web bookmarks and other types)
|
||||||
|
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
|
||||||
|
// Create ReadItem for each unique URL
|
||||||
|
for (const url of [...new Set(urls)]) {
|
||||||
|
if (!linksMap.has(url)) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: url,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'external',
|
||||||
|
url,
|
||||||
|
title: title || fallbackTitleFromUrl(url),
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
linksMap.set(url, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(linksMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
83
src/utils/readItemMerge.ts
Normal file
83
src/utils/readItemMerge.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a ReadItem into a state map, returning whether the state changed.
|
||||||
|
* Uses most recent reading activity to determine precedence.
|
||||||
|
*/
|
||||||
|
export function mergeReadItem(
|
||||||
|
stateMap: Map<string, ReadItem>,
|
||||||
|
incoming: ReadItem
|
||||||
|
): boolean {
|
||||||
|
const existing = stateMap.get(incoming.id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
stateMap.set(incoming.id, incoming)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always merge if incoming has reading progress data
|
||||||
|
const hasNewProgress = incoming.readingProgress !== undefined &&
|
||||||
|
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
|
||||||
|
|
||||||
|
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
|
||||||
|
|
||||||
|
// Merge by taking the most recent reading activity
|
||||||
|
const existingTime = existing.readingTimestamp || existing.markedAt || 0
|
||||||
|
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
|
||||||
|
|
||||||
|
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
|
||||||
|
// Keep existing data, but update with newer reading metadata
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
// Preserve event data if incoming doesn't have it
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author,
|
||||||
|
// Always take reading progress if available
|
||||||
|
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
|
||||||
|
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timestamps are equal but incoming has additional data, merge it
|
||||||
|
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a readable title from a URL when no title is available.
|
||||||
|
* Removes protocol, www, and shows domain + path.
|
||||||
|
*/
|
||||||
|
export function fallbackTitleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
let title = parsed.hostname.replace(/^www\./, '')
|
||||||
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
const path = parsed.pathname.slice(0, 40)
|
||||||
|
title += path.length < parsed.pathname.length ? path + '...' : path
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, just return the URL truncated
|
||||||
|
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
src/utils/readingProgressUtils.ts
Normal file
30
src/utils/readingProgressUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters ReadItems by reading progress
|
||||||
|
*/
|
||||||
|
export function filterByReadingProgress(
|
||||||
|
items: ReadItem[],
|
||||||
|
filter: ReadingProgressFilterType
|
||||||
|
): ReadItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
const progress = item.readingProgress || 0
|
||||||
|
const isMarked = item.markedAsRead || false
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 'unopened':
|
||||||
|
return progress === 0 && !isMarked
|
||||||
|
case 'started':
|
||||||
|
return progress > 0 && progress <= 0.10 && !isMarked
|
||||||
|
case 'reading':
|
||||||
|
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||||
|
case 'completed':
|
||||||
|
return progress >= 0.95 || isMarked
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
71
src/utils/readsFromBookmarks.ts
Normal file
71
src/utils/readsFromBookmarks.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { classifyBookmarkType } from './bookmarkTypeClassifier'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
|
||||||
|
* Returns items with type='article', using hydrated data when available.
|
||||||
|
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
|
||||||
|
*/
|
||||||
|
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles (kind:30023)
|
||||||
|
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
|
||||||
|
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
|
||||||
|
|
||||||
|
// Extract identifier from coordinate
|
||||||
|
const parts = coordinate.split(':')
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
// Convert to naddr format (reading positions use naddr as ID)
|
||||||
|
let naddr: string
|
||||||
|
try {
|
||||||
|
naddr = nip19.naddrEncode({
|
||||||
|
kind: KINDS.BlogPost,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to encode naddr for bookmark:', coordinate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (same as BookmarkItem does)
|
||||||
|
const title = bookmark.content || 'Untitled'
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
|
||||||
|
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: naddr, // Use naddr format to match reading positions
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
published: published ? parseInt(published) : undefined,
|
||||||
|
author: bookmark.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
readsMap.set(naddr, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(readsMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -8,3 +8,11 @@ declare module '*.svg?raw' {
|
|||||||
const content: string
|
const content: string
|
||||||
export default content
|
export default content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build-time defines injected by Vite in vite.config.ts
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
declare const __GIT_COMMIT__: string
|
||||||
|
declare const __GIT_BRANCH__: string
|
||||||
|
declare const __BUILD_TIME__: string
|
||||||
|
declare const __GIT_COMMIT_URL__: string
|
||||||
|
declare const __RELEASE_URL__: string
|
||||||
|
|||||||
11
vercel.json
11
vercel.json
@@ -1,5 +1,16 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/a/:naddr",
|
||||||
|
"has": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"key": "user-agent",
|
||||||
|
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"destination": "/api/article-og?naddr=:naddr"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/(.*)",
|
"source": "/(.*)",
|
||||||
"destination": "/index.html"
|
"destination": "/index.html"
|
||||||
|
|||||||
100
vite.config.ts
100
vite.config.ts
@@ -1,8 +1,101 @@
|
|||||||
|
/* eslint-env node */
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
|
function getGitMetadata() {
|
||||||
|
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
|
||||||
|
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||||
|
let commit = envSha
|
||||||
|
let branch = envRef
|
||||||
|
try {
|
||||||
|
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { commit, branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
|
||||||
|
return pkg.version as string
|
||||||
|
} catch {
|
||||||
|
return '0.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { commit, branch } = getGitMetadata()
|
||||||
|
const version = getPackageVersion()
|
||||||
|
const buildTime = new Date().toISOString()
|
||||||
|
|
||||||
|
function getReleaseUrl(version: string): string {
|
||||||
|
if (!version) return ''
|
||||||
|
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||||
|
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||||
|
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||||
|
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||||
|
return `https://github.com/${owner}/${slug}/releases/tag/v${version}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
if (remote.includes('github.com')) {
|
||||||
|
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||||
|
const https = remote.startsWith('git@')
|
||||||
|
? `https://github.com/${remote.split(':')[1]}`
|
||||||
|
: remote
|
||||||
|
const cleaned = https.replace(/\.git$/, '')
|
||||||
|
return `${cleaned}/releases/tag/v${version}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommitUrl(commit: string): string {
|
||||||
|
if (!commit) return ''
|
||||||
|
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||||
|
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||||
|
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||||
|
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||||
|
return `https://github.com/${owner}/${slug}/commit/${commit}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
if (remote.includes('github.com')) {
|
||||||
|
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||||
|
const https = remote.startsWith('git@')
|
||||||
|
? `https://github.com/${remote.split(':')[1]}`
|
||||||
|
: remote
|
||||||
|
const cleaned = https.replace(/\.git$/, '')
|
||||||
|
return `${cleaned}/commit/${commit}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseUrl = getReleaseUrl(version)
|
||||||
|
const commitUrl = getCommitUrl(commit)
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
__GIT_COMMIT__: JSON.stringify(commit),
|
||||||
|
__GIT_BRANCH__: JSON.stringify(branch),
|
||||||
|
__BUILD_TIME__: JSON.stringify(buildTime),
|
||||||
|
__GIT_COMMIT_URL__: JSON.stringify(commitUrl),
|
||||||
|
__RELEASE_URL__: JSON.stringify(releaseUrl)
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -30,7 +123,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
|
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: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -48,7 +142,7 @@ export default defineConfig({
|
|||||||
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
|
mainFields: ['module', 'jsnext:main', 'jsnext', 'main']
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react'],
|
include: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-react', 'applesauce-accounts', 'applesauce-signers'],
|
||||||
esbuildOptions: {
|
esbuildOptions: {
|
||||||
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
|
resolveExtensions: ['.js', '.ts', '.tsx', '.json']
|
||||||
}
|
}
|
||||||
@@ -65,7 +159,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay']
|
noExternal: ['applesauce-core', 'applesauce-factory', 'applesauce-relay', 'applesauce-accounts', 'applesauce-signers']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user