mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f316f915b | ||
|
|
002790a01d | ||
|
|
143abc8fd1 | ||
|
|
6376e73951 | ||
|
|
e2935c23fd | ||
|
|
96bbeef040 | ||
|
|
b78337af6d | ||
|
|
d58d411180 | ||
|
|
5463677237 | ||
|
|
ca959cb33e | ||
|
|
4d367109d5 | ||
|
|
b5ee063260 | ||
|
|
77fa149cc3 | ||
|
|
35e84971b8 | ||
|
|
12b1c1e7f8 | ||
|
|
30895e4444 | ||
|
|
0a3057d081 | ||
|
|
f61563d2e2 | ||
|
|
614ff3b1f1 | ||
|
|
3792b218a9 | ||
|
|
492c591f06 | ||
|
|
5d7df0eb61 | ||
|
|
89986759e0 | ||
|
|
cc02228548 | ||
|
|
61b893e94d | ||
|
|
3b0465067f | ||
|
|
e2540c729f | ||
|
|
18981f546c | ||
|
|
a6e67e2284 | ||
|
|
b02d275bdb | ||
|
|
13430ccea0 | ||
|
|
599c40c3cc | ||
|
|
c092e10188 | ||
|
|
e36e2c0aee | ||
|
|
661d6965e4 | ||
|
|
4ed5c97678 | ||
|
|
32686c5ef0 | ||
|
|
fd6919b65f | ||
|
|
c081d950b2 | ||
|
|
e9e21f733d | ||
|
|
c870d35f7c | ||
|
|
507f09dff5 | ||
|
|
4e42db2667 | ||
|
|
0b0d3628ee | ||
|
|
0cf50ee7e9 | ||
|
|
b54a0209d9 | ||
|
|
0d7eb42643 | ||
|
|
419bb02610 | ||
|
|
e05006f0b7 | ||
|
|
da0d17c8f9 | ||
|
|
0bf0c1be5c | ||
|
|
70dec62d8f | ||
|
|
87d0caed5b | ||
|
|
d6fd0fd8e9 | ||
|
|
55e3be4624 | ||
|
|
a5f9a33465 | ||
|
|
5b57fc644d | ||
|
|
86be0284d9 | ||
|
|
79e592b4a6 | ||
|
|
de358dd643 | ||
|
|
cb58c73b17 | ||
|
|
98dd51509d | ||
|
|
ebbddf77bc | ||
|
|
f9e2b975d1 | ||
|
|
0434401596 | ||
|
|
c99f6834cd | ||
|
|
7d389bb33d | ||
|
|
0addd59226 | ||
|
|
e9ec0b790e | ||
|
|
0ec2d48142 | ||
|
|
55b2dc77e7 | ||
|
|
ebc306c928 | ||
|
|
eec5b58175 | ||
|
|
4f266e25db | ||
|
|
9361752b54 | ||
|
|
073b62459a | ||
|
|
55429a705c | ||
|
|
ff83438dee | ||
|
|
879330b2a2 | ||
|
|
04865ffdc5 | ||
|
|
c3f08a8178 | ||
|
|
eaae8066da | ||
|
|
cb18615c1b | ||
|
|
3573b7a287 | ||
|
|
0a929e7b6d | ||
|
|
ccf65f7ccd | ||
|
|
2f0d8ad07b | ||
|
|
df20aaa114 | ||
|
|
9d6f17fedb | ||
|
|
fb07d706b1 | ||
|
|
31cad16403 | ||
|
|
23c3681b21 | ||
|
|
d53ce6742a | ||
|
|
45749f1977 | ||
|
|
004c6426a8 | ||
|
|
484343b027 | ||
|
|
46ef2b8123 | ||
|
|
10751a8fb9 | ||
|
|
9d7efce44e | ||
|
|
151c547490 | ||
|
|
8855fac640 | ||
|
|
a43f8e634d | ||
|
|
934d7c6cce | ||
|
|
9122a14493 | ||
|
|
86464f9798 | ||
|
|
b84f8c6031 | ||
|
|
17dda8f2c1 | ||
|
|
ad0713b4cb | ||
|
|
6ecbef207f | ||
|
|
ae6ba0d9e0 | ||
|
|
0843c70123 | ||
|
|
a97ce36c70 | ||
|
|
7995f92a38 | ||
|
|
ea81e3bf32 | ||
|
|
9531fb21be | ||
|
|
daf39ce361 | ||
|
|
7070eea35e | ||
|
|
814bb65ffe | ||
|
|
749818fa13 | ||
|
|
f8c227e1ed | ||
|
|
dbceaa98df | ||
|
|
bf552d3816 | ||
|
|
c1ae2c8c9f | ||
|
|
efe4ff99eb | ||
|
|
1a6b304daa | ||
|
|
fe0e023bd7 | ||
|
|
1db10f7f81 | ||
|
|
90bbcf0ad4 | ||
|
|
35898a95a5 | ||
|
|
3bf380dfc6 | ||
|
|
2e516ca989 | ||
|
|
3fe9f75700 | ||
|
|
cc10bc33f0 | ||
|
|
30e66fe969 | ||
|
|
b5cde1b977 | ||
|
|
384892eea2 | ||
|
|
c3b16baf0d | ||
|
|
3eb63f28fb | ||
|
|
d2c4758d6d | ||
|
|
c76f780264 | ||
|
|
58d28019e9 | ||
|
|
bc392b7088 | ||
|
|
bece17afda | ||
|
|
c699d3f438 | ||
|
|
6fb26fede5 | ||
|
|
73be11403b | ||
|
|
56cf4aa8e6 | ||
|
|
38e010c828 | ||
|
|
19bcc8ca6b | ||
|
|
cfa2e2f8bf | ||
|
|
ee2175a7ad | ||
|
|
47a78a083c | ||
|
|
723c8f8bf8 | ||
|
|
4529a59499 | ||
|
|
41b2b8fba6 | ||
|
|
dba7763000 | ||
|
|
ac29eea29c | ||
|
|
98fb9b437d | ||
|
|
38f70b666d | ||
|
|
e7939f52c1 | ||
|
|
f4925a2726 | ||
|
|
715087992e | ||
|
|
ddf1a560a8 | ||
|
|
c0290b33a7 | ||
|
|
79aa7f7669 | ||
|
|
9834fc7d9f | ||
|
|
25e53c08e3 | ||
|
|
8cf1ff0037 | ||
|
|
4fd16c5a8e | ||
|
|
d1d481555d | ||
|
|
8ad39b5cf6 | ||
|
|
b3b467755a | ||
|
|
957a3ae6fa | ||
|
|
58fd6ef7d5 | ||
|
|
2bafecfb50 | ||
|
|
f04c21004a | ||
|
|
63a178263a | ||
|
|
4d97493003 | ||
|
|
bffd98247e | ||
|
|
a453ead95e | ||
|
|
9a9dcb239f | ||
|
|
8981f1cb6a | ||
|
|
ff3d9fa05c | ||
|
|
251290ab2e | ||
|
|
3b2874a5c9 | ||
|
|
38ec169b22 | ||
|
|
8158eb2089 | ||
|
|
48cf7ed4f8 | ||
|
|
8e8ab147fb | ||
|
|
19f62d545d | ||
|
|
7286295a3a | ||
|
|
24b12d9108 | ||
|
|
1bb935628e | ||
|
|
7a923ae77c | ||
|
|
26c4420293 | ||
|
|
4f75f2dff4 | ||
|
|
4926cb5242 | ||
|
|
d9d07d8ff9 | ||
|
|
82f779099a | ||
|
|
332fc156bf | ||
|
|
d35f396c2f | ||
|
|
c4cc89af29 | ||
|
|
f80ebef5c3 | ||
|
|
e3b2220269 | ||
|
|
b5f218b221 | ||
|
|
84948101f3 | ||
|
|
c39208c629 | ||
|
|
62ccc468f9 | ||
|
|
33f96b762a | ||
|
|
7c9443e712 | ||
|
|
beb8b54d14 | ||
|
|
e6b465ccca | ||
|
|
287d954823 | ||
|
|
1c805b053e | ||
|
|
908435f476 | ||
|
|
b773e1ec21 | ||
|
|
c0ffde6462 | ||
|
|
d84dbe5fae | ||
|
|
793375a4fb | ||
|
|
c93075bb22 | ||
|
|
aa4e86758a | ||
|
|
9d57a43c98 | ||
|
|
e76107c8b2 | ||
|
|
8dd4b40b5a | ||
|
|
1e42b2af56 | ||
|
|
24823ebed9 | ||
|
|
1f0fb6ac87 | ||
|
|
9e296d4910 | ||
|
|
f2c7520a40 | ||
|
|
05f3f88795 | ||
|
|
8fd5ce3c88 | ||
|
|
0fd54abde8 | ||
|
|
5d04571b6b | ||
|
|
a39446f1e0 | ||
|
|
01a9b2f0bd | ||
|
|
8925eee72e | ||
|
|
16562cbb39 | ||
|
|
c9ea82a6dc | ||
|
|
76e063e8f6 | ||
|
|
e07d4c4cab | ||
|
|
6dd970d55b | ||
|
|
ad4f56a1d4 | ||
|
|
2529ab1314 | ||
|
|
80438555f5 | ||
|
|
5d3c2a5f35 | ||
|
|
22e93d83dd | ||
|
|
991da0f2d5 | ||
|
|
0824bbca98 | ||
|
|
69ea5b0919 | ||
|
|
7550007fe6 | ||
|
|
2d20ecb6ca | ||
|
|
fe89e54a2a | ||
|
|
c2620a7564 | ||
|
|
d843a6aa51 | ||
|
|
2132a913d5 | ||
|
|
99e8bd0c78 | ||
|
|
90e3649506 | ||
|
|
ce94e2c411 | ||
|
|
dbc8a5278a | ||
|
|
bbad8f7d48 | ||
|
|
b990578027 | ||
|
|
a37e82fe03 | ||
|
|
c8600f824e | ||
|
|
73a7a48c5c | ||
|
|
f6e1bf2424 | ||
|
|
7672726088 | ||
|
|
57cde4460a | ||
|
|
62fe113bf4 | ||
|
|
fd400f6fb2 | ||
|
|
9041f9c0b7 | ||
|
|
1d8fa2d0c0 | ||
|
|
84002d439d | ||
|
|
109a0a7aec | ||
|
|
3d56cc3835 | ||
|
|
7d88cb1dbc | ||
|
|
94a203f594 | ||
|
|
57cf16a0f4 | ||
|
|
94eb750854 | ||
|
|
7c3af36003 | ||
|
|
b1b0b0f625 | ||
|
|
24309c1356 | ||
|
|
a940d0cdfd | ||
|
|
4a588ed5d5 | ||
|
|
6bb089f4c1 | ||
|
|
f81c97e5a8 | ||
|
|
58d2611df7 | ||
|
|
edc03bf152 | ||
|
|
be0e16efec | ||
|
|
205a9fdbda | ||
|
|
88ad16326b | ||
|
|
8a73d6d1fb | ||
|
|
7b324593c5 | ||
|
|
1421a0c198 | ||
|
|
21b095e45f | ||
|
|
cf34e28a47 | ||
|
|
ef6b8bf1cf | ||
|
|
ad58ff04a3 | ||
|
|
f579c965a7 | ||
|
|
b46135cd36 | ||
|
|
8baeab2b0f | ||
|
|
f33cd66642 | ||
|
|
e0a9fae4c8 | ||
|
|
2fb2a83272 | ||
|
|
2e77cdbe04 | ||
|
|
361185ec02 | ||
|
|
0abd4249d1 | ||
|
|
1e1600984c | ||
|
|
05a3254372 | ||
|
|
2cfcbc8366 | ||
|
|
7c295b57be | ||
|
|
cbf5203dcf | ||
|
|
97e74875ae | ||
|
|
61815ba289 | ||
|
|
9fa359f9b5 | ||
|
|
3d04c90ddc | ||
|
|
1dfb9f2877 | ||
|
|
2b42d0ada6 | ||
|
|
8a4ab536da | ||
|
|
06c979569f | ||
|
|
9498ba3465 | ||
|
|
2e26242153 | ||
|
|
d950fd9470 | ||
|
|
2e6e7012a6 | ||
|
|
e77db9d07d | ||
|
|
245f6bd0d1 | ||
|
|
762dbe5dcc | ||
|
|
c3ed4bc6ea | ||
|
|
b1a493f113 | ||
|
|
84b70475d9 | ||
|
|
a4eb4f2367 | ||
|
|
19ac142d31 | ||
|
|
7fad136f4a | ||
|
|
041aa58398 | ||
|
|
212f48c0e3 | ||
|
|
f6729b8e35 | ||
|
|
7ca48bf6de | ||
|
|
9112aeadcc | ||
|
|
54b5358854 | ||
|
|
09fc89bda6 | ||
|
|
02bd23ccaf | ||
|
|
6cfaf38ce1 | ||
|
|
b4f7ed1e54 | ||
|
|
138094f57d | ||
|
|
6e4323d9f6 | ||
|
|
a1a4a3bbe1 | ||
|
|
8a0280902f | ||
|
|
9945c024fc | ||
|
|
23377591ce | ||
|
|
97a8ff814e | ||
|
|
2cc0697b17 | ||
|
|
15a5bbe499 | ||
|
|
4fec220b7b | ||
|
|
c2c7f6445d | ||
|
|
fb0c2a31ea | ||
|
|
bf63808007 | ||
|
|
aff2a81bf5 | ||
|
|
7f586a38c4 | ||
|
|
632ee88b90 | ||
|
|
0320492c54 | ||
|
|
57655aa304 | ||
|
|
48410b1abb | ||
|
|
32ab19e915 | ||
|
|
10adeddd82 | ||
|
|
43bda18a52 | ||
|
|
d8f6da2b2c | ||
|
|
6d1bbea045 | ||
|
|
385e7c6476 | ||
|
|
8bdfd672aa | ||
|
|
e97d1bb084 | ||
|
|
7149255004 | ||
|
|
602fa753b1 | ||
|
|
6e838aedd9 | ||
|
|
d938d01a4c | ||
|
|
d17047c6ea | ||
|
|
bf088532dc | ||
|
|
d9388db649 | ||
|
|
a22b904ddc | ||
|
|
628a671277 | ||
|
|
92cc05f1f6 | ||
|
|
9aa5a360d6 | ||
|
|
6684da3e5f | ||
|
|
7fcc123cb4 | ||
|
|
ff30678afc | ||
|
|
da328db9c9 | ||
|
|
38924abdcc | ||
|
|
4e6383ddeb | ||
|
|
61aa55e70b | ||
|
|
e92bfee9c4 | ||
|
|
b6dc562a3e | ||
|
|
c842c4dbb3 | ||
|
|
672c7cb052 | ||
|
|
cea8fcf6de | ||
|
|
8ff22e1e81 | ||
|
|
061bda8aec | ||
|
|
61c8b3a8ee | ||
|
|
db0461e9c7 | ||
|
|
a1505b7538 | ||
|
|
297fbb2450 | ||
|
|
7510ad2574 | ||
|
|
1c99d4c2f3 | ||
|
|
1298443c00 | ||
|
|
b9ac6dd9ca | ||
|
|
c87d9d96a4 | ||
|
|
ea25e0a92a | ||
|
|
9416efdf1a | ||
|
|
f473fc69e3 | ||
|
|
bea08e3b3c | ||
|
|
b44373a4b6 | ||
|
|
ecd3f4026e | ||
|
|
af7738b4d8 | ||
|
|
28def68012 | ||
|
|
95b2f23d66 | ||
|
|
63274d4c43 | ||
|
|
b0774de823 | ||
|
|
8708431083 | ||
|
|
d378b4ca17 | ||
|
|
eae0ea47b0 | ||
|
|
2bb49dd404 | ||
|
|
cf99059564 | ||
|
|
c06233947f | ||
|
|
d1a9b8c79d | ||
|
|
eaa81f56ab | ||
|
|
a025dd210f | ||
|
|
1d977f5016 | ||
|
|
6b1602d170 | ||
|
|
8f004e5947 | ||
|
|
09c8e275c4 | ||
|
|
2599bdaabc | ||
|
|
ff452ebe77 | ||
|
|
7a0cf00810 | ||
|
|
c70a107d2f | ||
|
|
c8a79316d0 | ||
|
|
cd09dbc68d | ||
|
|
b944560a08 | ||
|
|
b2ceb197f2 | ||
|
|
1749351fa2 | ||
|
|
9f2e06744c | ||
|
|
9e509bb782 | ||
|
|
5f4f025a1b | ||
|
|
fa583f0685 | ||
|
|
284bab6cff | ||
|
|
259bd3b281 | ||
|
|
0e29b07d5b | ||
|
|
b6e1219d09 | ||
|
|
713e4eeab6 | ||
|
|
2f1193dbf2 | ||
|
|
434f5c3fa5 | ||
|
|
48ad71ae80 | ||
|
|
c5e95c2143 | ||
|
|
9e2fb61182 | ||
|
|
ca4c53e081 | ||
|
|
234f1f1a4c | ||
|
|
eae6b7b925 | ||
|
|
bc9ddaa12a | ||
|
|
a20dc838dc | ||
|
|
87ab3f6435 | ||
|
|
8d47b32193 | ||
|
|
41a0d15e7a | ||
|
|
9309ac11ea | ||
|
|
9c7884d4b7 | ||
|
|
a9be08be1d | ||
|
|
03950f4555 | ||
|
|
e543de8bb4 | ||
|
|
78b5677d58 | ||
|
|
5d4c0d494b | ||
|
|
7de5a0302e | ||
|
|
825d3c7ce4 | ||
|
|
72d4c3ff30 | ||
|
|
5a243eea50 | ||
|
|
faa72e4032 | ||
|
|
0de482efc0 | ||
|
|
8db49e7be2 | ||
|
|
d3d1e0c2e7 | ||
|
|
cffdc02d40 | ||
|
|
65198c3abe |
No files matched your search
@@ -8,14 +8,14 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
|
||||
* **Main** is the production track. Anything merged here must preserve today’s UX and magnet safety guarantees. Rollbacks should be painless: keep commits atomic, avoid destructive migrations, and leave feature flags in a known-good default (`false` unless product explicitly flips them).
|
||||
* **Unstable** is our experimentation lane. Gate risky behavior behind feature flags defined in `js/constants.js` and document the toggle/rollback plan in PR descriptions.
|
||||
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately and annotate the AGENTS.md changelog with remediation tips so future agents do not repeat the mistake.
|
||||
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately, call this out in the PR, and annotate the AGENTS.md changelog with remediation tips. **Note:** These regressions usually surface in dev logs and tests around playback/magnet helpers—reference them when triaging so future agents don’t repeat the mistake.
|
||||
|
||||
## 2. Mission: URL‑First Playback with WebTorrent Fallback
|
||||
|
||||
* **Goal:** Always deliver smooth playback while keeping hosting costs low.
|
||||
* **Primary transport:** A hosted video URL (MP4/WebM/HLS/DASH) that the `<video>` element can stream directly.
|
||||
* **Fallback transport:** A WebTorrent magnet that includes browser‑safe trackers plus optional HTTP hints.
|
||||
* **Runtime behavior:** Call `playVideoWithFallback({ url, magnet })` (see `js/playbackUtils.js`). It probes the URL first; only when that fails should WebTorrent start.
|
||||
* **Runtime behavior:** Call `bitvidApp.playVideoWithFallback({ url, magnet })` in `js/app.js`. This function orchestrates URL probing and magnet fallback, delegating low-level stream handling to `js/services/playbackService.js`. `js/playbackUtils.js` now provides magnet/session helper utilities rather than the playback entry point itself.
|
||||
* **Content contract:** Bitvid posts (Nostr kind `30078`) must include a `title` and at least one of `url` or `magnet`. Prefer to publish both along with optional `thumbnail`, `description`, and `mode` fields. When mirroring to NIP‑94 (`kind 1063`), copy the hosted URL and (optionally) the magnet so other clients can discover the same asset.
|
||||
|
||||
---
|
||||
@@ -24,9 +24,10 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
|
||||
* **Use the helpers every time:** Always run inbound values through `safeDecodeMagnet()` before playback or normalization, and call `normalizeAndAugmentMagnet()` (see `js/magnetUtils.js`) to append `ws=` / `xs=` hints without mutating hashes.
|
||||
* **Keep magnets raw:** Persist the literal `magnet:?xt=urn:btih:...` string. Decode once, then feed that exact value to WebTorrent or storage. Never rely on re-encoding helpers that might alter casing.
|
||||
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
|
||||
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and can corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
|
||||
* **HTTP hints:** Encourage HTTPS `ws=` web seeds (file roots only) and HTTPS `xs=` pointers to `.torrent` files so fallback peers warm quickly.
|
||||
* **Tracker policy:** Browser code must ship WSS trackers only. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers.
|
||||
* **Tracker policy:** Browser code must ship **WSS trackers only**. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers. (If you introduce a new tracker set or feature flag, update `js/constants.js` and mention it here.)
|
||||
* **Validation reference:** When in doubt about legacy infohash handling or normalization, consult the existing tests that cover legacy/bare infohash inputs and magnet normalization. If you extend helper behavior, extend those tests alongside the code.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,29 +47,41 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
Run this script before shipping notable UI changes, especially around upload/playback flows. Automate when possible, but manual verification is required for releases.
|
||||
|
||||
1. **Smoke test the modal**
|
||||
|
||||
* Launch the site, open the Upload modal, and confirm required-field validation (title + one of URL/magnet).
|
||||
* Submit with URL only, magnet only, and both; ensure success states are clear.
|
||||
2. **URL-first playback**
|
||||
|
||||
* Publish a post with both URL and magnet. Load the card and verify the player fetches the hosted URL first (check network tab or logs).
|
||||
* Temporarily block the URL (e.g., devtools throttling or offline). Confirm playback seamlessly falls back to the magnet.
|
||||
3. **Magnet hygiene**
|
||||
|
||||
* Paste an encoded magnet; ensure `safeDecodeMagnet()` returns the raw value and the final string still includes the original `xt` hash.
|
||||
* Confirm `normalizeAndAugmentMagnet()` appends `ws=`/`xs=` hints without corrupting parameters.
|
||||
4. **P2P hints**
|
||||
|
||||
* Verify magnets authored through the modal include HTTPS `ws=` and optional `xs=` values when provided.
|
||||
* Ensure tracker lists come from `js/constants.js` and remain WSS-only.
|
||||
5. **Cross-browser sanity**
|
||||
|
||||
* Spot-check playback in Chromium and Firefox (desktop) to ensure no console errors related to CORS, Range requests, or tracker connections.
|
||||
|
||||
Document the run in PR descriptions so QA can cross-reference results.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Notes for Agents
|
||||
## 6. Moderation Quickstart (Bitvid)
|
||||
|
||||
* **Nostr interoperability:** Keep kind `30078` events as the source of truth and optionally mirror to kind `1063` so external clients see the hosted URL.
|
||||
* **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI.
|
||||
* **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above.
|
||||
The moderation and admin list tooling remain active in Bitvid and must follow Nostr moderation policies and QA steps. Review `docs/moderation/README.md` and these key expectations:
|
||||
|
||||
1. **Report thresholds:** Parse NIP-56 reports and compute `trustedReportCount` using **only F1 followers**.
|
||||
2. **Default behaviors:** Blur thumbnails at ≥3 F1 `nudity` reports and block autoplay at ≥2.
|
||||
3. **Admin lists:** Use NIP‑51 lists — `10000` for mute/downrank and `30000` for admin hard-hide/whitelist. Ensure these list IDs are correctly handled in the profile modal.
|
||||
4. **Profile Modal Integration:** Admin list toggles and moderation controls in `ProfileModalController` must trigger local state changes and reflect mutations visually without breaking cached profiles.
|
||||
5. **User overrides:** Add a “show anyway” option to any blurred or blocked content.
|
||||
6. **Testing:** Run all cases from `docs/moderation/testing.md` before shipping changes. Confirm blur, hide, and override flows function correctly across sessions.
|
||||
7. **Telemetry and privacy:** Log moderation decisions locally for debugging without leaking PII.
|
||||
8. **QA demo:** Record a ≤90 s screen demo showing blur/hide/override flows when submitting major moderation work.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +91,70 @@ Document the run in PR descriptions so QA can cross-reference results.
|
||||
`{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`.
|
||||
* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable.
|
||||
* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied.
|
||||
* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
|
||||
* **Playback orchestration:** `bitvidApp.playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
|
||||
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
|
||||
|
||||
---
|
||||
|
||||
## 8. Architectural Guide: `bitvidApp` vs. UI Controllers
|
||||
|
||||
To keep the codebase maintainable, we are moving complex UI logic from `js/app.js` into dedicated controller classes (e.g., `ProfileModalController`, `UploadModal`). **This is an architectural direction for `unstable`**—some controllers may not exist yet. When working on these components, follow these core principles:
|
||||
|
||||
#### **Principle: Separation of Concerns**
|
||||
|
||||
* **`bitvidApp` (js/app.js): The Orchestrator.** Its main job is to manage application-wide state, wire up services, and orchestrate high-level actions. It should **not** directly manipulate the DOM elements of complex components like modals. Instead, it should instantiate their controllers and communicate with them through a clean API.
|
||||
|
||||
* **UI Controllers (e.g., `js/ui/profileModalController.js`): The Specialists.** A controller is responsible for a specific piece of the UI. It manages its own DOM elements, handles user interactions within its scope, and uses services and callbacks provided by `bitvidApp` to perform actions.
|
||||
|
||||
#### **State Management: The Source of Truth**
|
||||
|
||||
The single source of truth for shared application state (like saved profiles, relay lists, and block lists) lives in modules like `js/state/cache.js` and services like `relayManager`.
|
||||
|
||||
1. **`bitvidApp` Writes State:** `bitvidApp` is responsible for calling the functions that mutate this central state (e.g., `setSavedProfiles`, `userBlocks.addBlock`).
|
||||
2. **`bitvidApp` Notifies the Controller:** After updating the state, `bitvidApp` must call a method on the controller to tell it that its data is stale and it needs to re-render.
|
||||
|
||||
* **Example:** When a user logs in, `bitvidApp` updates the active user and saved profiles, then calls `this.profileModalController.handleAuthLogin(detail)` so the modal can update its own display.
|
||||
3. **Controller Reads State:** The controller receives functions to *read* this state in its constructor (via the `state` and `services` objects). It should **always** use these functions (e.g., `this.state.getSavedProfiles()`, `this.services.userBlocks.getBlocked()`) to get the data it needs to render.
|
||||
|
||||
#### **Execution Flow: Callbacks**
|
||||
|
||||
When a user performs an action inside a modal that needs to affect the whole application (like switching a profile or updating relays), the flow is:
|
||||
|
||||
1. **User Action:** A user clicks a "Switch Profile" button inside the modal.
|
||||
2. **Controller Event Handler:** The controller's internal event listener fires.
|
||||
3. **Controller Invokes Callback:** The controller calls a callback function passed to it by `bitvidApp` (e.g., `this.callbacks.onRequestSwitchProfile({ pubkey })`). It does *not* contain the logic for switching profiles itself.
|
||||
4. **`bitvidApp` Executes the Logic:** `bitvidApp` provides the implementation for that callback (e.g., `handleProfileSwitchRequest`). This method contains the actual logic to call the auth service, reload videos, and update the application state.
|
||||
|
||||
#### **Checklist for Refactoring from `bitvidApp` to a Controller:**
|
||||
|
||||
When moving a feature to a controller, follow these steps:
|
||||
|
||||
1. **Identify Logic:** Find the methods in `js/app.js` that directly read from or write to the modal's DOM (e.g., old `renderSavedProfiles` or `populateBlockedList` logic).
|
||||
2. **Create Controller Method:** Implement the new logic inside the controller. Ensure it gets its data from `this.services.*` or `this.state.*`.
|
||||
3. **Create `bitvidApp` Wrapper:** In `js/app.js`, replace the old logic with a new, thin wrapper method. This wrapper should:
|
||||
|
||||
* a. Update any central application state if needed.
|
||||
* b. Call the new method on the controller instance (e.g., `this.profileModalController.populateBlockedList()`).
|
||||
4. **Wire Up Callbacks:** If the controller needs to trigger an app-wide action, ensure a callback is defined in the controller's constructor and provided by `bitvidApp` during instantiation.
|
||||
5. **Remove Old Code:** Once fully replaced, delete the old DOM manipulation logic and direct event listeners from `js/app.js`. All interactions should be routed through the controller.
|
||||
|
||||
#### **File Map (quick reference)**
|
||||
|
||||
* `js/app.js` — **`bitvidApp`** (orchestrator; owns high-level flows like `playVideoWithFallback` wiring)
|
||||
* `js/services/playbackService.js` — Lower-level playback stream handling
|
||||
* `js/playbackUtils.js` — Magnet/session helper utilities
|
||||
* `js/magnetUtils.js` — `safeDecodeMagnet`, `normalizeAndAugmentMagnet` (WSS trackers, `ws=`/`xs=` hints)
|
||||
* `js/constants.js` — Feature flags, canonical WSS tracker list
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
Please read these documents next.
|
||||
|
||||
* `docs/nostr-event-schemas.md`
|
||||
* `js/nostrEventSchemas.js`
|
||||
|
||||
And if you need to create new nostr kinds please keep the logic centralized there in `nostrEventSchemas.js` and the `nostr-event-schemas.md` up to date.
|
||||
|
||||
**End of AGENTS.md**
|
||||
@@ -11,7 +11,7 @@
|
||||
## Features
|
||||
|
||||
- **Decentralized Sharing**: Video sharing without central servers.
|
||||
- **Private Video Listings**: Share encrypted videos for added privacy.
|
||||
- **Private Video Listings**: Hide cards from shared grids so only the owner sees them.
|
||||
- **Nostr Integration**: Use Nostr keys for login and interaction.
|
||||
- **WebTorrent Streaming**: Stream videos directly through torrent technology.
|
||||
- **Developer-Friendly**: Open source and customizable for your needs.
|
||||
@@ -37,7 +37,7 @@ The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly
|
||||
- **Web seeds (`ws=`)**: HTTPS only. Point to a file root (e.g., `https://cdn.example.com/video/`). Mixed-content (`http://`) hints are rejected just like the modal message explains.
|
||||
- **Additional sources (`xs=`)**: Recommend adding an HTTPS `.torrent` link so WebTorrent peers can bootstrap faster.
|
||||
- **Trackers**: Bitvid’s browser client only connects to WSS trackers shipped in `js/constants.js`. Do not add UDP or plaintext HTTP trackers to published magnets.
|
||||
- **Private toggle**: Encrypts the listing for invited viewers.
|
||||
- **Private toggle**: Hides the card from shared grids so only you see it and adds a purple accent so the private state stands out.
|
||||
|
||||
### How playback works
|
||||
|
||||
@@ -62,7 +62,6 @@ To run **bitvid** locally:
|
||||
```
|
||||
|
||||
2. Start a local server:
|
||||
|
||||
- Using Python:
|
||||
```bash
|
||||
python -m http.server 8000
|
||||
@@ -73,24 +72,28 @@ To run **bitvid** locally:
|
||||
```
|
||||
|
||||
3. Open the site in your browser:
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
### CSS build pipeline
|
||||
|
||||
Tailwind utilities are generated from `css/tailwind.source.css` using the build
|
||||
script below. Run this whenever you add or remove classes in HTML or JavaScript
|
||||
so the purged output stays in sync:
|
||||
Tailwind utilities are generated from `css/tailwind.source.css` and themed via
|
||||
the shared tokens in `css/tokens.css`. Install dependencies once and lean on the
|
||||
package scripts to keep formatting, linting, and generated output consistent:
|
||||
|
||||
```bash
|
||||
npm install # first run only
|
||||
npm run build:css
|
||||
npm install # install Prettier, Stylelint, and Tailwind toolchain
|
||||
npm run format # format CSS/HTML/JS/MD with Prettier + tailwindcss plugin
|
||||
npm run lint:css # enforce token usage and forbid raw hex colors
|
||||
npm run build:css # rebuild css/tailwind.generated.css from tailwind.source.css
|
||||
npm run check:css # CI-friendly guard that fails if tailwind.generated.css is dirty
|
||||
```
|
||||
|
||||
The command compiles Tailwind with `tailwind.config.cjs`, runs it through the
|
||||
PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and emits
|
||||
the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
|
||||
The build command compiles Tailwind with `tailwind.config.cjs`, runs it through
|
||||
the PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and
|
||||
emits the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
|
||||
regenerated file alongside any template changes so deployments pick up the
|
||||
latest styles.
|
||||
|
||||
@@ -100,6 +103,13 @@ latest styles.
|
||||
- Central place for instance-specific values like the Super Admin npub and the
|
||||
default whitelist-only mode setting. Update the documented exports here when
|
||||
preparing a new deployment.
|
||||
- Tune `PLATFORM_FEE_PERCENT` (0–100) to keep a percentage of Lightning tips.
|
||||
When the fee is positive, BitVid routes the platform’s split to
|
||||
`PLATFORM_LUD16_OVERRIDE`, so set it to the Lightning address that should
|
||||
receive the sats (or publish a `lud16` on the Super Admin profile). Leave
|
||||
the fee at `0` to pass through every satoshi.
|
||||
- Populate `DEFAULT_RELAY_URLS_OVERRIDE` with WSS URLs to replace the bundled
|
||||
relay bootstrap list. Keep it empty to stick with the upstream defaults.
|
||||
- **`config.js`**:
|
||||
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
||||
- **`js/constants.js`**:
|
||||
@@ -163,6 +173,12 @@ Use the manual QA checklist before releases or when altering upload/playback flo
|
||||
|
||||
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
|
||||
|
||||
For automated DOM-focused regression coverage, run the Node-based test suite:
|
||||
|
||||
```bash
|
||||
node tests/profile-modal-controller.test.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -73,6 +73,572 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
id="editNip71EventDetails"
|
||||
data-nip71-section="event-details"
|
||||
>
|
||||
<fieldset
|
||||
id="editNip71KindFieldset"
|
||||
class="space-y-2"
|
||||
data-nip71-fieldset="kind"
|
||||
>
|
||||
<legend class="text-sm font-semibold text-gray-200">
|
||||
NIP-71 event type
|
||||
</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Choose the video format. <span class="font-medium text-gray-300">Kind 21</span> is a full video, while
|
||||
<span class="font-medium text-gray-300">Kind 22</span> is a short.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="editNip71KindFieldset"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindLong"
|
||||
value="21"
|
||||
checked
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Normal video (kind 21)
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindShort"
|
||||
value="22"
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Short video (kind 22)
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
|
||||
<div>
|
||||
<label for="editNip71PublishedAt" class="block text-sm font-medium text-gray-200">
|
||||
Published at
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="editNip71PublishedAt"
|
||||
data-nip71-input="published_at"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Set the canonical publish time for cross-client ordering.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71AltText" class="block text-sm font-medium text-gray-200">
|
||||
Alt text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71AltText"
|
||||
data-nip71-input="alt"
|
||||
placeholder="Describe the visuals for screen readers"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Plain-language fallback shown when the media cannot load.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71Duration" class="block text-sm font-medium text-gray-200">
|
||||
Duration (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="editNip71Duration"
|
||||
min="0"
|
||||
step="1"
|
||||
data-nip71-input="duration"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Provide the runtime in whole seconds for timeline scrubbing.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71ContentWarning" class="block text-sm font-medium text-gray-200">
|
||||
Content warning
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71ContentWarning"
|
||||
data-nip71-input="content-warning"
|
||||
placeholder="e.g., Flashing lights"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Leave blank if the video is safe for all audiences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="editNip71Summary" class="block text-sm font-medium text-gray-200">
|
||||
Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="editNip71Summary"
|
||||
rows="3"
|
||||
data-nip71-input="summary"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Short synopsis used as the Nostr content field"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
This becomes the event content. Keep it concise for Nostr readers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
id="editNip71TagsFieldset"
|
||||
class="space-y-6 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
data-nip71-section="tags"
|
||||
>
|
||||
<legend class="relative text-sm font-semibold text-gray-200">
|
||||
<span class="inline-block -ml-2 rounded px-2 bg-black/30">NIP-71 tags</span>
|
||||
</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Use the controls below to describe media variants, captions, chapters, participants, and references.
|
||||
</p>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-gray-200">Image metadata (imeta)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="imeta"
|
||||
aria-describedby="editNip71ImetaHelp"
|
||||
>
|
||||
Add variant
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71ImetaHelp" class="text-xs text-gray-400">
|
||||
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
|
||||
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
|
||||
</p>
|
||||
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
|
||||
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="imeta">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
MIME type (<code>m</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="m"
|
||||
placeholder="video/mp4"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">RFC 2046 media type for this encoding.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Pixel dimensions (<code>dim</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="dim"
|
||||
placeholder="1920x1080"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Formatted as widthxheight.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Streaming URL (<code>url</code>)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/variant.m3u8"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">HTTPS variant URL shared across clients.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Magnet (<code>x</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="x"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Optional magnet pointer if the variant is seeded via P2P.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="image">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Image values</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="image"
|
||||
>
|
||||
Add image URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="image"></div>
|
||||
<template data-nip71-nested-template="image">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.example.com/poster.jpg"
|
||||
data-nip71-nested-field="image"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove image URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="fallback">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Fallback URLs</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="fallback"
|
||||
>
|
||||
Add fallback
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
|
||||
<template data-nip71-nested-template="fallback">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.backup.net/video.mp4"
|
||||
data-nip71-nested-field="fallback"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove fallback URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="service">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Services</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="service"
|
||||
>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="service"></div>
|
||||
<template data-nip71-nested-template="service">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.example.com"
|
||||
data-nip71-nested-field="service"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove service entry"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="imeta"
|
||||
>
|
||||
Remove variant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-gray-200">
|
||||
Caption & subtitle tracks (text-track)
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="text-track"
|
||||
aria-describedby="editNip71TextTracksHelp"
|
||||
>
|
||||
Add track
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71TextTracksHelp" class="text-xs text-gray-400">
|
||||
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
|
||||
</p>
|
||||
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
|
||||
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="text-track">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Track URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/subs.vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="type"
|
||||
placeholder="text/vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="language"
|
||||
placeholder="en"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="text-track"
|
||||
>
|
||||
Remove track
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-gray-200">Chapters & segments (segment)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="segment"
|
||||
aria-describedby="editNip71SegmentsHelp"
|
||||
>
|
||||
Add segment
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71SegmentsHelp" class="text-xs text-gray-400">
|
||||
Outline chapters with start and end times, titles, and optional thumbnails.
|
||||
</p>
|
||||
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
|
||||
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="segment">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Start (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="start"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">End (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="end"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="title"
|
||||
placeholder="Chapter title"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Thumbnail URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="thumbnail"
|
||||
placeholder="https://cdn.example.com/frame.jpg"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="segment"
|
||||
>
|
||||
Remove segment
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-gray-200">Hashtags (<code>t</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="t"
|
||||
>
|
||||
Add hashtag
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
|
||||
</p>
|
||||
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
|
||||
<template id="editNip71HashtagTemplate" data-nip71-template="t">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="t">
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="value"
|
||||
placeholder="#nostr"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="t"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-gray-200">Participants (<code>p</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="p"
|
||||
>
|
||||
Add participant
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
|
||||
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="p">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Participant pubkey</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="pubkey"
|
||||
placeholder="npub1... or hex"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Relay hint</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="relay"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="p"
|
||||
>
|
||||
Remove participant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-gray-200">Reference links (<code>r</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="r"
|
||||
>
|
||||
Add reference
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
|
||||
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="r">
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://example.com/context"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="r"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
|
||||
Hosted video URL (https)
|
||||
@@ -199,6 +765,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<span id="editVideoVisibilityLabel" class="block text-sm font-medium text-gray-200">
|
||||
Visibility
|
||||
</span>
|
||||
<div
|
||||
class="space-y-3 rounded-md border border-gray-800 bg-black/30 p-3"
|
||||
role="group"
|
||||
aria-labelledby="editVideoVisibilityLabel"
|
||||
data-visibility-toggle
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-between">
|
||||
<div
|
||||
class="video-visibility-toggle"
|
||||
data-visibility-options
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="video-visibility-toggle__option"
|
||||
data-visibility-option="public"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="video-visibility-toggle__option"
|
||||
data-visibility-option="private"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editVideoIsPrivate"
|
||||
class="hidden"
|
||||
data-default-checked="false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400" data-visibility-helper>
|
||||
Private notes stay visible only to you. Switch back to public when you're ready to share.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoIsPrivate"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editEnableComments" class="block text-sm font-medium text-gray-200">
|
||||
Enable comments
|
||||
@@ -211,6 +830,8 @@
|
||||
type="checkbox"
|
||||
id="editEnableComments"
|
||||
class="h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
data-default-checked="true"
|
||||
checked
|
||||
/>
|
||||
<span class="text-sm text-gray-300">
|
||||
Allow replies once comment threads are available.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Whitelist Application Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Bug Report Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Content Appeals Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid General Feedback Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Feature Request Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
|
||||
+172
-11
@@ -79,6 +79,15 @@
|
||||
>
|
||||
Relays
|
||||
</button>
|
||||
<button
|
||||
id="profileNavWallet"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneWallet"
|
||||
aria-selected="false"
|
||||
>
|
||||
Wallet
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
@@ -243,6 +252,71 @@
|
||||
<ul id="relayList" class="space-y-3"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneWallet"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavWallet"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from BitVid.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm text-gray-300" for="profileWalletUri">
|
||||
<span class="mb-2 block font-medium">Wallet Connect URI</span>
|
||||
<input
|
||||
id="profileWalletUri"
|
||||
type="url"
|
||||
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-sm text-gray-300" for="profileWalletDefaultZap">
|
||||
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
|
||||
<input
|
||||
id="profileWalletDefaultZap"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="500"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="profileWalletSave"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Save wallet
|
||||
</button>
|
||||
<button
|
||||
id="profileWalletTest"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Test connection
|
||||
</button>
|
||||
<button
|
||||
id="profileWalletDisconnect"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Disconnect wallet
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileWalletStatus"
|
||||
class="text-sm text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
@@ -290,19 +364,106 @@
|
||||
<p class="text-sm text-gray-400">
|
||||
Your recently watched videos appear here so you can jump back into favorites.
|
||||
</p>
|
||||
<div
|
||||
id="profileHistoryFeatureBanner"
|
||||
class="hidden rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
id="profileHistoryLoading"
|
||||
class="flex items-center justify-center gap-3 py-6"
|
||||
>
|
||||
<div class="h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p
|
||||
id="profileHistoryStatus"
|
||||
class="text-sm text-gray-300 hidden"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
></p>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Watch history actions"
|
||||
>
|
||||
<button
|
||||
id="profileHistoryClear"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Clear local cache
|
||||
</button>
|
||||
<button
|
||||
id="profileHistoryRepublish"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Republish now
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
id="profileHistoryMetadataLabel"
|
||||
class="text-sm font-medium text-gray-200"
|
||||
>
|
||||
Store titles/thumbnails locally
|
||||
</span>
|
||||
<span
|
||||
id="profileHistoryMetadataDescription"
|
||||
class="text-xs text-gray-400"
|
||||
>
|
||||
When disabled, BitVid skips saving rich metadata to this device.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
id="profileHistoryMetadataToggle"
|
||||
type="button"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer items-center rounded-full border border-gray-600 bg-gray-700 transition focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
role="switch"
|
||||
aria-checked="true"
|
||||
aria-labelledby="profileHistoryMetadataLabel"
|
||||
aria-describedby="profileHistoryMetadataDescription"
|
||||
>
|
||||
<span class="sr-only">Toggle local metadata storage</span>
|
||||
<span
|
||||
id="profileHistoryMetadataThumb"
|
||||
class="inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white transition"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="profileHistoryToastRegion"
|
||||
class="space-y-2"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
></div>
|
||||
<div
|
||||
id="profileHistorySessionWarning"
|
||||
class="hidden inline-flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-100"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Session actor in use. Connect a NIP-07 extension to publish as your main profile.
|
||||
</div>
|
||||
<div id="profileHistoryLoading" class="py-6">
|
||||
<div id="profileHistoryStatus">
|
||||
<div
|
||||
class="sidebar-loading-wrapper"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="sidebar-loading-indicator">
|
||||
<span
|
||||
class="status-spinner status-spinner--inline"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sidebar-loading-text"
|
||||
>Fetching watch history from relays…</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="profileHistoryError"
|
||||
class="hidden rounded-md border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-100"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
></div>
|
||||
<div
|
||||
id="profileHistoryEmpty"
|
||||
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
|
||||
|
||||
@@ -87,8 +87,64 @@
|
||||
class="rounded-lg border border-gray-800 bg-gray-900/70 p-4 text-sm text-gray-200"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div id="revertVersionPlaceholder" class="flex h-full items-center justify-center text-center text-gray-500">
|
||||
Select an earlier revision to inspect its metadata.
|
||||
<!-- NOTE: Keep Upload/Edit metadata fields mirrored when adjusting these sections. -->
|
||||
<div class="space-y-6" aria-labelledby="revertVersionPlaceholderHeading">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 id="revertVersionPlaceholderHeading" class="text-sm font-semibold text-gray-200">
|
||||
Revision metadata
|
||||
</h3>
|
||||
<p
|
||||
id="revertVersionPlaceholder"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
Select an earlier revision to inspect its metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Playback sources
|
||||
</h4>
|
||||
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Hosted URL</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Magnet</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
NIP-71 event metadata
|
||||
</h4>
|
||||
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Kind</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Duration</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="font-semibold text-gray-300">Summary</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
NIP-71 media metadata
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Media variants, text tracks, chapters, hashtags, participants, and references will appear once a revision is selected.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+134
-122
@@ -5,150 +5,174 @@
|
||||
-->
|
||||
<aside
|
||||
id="sidebar"
|
||||
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto fade-in fade-in-delay-100"
|
||||
class="fade-in fade-in-delay-100 sidebar-collapsed"
|
||||
data-state="collapsed"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Top Navigation Links -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<div class="bg-gray-800 rounded-lg shadow-md mt-4 p-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a
|
||||
href="#view=most-recent-videos"
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
<div class="sidebar__inner">
|
||||
<div class="sidebar-scroll">
|
||||
<!-- Primary navigation -->
|
||||
<section class="sidebar-panel sidebar-panel--primary">
|
||||
<div class="sidebar-panel__header">
|
||||
<button
|
||||
id="sidebarCollapseToggle"
|
||||
type="button"
|
||||
class="sidebar-collapse-toggle"
|
||||
aria-controls="sidebar"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<svg
|
||||
data-sidebar-chevron
|
||||
class="sidebar-collapse-toggle__icon sidebar-toggle-icon--rotated"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav" aria-label="Primary">
|
||||
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recent">
|
||||
<img
|
||||
src="assets/svg/recent-icon.svg"
|
||||
alt="Recent"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Recent</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=explore"
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
>
|
||||
<a href="#view=explore" class="sidebar-nav-link" title="Explore">
|
||||
<img
|
||||
src="assets/svg/explore-icon.svg"
|
||||
alt="Explore"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Explore</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=history"
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
>
|
||||
<a href="#view=history" class="sidebar-nav-link" title="History">
|
||||
<img
|
||||
src="assets/svg/history-icon.svg"
|
||||
alt="History"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">History</span>
|
||||
</a>
|
||||
<a
|
||||
id="subscriptionsLink"
|
||||
href="#view=subscriptions"
|
||||
class="hidden flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
class="sidebar-nav-link hidden"
|
||||
title="Subscriptions"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/subscriptions-icon.svg"
|
||||
alt="Subscriptions"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Subscriptions</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer Links Toggle -->
|
||||
<div class="md:mt-auto">
|
||||
<div class="p-4">
|
||||
<!-- Footer Links Toggle -->
|
||||
<footer class="sidebar-footer" data-footer-state="collapsed">
|
||||
<button
|
||||
id="footerDropdownButton"
|
||||
type="button"
|
||||
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
|
||||
class="sidebar-dropup-trigger"
|
||||
aria-expanded="false"
|
||||
aria-controls="footerLinksContainer"
|
||||
>
|
||||
<!-- Arrow on the left side -->
|
||||
<img
|
||||
src="assets/svg/ellipsis.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon sidebar-dropup-trigger__glyph"
|
||||
/>
|
||||
<span id="footerDropdownText" class="sidebar-text">More</span>
|
||||
<svg
|
||||
id="footerDropdownIcon"
|
||||
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
||||
class="sidebar-dropup-trigger__chevron"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span id="footerDropdownText">More</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links Container -->
|
||||
<div id="footerLinksContainer" class="hidden">
|
||||
<hr class="border-gray-700" />
|
||||
<div class="p-4">
|
||||
<nav class="space-y-2 text-sm opacity-70">
|
||||
<a
|
||||
href="#view=about"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<!-- Footer Links Container -->
|
||||
<div
|
||||
id="footerLinksContainer"
|
||||
class="sidebar-dropup-panel"
|
||||
aria-hidden="true"
|
||||
data-state="collapsed"
|
||||
>
|
||||
<nav class="sidebar-dropup-panel__inner" aria-label="Secondary">
|
||||
<a href="#view=about" class="sidebar-nav-link sidebar-nav-link--sub" title="About">
|
||||
<img
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">About</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=community-guidelines"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Community Guidelines"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Guidelines</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=getting-started"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Getting Started"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Getting Started</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=roadmap"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=roadmap" class="sidebar-nav-link sidebar-nav-link--sub" title="Roadmap">
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=blog" class="sidebar-nav-link sidebar-nav-link--sub" title="Blog">
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
@@ -156,12 +180,14 @@
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Nostr"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
@@ -169,12 +195,14 @@
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="GitHub"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
@@ -182,12 +210,14 @@
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="βeacon"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
@@ -195,34 +225,32 @@
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Beta"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=links" class="sidebar-nav-link sidebar-nav-link--sub" title="Links">
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=ipns"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=ipns" class="sidebar-nav-link sidebar-nav-link--sub" title="IPNS">
|
||||
<img
|
||||
src="assets/svg/ipns-icon.svg"
|
||||
alt="IPNS"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">IPNS</span>
|
||||
</a>
|
||||
@@ -230,36 +258,20 @@
|
||||
href="https://bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="DNS"
|
||||
>
|
||||
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
||||
<img
|
||||
src="assets/svg/dns-icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">DNS</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById("footerDropdownButton");
|
||||
var footerLinks = document.getElementById("footerLinksContainer");
|
||||
if (btn && footerLinks) {
|
||||
btn.addEventListener("click", function () {
|
||||
footerLinks.classList.toggle("hidden");
|
||||
var isHidden = footerLinks.classList.contains("hidden");
|
||||
btn.setAttribute("aria-expanded", String(!isHidden));
|
||||
var icon = document.getElementById("footerDropdownIcon");
|
||||
var textSpan = document.getElementById("footerDropdownText");
|
||||
if (textSpan) {
|
||||
textSpan.textContent = isHidden ? "More" : "Less";
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.toggle("rotate-180", !isHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</aside>
|
||||
+1067
-124
File diff suppressed because it is too large.
Load diff
+154
-14
@@ -35,18 +35,124 @@
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="modalZapBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Send a zap"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div class="relative" data-modal-zap-wrapper>
|
||||
<button
|
||||
id="modalZapBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Send a zap"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls="modalZapDialog"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
id="modalZapDialog"
|
||||
class="zap-popover hidden"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="zap-popover__content w-72 max-w-[calc(100vw-2rem)] rounded-lg p-4 shadow-lg"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Send a zap
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapCloseBtn"
|
||||
class="text-gray-400 transition-colors hover:text-gray-200"
|
||||
aria-label="Close zap dialog"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapWalletPrompt"
|
||||
class="mb-3 hidden text-sm text-gray-200"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Connect a wallet in
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapWalletLink"
|
||||
class="font-semibold text-blue-300 underline decoration-dotted transition-colors hover:text-blue-200"
|
||||
>
|
||||
Wallet Connect settings
|
||||
</button>
|
||||
to send zaps.
|
||||
</p>
|
||||
<form id="modalZapForm" class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="modalZapAmountInput"
|
||||
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Zap amount (sats)
|
||||
</label>
|
||||
<input
|
||||
id="modalZapAmountInput"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="w-full rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
placeholder="Enter sats"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="modalZapCommentInput"
|
||||
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="modalZapCommentInput"
|
||||
rows="2"
|
||||
maxlength="280"
|
||||
class="w-full resize-y rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
placeholder="Include a note with your zap"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapSplitSummary"
|
||||
class="text-sm text-gray-300"
|
||||
aria-live="polite"
|
||||
>
|
||||
Enter an amount to view the split.
|
||||
</p>
|
||||
<p
|
||||
id="modalZapStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<ul
|
||||
id="modalZapReceipts"
|
||||
class="space-y-2 text-xs text-gray-300"
|
||||
></ul>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
id="modalZapSendBtn"
|
||||
class="rounded bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="shareBtn" class="icon-button">
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
@@ -90,6 +196,33 @@
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<div
|
||||
class="px-4 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Boost on Nostr…
|
||||
</div>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="repost-event"
|
||||
data-context="modal"
|
||||
>
|
||||
Repost (kind 6)
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="mirror-video"
|
||||
data-context="modal"
|
||||
>
|
||||
Mirror (kind 1063)
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="ensure-presence"
|
||||
data-context="modal"
|
||||
>
|
||||
Rebroadcast
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-700 opacity-70"></div>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="blacklist-author"
|
||||
@@ -131,9 +264,16 @@
|
||||
|
||||
<!-- Video Timestamp -->
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-gray-400 mb-4"
|
||||
class="flex items-start justify-between text-sm text-gray-400 mb-4"
|
||||
>
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<div class="flex flex-wrap items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">Posted just now</span>
|
||||
<span
|
||||
id="videoEditedTimestamp"
|
||||
class="hidden"
|
||||
>Last edited just now</span>
|
||||
<span id="videoViewCount" class="modal-view-count">– views</span>
|
||||
</div>
|
||||
<div id="modalStatus" class="text-gray-300">
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ASSET_VERSION = "2024.10.29";
|
||||
+141
-6
@@ -25,6 +25,67 @@
|
||||
export const ADMIN_SUPER_NPUB =
|
||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
|
||||
|
||||
/**
|
||||
* Canonical URL for the public BitVid site.
|
||||
*
|
||||
* Surfaces in admin outreach copy and moderation DMs so that recipients can
|
||||
* quickly jump back to the primary destination. Update this if your
|
||||
* deployment relies on a different canonical hostname.
|
||||
*/
|
||||
export const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||
|
||||
/**
|
||||
* Default image included in automated moderation DMs.
|
||||
*
|
||||
* BitVid embeds this media asset at the top of notification messages so the
|
||||
* payload renders with a recognizable preview. Provide a fully qualified URL
|
||||
* that points to a hosted image accessible by the intended recipients.
|
||||
*/
|
||||
export const ADMIN_DM_IMAGE_URL =
|
||||
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
||||
|
||||
/**
|
||||
* Maximum satoshi value allowed when storing the default zap amount.
|
||||
*
|
||||
* Wallet settings clamp user input to this value before persistence to guard
|
||||
* against accidental overpayment. Tune the ceiling to match your instance's
|
||||
* risk tolerance.
|
||||
*/
|
||||
export const MAX_WALLET_DEFAULT_ZAP = 100000000;
|
||||
|
||||
/**
|
||||
* Percentage of every Lightning payment the platform retains as a fee.
|
||||
*
|
||||
* Accepts numbers between 0 and 100 (inclusive). Decimals are supported when
|
||||
* you want to keep a fractional cut—e.g., `2.5` represents a 2.5% fee. The
|
||||
* default of `0` disables the fee so creators receive the full payment.
|
||||
*/
|
||||
export const PLATFORM_FEE_PERCENT = 30;
|
||||
|
||||
/**
|
||||
* Lightning address to fall back to when authors omit their own `lud16`.
|
||||
*
|
||||
* Supply a string like `"[email protected]"` to force a deployment-wide
|
||||
* fallback Lightning address. When `PLATFORM_FEE_PERCENT` is greater than 0,
|
||||
* this override also acts as the Lightning target for the platform’s split.
|
||||
* Leave the value as `null` to respect the creator’s metadata and rely on the
|
||||
* Super Admin profile publishing a `lud16` so BitVid still knows where to route
|
||||
* fees when they are enabled.
|
||||
*/
|
||||
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
|
||||
|
||||
/**
|
||||
* Optional list of relays to seed new sessions with instead of the defaults.
|
||||
*
|
||||
* Provide WSS URLs (e.g., `"wss://relay.example.com"`). Leave the array empty
|
||||
* to keep BitVid’s bundled defaults. Operators that need a custom bootstrap set
|
||||
* should list the relays in priority order; entries later in the list are used
|
||||
* as fallbacks when earlier relays fail.
|
||||
*/
|
||||
export const DEFAULT_RELAY_URLS_OVERRIDE = Object.freeze([
|
||||
// "wss://relay.example.com",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Storage key used to persist whitelist-only mode in the browser.
|
||||
*
|
||||
@@ -48,12 +109,15 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
||||
/**
|
||||
* Nostr kind used when persisting watch history events.
|
||||
*
|
||||
* BitVid’s roadmap standardizes on kind 30078 so that watch events, view logs,
|
||||
* and media metadata stay in the same family of documents. Operators that want
|
||||
* to experiment with a separate list kind (for example, a NIP-51 collection)
|
||||
* can flip this number so long as their relays accept the chosen kind.
|
||||
* BitVid’s roadmap standardizes on kind 30079 so that watch events, view logs,
|
||||
* and media metadata stay in the same family of documents. During the rollout
|
||||
* from the legacy 30078 payloads, clients query both kinds so historical data
|
||||
* keeps syncing; remove the compatibility fetch once all writers emit the new
|
||||
* kind. Operators that want to experiment with a separate list kind (for
|
||||
* example, a NIP-51 collection) can flip this number so long as their relays
|
||||
* accept the chosen kind.
|
||||
*/
|
||||
export const WATCH_HISTORY_KIND = 30078;
|
||||
export const WATCH_HISTORY_KIND = 30079;
|
||||
|
||||
/**
|
||||
* Identifier applied to the watch-history list when storing it on relays.
|
||||
@@ -65,6 +129,25 @@ export const WATCH_HISTORY_KIND = 30078;
|
||||
*/
|
||||
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history";
|
||||
|
||||
/**
|
||||
* Legacy identifiers that BitVid clients should continue honoring when
|
||||
* fetching historical watch-history snapshots.
|
||||
*/
|
||||
export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([
|
||||
"watch-history:v2:index",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether to enable the V2 encrypted watch-history service.
|
||||
*
|
||||
* The runtime flag that controls this feature defaults to `false` so that new
|
||||
* deployments stick with the analytics-only view flow until operators opt in.
|
||||
* To enable V2, set `window.__BITVID_RUNTIME_FLAGS__.FEATURE_WATCH_HISTORY_V2 = true`
|
||||
* in a bootstrap script (or override the value before the app loads). When the
|
||||
* flag stays off, BitVid still emits legacy view events and will honor
|
||||
* existing watch-history reads per plan §12, but the sync UI will surface a
|
||||
* disabled banner instead of querying relays.
|
||||
*/
|
||||
/**
|
||||
* Maximum number of watch-history entries to retain per user.
|
||||
*
|
||||
@@ -84,6 +167,17 @@ export const WATCH_HISTORY_MAX_ITEMS = 1500;
|
||||
*/
|
||||
export const WATCH_HISTORY_BATCH_RESOLVE = true;
|
||||
|
||||
/**
|
||||
* Maximum number of canonical items each batched watch-history response should
|
||||
* include.
|
||||
*
|
||||
* Leave this as `null` (the default) to return the full
|
||||
* `WATCH_HISTORY_MAX_ITEMS` window, even when batching is enabled. Deployments
|
||||
* that page through histories in smaller slices can set a positive integer to
|
||||
* cap the resolver output so pagination and API responses stay aligned.
|
||||
*/
|
||||
export const WATCH_HISTORY_BATCH_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Maximum size of the JSON payload for each watch-history chunk, measured
|
||||
* before encryption.
|
||||
@@ -102,7 +196,7 @@ export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000;
|
||||
* the maximum number of chunks you expect a client to publish in one snapshot
|
||||
* so the UI can stitch the full list back together.
|
||||
*/
|
||||
export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12;
|
||||
export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 64;
|
||||
|
||||
/**
|
||||
* How long clients should cache watch-history snapshots in localStorage.
|
||||
@@ -113,3 +207,44 @@ export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12;
|
||||
* retention guarantees.
|
||||
*/
|
||||
export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long the view counter should treat repeat plays as duplicates.
|
||||
*
|
||||
* BitVid de-duplicates view events that occur within this rolling window so
|
||||
* quick refreshes or stalled replays do not inflate the totals. The default of
|
||||
* 24 hours mirrors common analytics tooling, but you can tighten or relax the
|
||||
* window depending on how aggressively you want to filter repeat traffic.
|
||||
*/
|
||||
export const VIEW_COUNT_DEDUPE_WINDOW_SECONDS = 86_400;
|
||||
|
||||
/**
|
||||
* How far back the view counter should hydrate historical events during
|
||||
* backfills.
|
||||
*
|
||||
* When a new analytics worker starts up, it can walk older Nostr events to
|
||||
* reconstruct totals. Limiting the backfill horizon keeps catch-up jobs
|
||||
* bounded—90 days covers recent trends without hammering relays for year-old
|
||||
* history. Increase the number if you need deeper analytics or shrink it for
|
||||
* lighter start-up workloads.
|
||||
*/
|
||||
export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90;
|
||||
|
||||
/**
|
||||
* How long clients can trust cached view totals before re-fetching.
|
||||
*
|
||||
* Cached results smooth out traffic spikes and reduce relay load. Five minutes
|
||||
* strikes a balance between responsiveness and efficiency; lower the TTL for
|
||||
* fresher dashboards or raise it if your analytics traffic is heavy.
|
||||
*/
|
||||
export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cooldown window (in seconds) between manual "Rebroadcast" attempts.
|
||||
*
|
||||
* Rebroadcasting is an escape hatch for creators to nudge stale relays. A
|
||||
* short cooldown keeps this safeguard from turning into an accidental DDoS.
|
||||
* Five minutes provides enough breathing room for relays to catch up while
|
||||
* still letting operators retry if a publish genuinely failed.
|
||||
*/
|
||||
export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60;
|
||||
@@ -0,0 +1,85 @@
|
||||
// config/validate-config.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// Runtime validation helpers for instance-level configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
} from "./instance-config.js";
|
||||
|
||||
function assertSuperAdminConfigured(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB must be a string exported from config/instance-config.js."
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB is required. Populate it with the Super Admin npub in config/instance-config.js."
|
||||
);
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("npub")) {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB should be a bech32 npub (e.g., starting with 'npub')."
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function assertPlatformFeeInRange(value) {
|
||||
const numericValue =
|
||||
typeof value === "number" ? value : Number.parseFloat(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
throw new Error(
|
||||
"PLATFORM_FEE_PERCENT must be a finite number between 0 and 100."
|
||||
);
|
||||
}
|
||||
|
||||
if (numericValue < 0 || numericValue > 100) {
|
||||
throw new Error("PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive.");
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function getPlatformLud16Override(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function resolveConfig(overrides = {}) {
|
||||
return {
|
||||
adminSuperNpub:
|
||||
overrides.ADMIN_SUPER_NPUB ?? ADMIN_SUPER_NPUB,
|
||||
platformFeePercent:
|
||||
overrides.PLATFORM_FEE_PERCENT ?? PLATFORM_FEE_PERCENT,
|
||||
platformLud16Override:
|
||||
overrides.PLATFORM_LUD16_OVERRIDE ?? PLATFORM_LUD16_OVERRIDE,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInstanceConfig(overrides) {
|
||||
const config = resolveConfig(overrides);
|
||||
assertSuperAdminConfigured(config.adminSuperNpub);
|
||||
const platformFeePercent = assertPlatformFeeInRange(
|
||||
config.platformFeePercent
|
||||
);
|
||||
const trimmedOverride = getPlatformLud16Override(
|
||||
config.platformLud16Override
|
||||
);
|
||||
|
||||
if (platformFeePercent > 0 && !trimmedOverride) {
|
||||
throw new Error(
|
||||
"PLATFORM_FEE_PERCENT is positive but PLATFORM_LUD16_OVERRIDE is empty. Set PLATFORM_LUD16_OVERRIDE to the Lightning address that should receive the platform's split, or publish a lud16 value on the Super Admin profile so BitVid can route platform fees."
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
- Fix "Dev Mode" publishing "Live Mode" notes—add a flag for dev mode posts.
|
||||
- Fix issue with video post set to private.
|
||||
- Disable "private video" until I can make it work better.
|
||||
- Make "private video" work better using nip-04 encryption for magnet field.
|
||||
- Make "private video" work better by hiding cards from shared grids while keeping an owner-only view.
|
||||
- Fix slow back button issues on Firefox.
|
||||
- Add Amber login support for mobile.
|
||||
- Add settings (three dots) button for all videos, not the same as gear menu. Only show delete and edit gear for logged-in users videos.
|
||||
|
||||
@@ -31,7 +31,7 @@ This new file could export:
|
||||
- `buildNewNote(data, pubkey)`: Takes basic form data and returns a fully structured Nostr note (an object) ready to be signed.
|
||||
- `buildEditNote(originalEvent, updatedData)`: Merges old note content with new fields.
|
||||
- `softDeleteNote(originalEvent)`: Constructs a note with `deleted = true`.
|
||||
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Real or placeholder encryption functions.
|
||||
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Legacy pass-through helpers kept in case encryption returns; private listings now hide rather than encrypt.
|
||||
- `validateNoteContent(content)`: Ensures essential fields (title, magnet, mode, etc.) are present and valid.
|
||||
|
||||
Because you’re not implementing Version 3 yet, keep your existing version logic. If you do plan to adopt Version 3 later, the new file is where you’d add or change fields without scattering edits across multiple files.
|
||||
@@ -53,7 +53,7 @@ export function prepareNewNote(formInput, pubkey) {
|
||||
// Combine user inputs with defaults
|
||||
const isPrivate = formInput.isPrivate === true;
|
||||
const finalMagnet = isPrivate
|
||||
? encryptMagnet(formInput.magnet)
|
||||
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
|
||||
: formInput.magnet;
|
||||
|
||||
return {
|
||||
@@ -178,7 +178,7 @@ By delegating the actual note-building to `buildEditNote` and `buildDeleteNote`,
|
||||
|
||||
6. **Test Thoroughly:**
|
||||
- Create, edit, and delete events to ensure everything behaves the same.
|
||||
- Confirm that private videos are still encrypted as expected.
|
||||
- Confirm that private videos stay hidden from shared grids as expected.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,13 +189,13 @@ Below is a small outline showing how you might organize the new file. The actual
|
||||
```js
|
||||
// bitvidNoteSpec.js
|
||||
|
||||
// A placeholder or real encryption method
|
||||
// Legacy placeholder kept for future encryption experiments (currently returns the raw magnet)
|
||||
export function encryptMagnet(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
return magnet; // private cards are hidden instead of encrypted
|
||||
}
|
||||
|
||||
export function decryptMagnet(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
return magnet; // private cards are hidden instead of encrypted
|
||||
}
|
||||
|
||||
function generateUniqueDTag() {
|
||||
@@ -206,7 +206,7 @@ function generateUniqueDTag() {
|
||||
export function prepareNewNote(formInput, pubkey) {
|
||||
const isPrivate = formInput.isPrivate === true;
|
||||
const finalMagnet = isPrivate
|
||||
? encryptMagnet(formInput.magnet)
|
||||
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
|
||||
: formInput.magnet;
|
||||
|
||||
return {
|
||||
@@ -234,7 +234,7 @@ export function prepareNewNote(formInput, pubkey) {
|
||||
export function buildEditNote(originalEvent, updatedData) {
|
||||
// parse old content
|
||||
// combine with new fields
|
||||
// handle old vs new encryption
|
||||
// handle legacy visibility toggles / future encryption experiments
|
||||
// return the final event object
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -43,19 +43,19 @@ A typical event:
|
||||
| `videoRootId` | String (optional) | A stable root ID used to link multiple versions (edits) and a delete event together. **Recommended** for overshadow logic. |
|
||||
| `version` | Integer | Now set to `3`. |
|
||||
| `deleted` | Boolean | `true` marks the post as “soft deleted.” |
|
||||
| `isPrivate` | Boolean | Indicates if `magnet` (and possibly other fields) are encrypted. |
|
||||
| `isPrivate` | Boolean | Indicates the card should stay off shared grids and remain visible only to its owner; payloads stay plaintext. |
|
||||
| `title` | String | Display title for the media. |
|
||||
| `magnet` | String | Magnet link for primary media (encrypted if `isPrivate = true`). |
|
||||
| `magnet` | String | Magnet link for primary media (still stored in plaintext even when `isPrivate = true`). |
|
||||
| `extraMagnets` | Array (optional) | Additional magnet links (e.g., multiple resolutions). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `description` | String (optional) | A textual description (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image. |
|
||||
| `description` | String (optional) | A textual description. |
|
||||
| `mode` | String | Typically `live` or `dev`. |
|
||||
| `adult` | Boolean (optional) | `true` if content is adult-only. Default: `false` or omitted. |
|
||||
| `categories` | Array (optional) | Array of categories, e.g. `["comedy", "music"]`. |
|
||||
| `language` | String (optional) | Language code (e.g. `"en"`, `"es"`). |
|
||||
| `payment` | String (optional) | Monetization field (e.g. a Lightning address). |
|
||||
| `i18n` | Object (optional) | Internationalization map (e.g. `{"title_en": "...", "description_es": "..."}`). |
|
||||
| `encryptedMeta` | Boolean (optional) | Indicates if fields like `description` or `thumbnail` are encrypted. |
|
||||
| `encryptedMeta` | Boolean (optional) | Legacy flag from earlier encryption experiments; leave `false` unless a future spec revives encrypted metadata. |
|
||||
| **Audio/Podcast-Specific Fields** | | |
|
||||
| `contentType` | String (optional) | E.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
||||
| `albumName` | String (optional) | Name of the album (for music). |
|
||||
@@ -194,7 +194,7 @@ A typical event:
|
||||
### **8.2 Deletion**
|
||||
|
||||
1. **deleted = true**: Mark the item as deleted in the `content` JSON.
|
||||
2. **Remove or encrypt** sensitive fields (`magnet`, `description`, etc.).
|
||||
2. **Clear** sensitive fields (`magnet`, `description`, etc.) if you do not want them mirrored in older clients.
|
||||
3. **videoRootId**: Must remain the same as the original so clients remove/overshadow the old item.
|
||||
4. **subscribeVideos** Logic:
|
||||
```js
|
||||
@@ -217,7 +217,7 @@ A typical event:
|
||||
1. **Adult Content**: If `adult = true`, clients typically hide the post unless the user enables adult content in settings.
|
||||
2. **Categories**: Provide optional grouping or searching by `categories`.
|
||||
3. **Language**: If specified, clients can filter by language.
|
||||
4. **Encryption**: If `isPrivate = true`, some fields (e.g., `magnet`) may need client-side decryption.
|
||||
4. **Privacy handling**: If `isPrivate = true`, clients should hide the card from shared grids instead of attempting client-side decryption.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+787
-161
File diff suppressed because it is too large.
Load diff
@@ -1 +1,3 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@mediLine truncated
|
||||
:root{--color-page:#fff;--color-page-alt:#f9fafb;--color-surface:#0f172a;--color-surface-alt:#111827;--color-bg:var(--color-surface);--color-card:#1e293b;--color-card-private:#3b1d74;--color-card-critical:#3f0b13;--color-panel:#1f2937;--color-panel-hover:#334155;--color-border:#374151;--color-border-subtle:#d1d5db;--color-primary:#fe0032;--color-secondary:#ff93a5;--color-info:#60a5fa;--color-info-strong:#2563eb;--color-info-pressed:#1d4ed8;--color-muted-strong:#6b7280;--color-text:#f8fafc;--color-text-strong:#0f172a;--color-muted:#94a3b8;--color-muted-alt:#9ca3af;--color-subtle-text:#e2e8f0;--color-subtle:#e5e7eb;--color-critical:#7f1d1d;--color-critical-strong:#991b1b;--color-neutral-700:#3f3f46;--color-neutral-600:#52525b;--color-white:#fff;--color-black:#000;--color-overlay:#1a2234;--space-2xs:0.25rem;--space-xs:0.5rem;--space-sm:0.75rem;--space-md:1rem;--space-lg:1.5rem;--space-xl:2rem;--space-2xl:3rem;--radius-sm:0.25rem;--radius-md:0.5rem;--radius-lg:0.75rem;--radius-xl:1rem;--radius-full:9999px;--shadow-sm:0 0.0625rem 0.125rem 0 rgb(0 0 0/5%);--shadow-md:0 0.25rem 0.375rem -0.0625rem rgba(0,0,0,.1);--shadow-lg:0 0.625rem 0.9375rem -0.1875rem rgba(0,0,0,.1);--shadow-modal:0 1.25rem 2.5rem -0.375rem rgba(15,23,42,.55);--font-family-sans:"Inter","Segoe UI",system-ui,-apple-system,sans-serif;--font-family-mono:"Fira Code","SFMono-Regular",ui-monospace,monospace;--font-size-xs:0.75rem;--font-size-sm:0.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.5rem;--font-size-2xl:2rem;--line-height-tight:1.1;--line-height-snug:1.25;--line-height-base:1.5;--line-height-relaxed:1.75;--motion-duration-fast:120ms;--motion-duration-base:160ms;--motion-duration-slow:280ms;--motion-ease-out:cubic-bezier(0.16,1,0.3,1);--motion-ease-in-out:cubic-bezier(0.4,0,0.2,1);--modal-motion-duration:var(--motion-duration-base)}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.bottom-4{bottom:1rem}.bottom-full{bottom:100%}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-12{top:3rem}.top-14{top:3.5rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[60\]{z-index:60}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-0{margin-top:0;margin-bottom:0}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-2{margin-left:-.5rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.box-border{box-sizing:border-box}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-4{height:1rem}.h-48{height:12rem}Line truncated
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
/* Color tokens */
|
||||
--color-page: rgb(255 255 255);
|
||||
--color-page-alt: rgb(249 250 251);
|
||||
--color-surface: rgb(15 23 42);
|
||||
--color-surface-alt: rgb(17 24 39);
|
||||
--color-bg: var(--color-surface);
|
||||
--color-card: rgb(30 41 59);
|
||||
--color-card-private: rgb(59 29 116);
|
||||
--color-card-critical: rgb(63 11 19);
|
||||
--color-panel: rgb(31 41 55);
|
||||
--color-panel-hover: rgb(51 65 85);
|
||||
--color-border: rgb(55 65 81);
|
||||
--color-border-subtle: rgb(209 213 219);
|
||||
--color-primary: rgb(254 0 50);
|
||||
--color-secondary: rgb(255 147 165);
|
||||
--color-info: rgb(96 165 250);
|
||||
--color-info-strong: rgb(37 99 235);
|
||||
--color-info-pressed: rgb(29 78 216);
|
||||
--color-muted-strong: rgb(107 114 128);
|
||||
--color-text: rgb(248 250 252);
|
||||
--color-text-strong: rgb(15 23 42);
|
||||
--color-muted: rgb(148 163 184);
|
||||
--color-muted-alt: rgb(156 163 175);
|
||||
--color-subtle-text: rgb(226 232 240);
|
||||
--color-subtle: rgb(229 231 235);
|
||||
--color-critical: rgb(127 29 29);
|
||||
--color-critical-strong: rgb(153 27 27);
|
||||
--color-neutral-700: rgb(63 63 70);
|
||||
--color-neutral-600: rgb(82 82 91);
|
||||
--color-white: rgb(255 255 255);
|
||||
--color-black: rgb(0 0 0);
|
||||
--color-overlay: rgb(26 34 52);
|
||||
|
||||
/* Spacing scale */
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
|
||||
/* Border radii */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 5%);
|
||||
--shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 10%);
|
||||
--shadow-lg: 0 0.625rem 0.9375rem -0.1875rem rgb(0 0 0 / 10%);
|
||||
--shadow-modal: 0 1.25rem 2.5rem -0.375rem rgb(15 23 42 / 55%);
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-family-mono: "Fira Code", "SFMono-Regular", ui-monospace, monospace;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 2rem;
|
||||
--line-height-tight: 1.1;
|
||||
--line-height-snug: 1.25;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Motion */
|
||||
--motion-duration-fast: 120ms;
|
||||
--motion-duration-base: 160ms;
|
||||
--motion-duration-slow: 280ms;
|
||||
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--modal-motion-duration: var(--motion-duration-base);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Application Responsibility Audit
|
||||
|
||||
This document catalogs the major responsibilities still implemented inside `Application` (formerly `bitvidApp`). Each area is a candidate for future extraction into dedicated service or UI modules.
|
||||
|
||||
## Auth & Profile Coordination
|
||||
- Instantiates `AuthService`, wires login/logout/profile listeners, and exposes helper methods for cached profile state management.【F:js/app.js†L221-L314】【F:js/app.js†L620-L772】
|
||||
- Manages profile modal lifecycle, navigation between panes, and account switching UI state.【F:js/app.js†L283-L420】【F:js/app.js†L1328-L1822】
|
||||
|
||||
## Playback Orchestration
|
||||
- Configures `PlaybackService`, logs telemetry callbacks, and tracks the active playback session lifecycle.【F:js/app.js†L224-L323】【F:js/app.js†L2805-L3352】
|
||||
- Coordinates view logging, cooldown keys, and watch-count subscriptions for the primary player and modal overlays.【F:js/app.js†L4305-L4592】【F:js/app.js†L4972-L5332】
|
||||
|
||||
## Modal & UI Composition
|
||||
- Constructs upload, edit, revert, and video modals; registers their event listeners; and bridges UI events back into application logic.【F:js/app.js†L340-L615】【F:js/app.js†L2382-L2799】
|
||||
- Builds and manages the `VideoListView` component, injecting helpers for health badges, share links, and context menu actions.【F:js/app.js†L516-L744】【F:js/app.js†L2149-L2376】
|
||||
|
||||
## Routing & View Bootstrapping
|
||||
- Handles initial view loading, hash-based navigation, and video list mounting during application startup.【F:js/app.js†L903-L959】【F:js/app.js†L1932-L2124】
|
||||
- Provides navigation helpers for channel/profile transitions tied to hash routing.【F:js/app.js†L934-L968】
|
||||
|
||||
## State & Watch History Management
|
||||
- Synchronizes watch-history preferences, local metadata caching, and publication of watch history events to services.【F:js/app.js†L1975-L2093】【F:js/app.js†L4087-L4478】
|
||||
- Tracks app-level state such as active intervals, cleanup routines, view counters, and URL/magnet health caches.【F:js/app.js†L200-L323】【F:js/app.js†L3354-L4075】
|
||||
|
||||
These clusters outline the remaining cross-cutting responsibilities that can be incrementally migrated into focused modules under `js/services` and `js/ui`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Feed Engine Architecture
|
||||
|
||||
The feed engine is a lightweight pipeline for composing Bitvid feeds from one
|
||||
or more data sources. It lives in `js/feedEngine/` and exposes factories for
|
||||
sources, stages, sorters, and the engine itself. The goal is to make it easy to
|
||||
register new feeds today while leaving room for future "open algorithm" work
|
||||
that surfaces optional "why this video" metadata.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Feed definition** – A feed consists of a source, zero or more pipeline
|
||||
stages, an optional sorter, and optional decorators. Each feed can expose its
|
||||
own hooks and configuration defaults.
|
||||
- **Feed items** – The engine normalizes everything into a `{ video, pointer?,
|
||||
metadata }` DTO so stages can reason about inputs without caring about the
|
||||
original source.
|
||||
- **Context** – Every source and stage receives a context object containing the
|
||||
feed configuration, runtime helpers, registered hooks, and a `addWhy()`
|
||||
method for collecting metadata about why an item was filtered or prioritised.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```js
|
||||
import {
|
||||
createFeedEngine,
|
||||
createActiveNostrSource,
|
||||
createDedupeByRootStage,
|
||||
createBlacklistFilterStage,
|
||||
createChronologicalSorter,
|
||||
} from "../js/feedEngine/index.js";
|
||||
|
||||
const engine = createFeedEngine();
|
||||
|
||||
engine.registerFeed("recent", {
|
||||
source: createActiveNostrSource(),
|
||||
stages: [
|
||||
createBlacklistFilterStage(),
|
||||
createDedupeByRootStage(),
|
||||
],
|
||||
sorter: createChronologicalSorter(),
|
||||
});
|
||||
|
||||
const { videos, metadata } = await engine.runFeed("recent", {
|
||||
runtime: {
|
||||
blacklistedEventIds: new Set(["..."]),
|
||||
isAuthorBlocked: (pubkey) => false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`metadata.why` collects the audit trail from each stage so later phases of the
|
||||
open algorithm project can surface transparency UI.
|
||||
|
||||
## Sources
|
||||
|
||||
| Factory | Description |
|
||||
| --- | --- |
|
||||
| `createActiveNostrSource` | Wraps `nostrService.getFilteredActiveVideos(...)` and emits DTOs with a `metadata.source` of `nostr:active`. |
|
||||
| `createSubscriptionAuthorsSource` | Filters the active video list down to subscribed authors using runtime hooks or `config.actorFilters`. |
|
||||
| `createWatchHistoryPointerSource` | Loads pointer DTOs from `watchHistoryService.getQueuedPointers(...)`. Optional hooks can resolve the backing video if a pointer is missing it. |
|
||||
|
||||
Every source resolves blacklist and author-block runtime helpers so feeds
|
||||
constructed today behave exactly like the existing UI.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Behavior |
|
||||
| --- | --- |
|
||||
| `createDedupeByRootStage` | Reuses the application’s `dedupeVideosByRoot` helper (falling back to `dedupeToNewestByRoot`) to drop older versions of the same videoRoot. Adds "why" metadata for removed entries. |
|
||||
| `createBlacklistFilterStage` | Calls `nostrService.shouldIncludeVideo(...)` so moderators and block lists stay enforced. Each rejection logs a "blacklist" reason in the why-trail. |
|
||||
| `createWatchHistorySuppressionStage` | Invokes feed-provided hooks to optionally suppress watched items. Useful for per-feed watch history preferences. |
|
||||
|
||||
Stages receive `(items, context)` and should return the transformed list. They
|
||||
can rely on `context.addWhy(...)` to annotate decisions without mutating the
|
||||
items in place.
|
||||
|
||||
## Sorting & Decorators
|
||||
|
||||
`createChronologicalSorter` is the baseline sorter that orders DTOs by
|
||||
`video.created_at` (newest first by default). Additional decorators can run
|
||||
after sorting to attach extra metadata or inject presentation hints.
|
||||
|
||||
## Configuration Hooks
|
||||
|
||||
Every feed inherits the default config contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"timeWindow": null,
|
||||
"actorFilters": [],
|
||||
"tagFilters": [],
|
||||
"sortOrder": "recent"
|
||||
}
|
||||
```
|
||||
|
||||
Feeds can override defaults or expose richer schemas through
|
||||
`definition.defaultConfig` and `definition.configSchema`. At execution time the
|
||||
engine merges `options.config` with those defaults and passes them to the
|
||||
pipeline via `context.config`.
|
||||
|
||||
Hooks can be provided globally when registering the feed or per-execution via
|
||||
`engine.runFeed(name, { hooks: { ... } })`. The watch-history suppression stage
|
||||
uses this mechanism so Phase 1 feeds can plug in actor-specific suppression
|
||||
logic later without changing the core pipeline.
|
||||
|
||||
## Why-Metadata
|
||||
|
||||
`context.addWhy()` records structured audit entries. All built-in stages use it
|
||||
for dedupe drops, blacklist filtering, and watch-history suppression. The
|
||||
engine returns these records alongside the final video list so UI components can
|
||||
render transparency affordances when the open algorithm effort ships.
|
||||
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bitvid Tailwind Kitchen Sink</title>
|
||||
<link rel="stylesheet" href="../css/tailwind.generated.css" />
|
||||
</head>
|
||||
<body class="bg-page font-sans text-text-strong">
|
||||
<div class="min-h-screen bg-page-alt">
|
||||
<header class="bg-surface text-text shadow-modal">
|
||||
<div
|
||||
class="mx-auto flex max-w-6xl items-center justify-between px-md py-sm"
|
||||
>
|
||||
<h1 class="text-2xl font-semibold">Bitvid UI Kitchen Sink</h1>
|
||||
<button
|
||||
class="rounded-lg bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
Primary Action
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex max-w-6xl flex-col gap-lg px-md py-lg">
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Buttons</h2>
|
||||
<div class="flex flex-wrap gap-sm">
|
||||
<button
|
||||
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-fast ease-out hover:bg-info-strong"
|
||||
>
|
||||
Informational
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-critical px-sm py-2 text-white shadow-sm transition duration-fast ease-out hover:bg-critical-strong"
|
||||
>
|
||||
Destructive
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-border-subtle px-sm py-2 text-text-muted shadow-none transition duration-fast ease-out hover:bg-panel-hover hover:text-text"
|
||||
>
|
||||
Ghost
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Cards</h2>
|
||||
<div class="grid gap-md md:grid-cols-3">
|
||||
<article class="rounded-lg bg-card p-md text-text shadow-lg">
|
||||
<h3 class="text-lg font-semibold">Default Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Uses
|
||||
<code class="font-mono text-sm text-info">bg-card</code> and
|
||||
tokenized shadows.
|
||||
</p>
|
||||
<button
|
||||
class="mt-sm rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-base ease-in-out hover:bg-info-strong"
|
||||
>
|
||||
Watch Now
|
||||
</button>
|
||||
</article>
|
||||
<article
|
||||
class="rounded-lg bg-card-private p-md text-text shadow-lg"
|
||||
>
|
||||
<h3 class="text-lg font-semibold">Private Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Emphasizes restricted content with
|
||||
<code class="font-mono text-sm text-info">bg-card-private</code
|
||||
>.
|
||||
</p>
|
||||
</article>
|
||||
<article
|
||||
class="rounded-lg bg-card-critical p-md text-text shadow-lg"
|
||||
>
|
||||
<h3 class="text-lg font-semibold">Critical Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Use for outage or abuse states.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Form Inputs</h2>
|
||||
<form class="grid gap-md md:grid-cols-2">
|
||||
<label class="flex flex-col gap-xs">
|
||||
<span class="text-sm font-medium text-text-muted"
|
||||
>Display Name</span
|
||||
>
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
type="text"
|
||||
placeholder="Satoshi Stack"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-xs">
|
||||
<span class="text-sm font-medium text-text-muted">Video URL</span>
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
type="url"
|
||||
placeholder="https://example.com/video.mp4"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-xs md:col-span-2">
|
||||
<span class="text-sm font-medium text-text-muted"
|
||||
>Description</span
|
||||
>
|
||||
<textarea
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
rows="3"
|
||||
placeholder="Describe what viewers should expect..."
|
||||
></textarea>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Modal Preview</h2>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl bg-overlay p-lg text-text shadow-modal"
|
||||
>
|
||||
<div class="space-y-sm">
|
||||
<h3 class="text-lg font-semibold">Upload Video</h3>
|
||||
<p class="text-text-muted">
|
||||
Motion tokens control the fade via
|
||||
<code class="font-mono text-sm text-info">duration-base</code>
|
||||
and
|
||||
<code class="font-mono text-sm text-info">ease-out</code>
|
||||
utilities.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-md flex flex-col gap-sm">
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong focus:border-info focus:ring-2 focus:ring-info"
|
||||
type="text"
|
||||
placeholder="Magnet URI"
|
||||
/>
|
||||
<div class="flex justify-end gap-sm">
|
||||
<button
|
||||
class="rounded-md border border-border px-sm py-2 text-text-muted transition duration-fast ease-out hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">
|
||||
Typography Scale
|
||||
</h2>
|
||||
<div class="space-y-xs">
|
||||
<p class="text-2xl font-semibold">Display / 2xl</p>
|
||||
<p class="text-xl font-semibold">Headline / xl</p>
|
||||
<p class="text-lg font-medium">Title / lg</p>
|
||||
<p class="text-base">Body / base</p>
|
||||
<p class="text-sm text-text-muted">Caption / sm</p>
|
||||
<p class="text-xs uppercase tracking-wide text-text-muted">
|
||||
Meta / xs
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
# Manual QA — 2024-05-10
|
||||
|
||||
## Scope
|
||||
* Login/logout state transitions via `authService`
|
||||
* Video list rendering and playback fallback via `playbackService`
|
||||
* Upload submissions (URL only, magnet only, combined) via `publishVideoNote`
|
||||
|
||||
## Environment
|
||||
* Local static server: `python -m http.server 5173`
|
||||
* Browser automation: Playwright (Chromium) via sandboxed runner
|
||||
* Stubbed `window.nostr` for extension-less login flows
|
||||
|
||||
## Results
|
||||
### Authentication
|
||||
* Pre-login upload button hidden (`False`).
|
||||
* Post-login upload button visible (`True`); profile button also visible.
|
||||
* Post-logout upload button hidden again (`False`).
|
||||
|
||||
### Playback
|
||||
* Hosted URL attempt emitted `session-start` analytics event. Hosted playback errored in sandbox (no magnet fallback available for URL-only sample).
|
||||
* Second sample (URL + magnet) emitted `session-start`, `fallback`, `playViaWebTorrent`, and `sourcechange: torrent` events confirming magnet fallback wiring.
|
||||
|
||||
### Upload flows
|
||||
* URL-only submission succeeded (normalized payload kept empty magnet and blank hints).
|
||||
* Magnet-only submission appended WSS tracker hints plus provided `ws`/`xs` values.
|
||||
* Combined submission retained HTTPS URL and normalized magnet with tracker hints.
|
||||
|
||||
## Notes
|
||||
Automation invoked `app.authService.login`/`handleAuthLogin` and `app.publishVideoNote` directly to simulate modal interactions. Playback checks observed console analytics, not media rendering (hosted stream blocked by sandbox security).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Bitvid Moderation Overview
|
||||
|
||||
## What we’re building
|
||||
Bitvid is follow-centric. Your Home feed comes from people you follow (F1). Discovery can optionally expand to friends-of-friends (F2) and ranked sources. Moderation is **client-side** and **user-controlled** with optional admin lists.
|
||||
|
||||
## Core principles
|
||||
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
|
||||
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
|
||||
- **Whitelist > blacklist**: Prefer showing content from trusted networks over fighting global spam.
|
||||
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
|
||||
|
||||
## Threat model (short)
|
||||
- Spam/bots, impersonation, malware, NSFW thumbnails, illegal content, brigading/dogpiles, Sybil report attacks.
|
||||
|
||||
## Building blocks (Nostr)
|
||||
- **Reports**: NIP-56 (`kind 1984`) with types like `nudity`, `spam`, `illegal`, `impersonation`, etc.
|
||||
- **Lists**: NIP-51 (mute list 10000, categorized people 30000, bookmarks 30001).
|
||||
- **Replies/threads**: NIP-10 (comments).
|
||||
- **Counts**: NIP-45 (relay COUNT; optional fallbacks).
|
||||
|
||||
## Defaults (policy)
|
||||
- Blur video thumbnails if **≥ 3** F1 friends report `nudity`.
|
||||
- Disable autoplay preview if **≥ 2** F1 friends report `nudity`.
|
||||
- Downrank author when any F1 has them in mute list (10000).
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content.
|
||||
|
||||
> You can override all defaults in **Settings → Safety & Moderation**.
|
||||
|
||||
## Files in this folder
|
||||
- `web-of-trust.md` — how we compute trust signals and thresholds
|
||||
- `nips.md` — exact NIPs and kinds Bitvid uses
|
||||
- `relays.md` — relay compatibility and COUNT fallbacks
|
||||
- `testing.md` — QA checklist + test vectors
|
||||
@@ -0,0 +1,22 @@
|
||||
# NIPs Bitvid uses (Moderation-Relevant)
|
||||
|
||||
## Required
|
||||
- **NIP-56 — Reporting**
|
||||
`kind 1984` events with a `type` tag such as `nudity`, `spam`, `illegal`, `impersonation`, `malware`, `profanity`, `other`.
|
||||
Client may act on **reports from friends** (our trusted set).
|
||||
|
||||
- **NIP-51 — Lists**
|
||||
- `10000` mute list (authors to mute).
|
||||
- `30000` categorized people (used for admin/curation lists).
|
||||
- `30001` bookmarks (not moderation, but used elsewhere).
|
||||
|
||||
- **NIP-10 — Replies & Mentions**
|
||||
Threading for comments; moderation badges appear in the thread UI.
|
||||
|
||||
## Recommended
|
||||
- **NIP-45 — COUNT**
|
||||
Event counts; we use it opportunistically for faster totals with graceful fallback.
|
||||
|
||||
## Nice-to-have
|
||||
- **NIP-36 — Sensitive Content / Content Warning**
|
||||
If present, we pre-blur content even without reports; user can “show anyway”.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Relay Compatibility & Fallbacks
|
||||
|
||||
## COUNT (NIP-45)
|
||||
- Use COUNT when available to fetch totals efficiently.
|
||||
- If a relay doesn’t support COUNT:
|
||||
- Show “—” placeholder.
|
||||
- Fall back to client-side counting when cheap (narrow queries only).
|
||||
- Avoid heavy per-relay scans; cap by time window and result size.
|
||||
|
||||
## Query hygiene
|
||||
- Prefer specific filters: `authors`, `kinds`, `#e`, `#p`, `since/until`.
|
||||
- Batch requests across relays; debounce UI updates.
|
||||
- Cache viewer’s F1 set (pubkeys you follow) and their report summaries.
|
||||
|
||||
## Media/thumbnail considerations
|
||||
- Never block media fetch solely on relay count results.
|
||||
- Thumbnails respect blur rules locally; “show anyway” toggles do not re-query relays.
|
||||
|
||||
## Timeouts & errors
|
||||
- Per-relay timeout (e.g., 1500–2500 ms); proceed with partial results.
|
||||
- Surface a subtle “partial results” indicator when some relays fail.
|
||||
|
||||
## Privacy notes
|
||||
- Do not leak the viewer’s F1 set to third-party endpoints.
|
||||
- If using a reputation provider, pass pubkeys in batches; avoid attaching viewer identity unless needed.
|
||||
@@ -0,0 +1,71 @@
|
||||
# QA & Test Vectors (Moderation)
|
||||
|
||||
## Quick checklist
|
||||
- [ ] F1 set cached and used for “trusted reports”.
|
||||
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
|
||||
- [ ] “Show anyway” works and records only local pref.
|
||||
- [ ] Mute list (10000) downranks/hides consistently.
|
||||
- [ ] Admin lists (30000) take effect only when subscribed.
|
||||
- [ ] COUNT fallback shows placeholders and doesn’t crash UI.
|
||||
- [ ] Comment threads render with NIP-10; moderation badges show in context.
|
||||
- [ ] Reports from muted reporters are ignored.
|
||||
|
||||
## Fixtures (create on test relays)
|
||||
|
||||
### A) NIP-56 report events
|
||||
Create three distinct F1 reporter keys; each sends:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1984,
|
||||
"tags": [
|
||||
["e", "<VIDEO_EVENT_ID>", "nudity"],
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
],
|
||||
"content": "test: thumbnail too revealing"
|
||||
}
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* After 1 report → no blur; autoplay allowed.
|
||||
* After 2 reports → autoplay blocked.
|
||||
* After 3 reports → thumbnail blurred; reason chip shown.
|
||||
|
||||
### B) Mute list (10000)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10000,
|
||||
"tags": [
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expected: author’s items are downranked/hidden for this viewer.
|
||||
|
||||
### C) Admin blacklist (30000)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30000,
|
||||
"tags": [
|
||||
["d", "bitvid:admin:blacklist"],
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expected: when viewer subscribes to this list, author is hard-hidden.
|
||||
|
||||
### D) COUNT fallback
|
||||
|
||||
* Use a relay that doesn’t support COUNT.
|
||||
* Verify “—” placeholders; no spinner loops; no crashes.
|
||||
|
||||
## Automated checks (suggested)
|
||||
|
||||
* Unit test `trustedReportCount`.
|
||||
* Integration test: emit events over a local relay; assert UI state.
|
||||
* Regression test: ensure Home feed ignores reputation gating.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Web-of-Trust Policy (Bitvid)
|
||||
|
||||
## Graph terms
|
||||
- **F1 (friends)**: pubkeys you follow.
|
||||
- **F2**: friends-of-friends (off by default for Home; allowed for Discovery).
|
||||
- **Trusted report**: a NIP-56 report from an F1 account.
|
||||
|
||||
## Signals we use
|
||||
- NIP-56 reports by type (`nudity`, `spam`, `illegal`, `impersonation`, etc.).
|
||||
- NIP-51 lists:
|
||||
- 10000 mute list → downrank/hide author content.
|
||||
- 30000 categorized people → optional admin lists (see below).
|
||||
- (Optional) reputation score from a reputation source (e.g., PageRank/DVM) for **Discovery** only.
|
||||
|
||||
## Default thresholds (can be tuned)
|
||||
- `blurThumbnail = trustedReportCount(event,'nudity') >= 3`
|
||||
- `hideAutoplay = trustedReportCount(event,'nudity') >= 2`
|
||||
- `downrankIfMutedByF1 = true`
|
||||
|
||||
### Why these numbers?
|
||||
- F1-only reports resist Sybil attacks.
|
||||
- Blur is reversible; hiding autoplay reduces accidental exposure.
|
||||
|
||||
## Admin lists (opt-in)
|
||||
- We recognize curated lists using `30000` events:
|
||||
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
|
||||
- `['d','bitvid:admin:whitelist']` → always show in Discovery.
|
||||
- `['d','bitvid:admin:editors']` → trusted channel editors.
|
||||
- Users can subscribe/unsubscribe any time.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```ts
|
||||
type Hex = string;
|
||||
|
||||
interface Report {
|
||||
reporter: Hex; // F1 must include this key
|
||||
type: 'nudity'|'spam'|'illegal'|'impersonation'|'malware'|'profanity'|'other';
|
||||
targetEvent: string;
|
||||
}
|
||||
|
||||
function trustedReportCount(eventId: string, type: Report['type'], viewerFollows: Set<Hex>, reports: Report[]): number {
|
||||
return reports.filter(r => r.targetEvent === eventId && r.type === type && viewerFollows.has(r.reporter)).length;
|
||||
}
|
||||
|
||||
function shouldBlurThumb(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 3;
|
||||
}
|
||||
|
||||
function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 2;
|
||||
}
|
||||
```
|
||||
|
||||
## UI rules
|
||||
|
||||
* Always show a reason chip: e.g., `Blurred · 3 friends reported “nudity” · Show anyway`.
|
||||
* Respect per-viewer choices (global off, per-channel off).
|
||||
* Never bury the override.
|
||||
|
||||
## Discovery (optional reputation)
|
||||
|
||||
* Home feed: **never** gated by reputation.
|
||||
* Discovery/Trending: may require a minimum score (pluggable `ReputationSource`).
|
||||
* Keep a toggle in Settings to disable reputation gating.
|
||||
|
||||
```ts
|
||||
interface ReputationSource {
|
||||
// returns 0..1 rank per pubkey for a given perspective
|
||||
rank(pubkeys: Hex[], perspective?: Hex): Promise<Record<Hex, number>>;
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-abuse hardening
|
||||
|
||||
* Count unique F1 reporters only (dedupe by pubkey).
|
||||
* Minimum account age or minimum “being-followed” count for reporters (optional).
|
||||
* Rate-limit rapid report bursts per reporter.
|
||||
* Ignore reports from muted or blocked reporters.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Nostr Analytics Knobs
|
||||
|
||||
BitVid's view counter emits Nostr events so operators can track engagement without duplicating storage. Tune the following exports in `config/instance-config.js` to match your retention and performance goals:
|
||||
|
||||
- `VIEW_COUNT_DEDUPE_WINDOW_SECONDS` (default: `86_400`): repeat plays from the same viewer inside this window are treated as duplicates so stalled reloads do not inflate totals. Shorten the window to count more aggressive replays, or extend it if you want conservative numbers.
|
||||
- `VIEW_COUNT_BACKFILL_MAX_DAYS` (default: `90`): controls how far back hydrators should walk history when a new analytics worker boots. Longer windows deliver deeper trend lines at the cost of heavier relay scans.
|
||||
- `VIEW_COUNT_CACHE_TTL_MS` (default: `5 * 60 * 1000`): defines how long cached aggregates remain trustworthy before clients refresh them. Lower values surface spikes faster, while higher ones smooth traffic for relay-friendly dashboards.
|
||||
|
||||
Most operators can ship with the defaults—24-hour deduplication, a 90-day backfill horizon, and five-minute cache TTLs match what we run in production. Deviate only if your relays have unusual load constraints or your reporting needs stricter fidelity.
|
||||
|
||||
## View Event Lifecycle
|
||||
|
||||
### Anonymous session keys
|
||||
|
||||
Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr.js#L2011-L2105) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback.
|
||||
|
||||
### Publish latency expectations
|
||||
|
||||
When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing.
|
||||
|
||||
The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing.
|
||||
|
||||
To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts.
|
||||
|
||||
Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history.
|
||||
|
||||
### Hydration, COUNT, and UI sync
|
||||
|
||||
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Nostr event schemas
|
||||
|
||||
BitVid now centralizes every note that it publishes to Nostr in
|
||||
[`js/nostrEventSchemas.js`](../js/nostrEventSchemas.js). The module defines the
|
||||
kind, required tags, and content format for each note type so troubleshooting
|
||||
no longer requires hunting through the codebase. It also exposes helpers for
|
||||
building events and for overriding the schema at runtime when you need to
|
||||
experiment.
|
||||
|
||||
## Runtime helpers
|
||||
|
||||
```js
|
||||
import {
|
||||
NOTE_TYPES,
|
||||
getNostrEventSchema,
|
||||
setNostrEventSchemaOverrides,
|
||||
buildVideoPostEvent,
|
||||
buildRepostEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
// Inspect the current schema
|
||||
console.log(getNostrEventSchema(NOTE_TYPES.VIDEO_POST));
|
||||
|
||||
// Temporarily override the kind while debugging
|
||||
setNostrEventSchemaOverrides({
|
||||
[NOTE_TYPES.VIDEO_POST]: { kind: 30000 },
|
||||
});
|
||||
|
||||
// Build an event with the active schema
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: "debug-video",
|
||||
content: { version: 3, title: "Test", videoRootId: "debug" },
|
||||
});
|
||||
```
|
||||
|
||||
In the browser you can call the same helpers from DevTools via
|
||||
`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`.
|
||||
|
||||
When managing relay metadata, use `buildRelayListEvent` so the resulting
|
||||
replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the
|
||||
read/write split.
|
||||
|
||||
### Accessing raw events
|
||||
|
||||
`NostrClient` now keeps a lightweight cache of parsed videos alongside a
|
||||
separate cache of the untouched event payloads. Call
|
||||
`nostrClient.fetchRawEventById(eventId)` when you need the original JSON that
|
||||
was received from the relay. For convenience,
|
||||
`nostrClient.getEventById(eventId, { includeRaw: true })` returns both shapes at
|
||||
once:
|
||||
|
||||
```js
|
||||
const { video, rawEvent } = await nostrClient.getEventById(eventId, {
|
||||
includeRaw: true,
|
||||
});
|
||||
|
||||
// `video` is the normalized BitVid object and `rawEvent` is the original
|
||||
// Nostr event with `sig`, `id`, and relay metadata intact.
|
||||
```
|
||||
|
||||
In DevTools the active client lives at `window.bitvidApp?.nostrClient`.
|
||||
|
||||
Use the `rawEvent` blob when implementing “Rebroadcast” style flows so the
|
||||
client can republish exactly what was signed, including the original
|
||||
`sig`. Republishers should prefer `rawEvent.sig` over re-signing unless the
|
||||
payload actually changes; this avoids accidental drift in dedupe tags or
|
||||
timestamps.
|
||||
|
||||
## Event catalogue
|
||||
|
||||
| Note | Kind (default) | Tags | Content format |
|
||||
| --- | --- | --- | --- |
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
|
||||
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
|
||||
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
|
||||
| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) |
|
||||
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
|
||||
The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility.
|
||||
|
||||
If you introduce a new Nostr feature, add its schema to
|
||||
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
|
||||
builders inherit the same debugging knobs.
|
||||
|
||||
### NIP-71 rollout
|
||||
|
||||
BitVid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short
|
||||
form) whenever the `FEATURE_PUBLISH_NIP71` flag is enabled. The builder converts
|
||||
structured upload metadata—image variants, captions, segments, hashtags, and
|
||||
participant pointers—into canonical tags so other clients can discover the same
|
||||
video. The legacy kind `30078` post remains the source of truth during the
|
||||
transition, and schema overrides can adjust either kind if relays need
|
||||
experimentation.
|
||||
|
||||
### Watch history identifiers
|
||||
|
||||
The encrypted watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2`
|
||||
runtime flag. When the flag is disabled, clients continue emitting view events
|
||||
but skip publishing snapshots; the UI still resolves legacy `watch-history:v2:index`
|
||||
lists so operators can stage the rollout per deployment.【F:config/instance-config.js†L69-L94】【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L948-L985】
|
||||
|
||||
Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER`
|
||||
(`"watch-history"`) and the legacy aliases enumerated in
|
||||
`WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Chunk events derive their `d` tag from
|
||||
`<snapshotId>:<index>`, advertise `['snapshot', <id>]`, and carry `['chunk', <index>, <total>]`
|
||||
plus an optional leading `['head','1']` marker so relays can prioritize the first
|
||||
ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer
|
||||
entries; richer metadata remains on-device via the
|
||||
[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to
|
||||
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr.js†L2329-L2369】【F:js/watchHistoryService.js†L331-L376】
|
||||
|
||||
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
|
||||
management hooks, manual snapshot helpers, and metadata toggle controls that
|
||||
complement these schema definitions.【F:js/watchHistoryService.js†L695-L776】【F:js/watchHistoryService.js†L1040-L1093】
|
||||
@@ -0,0 +1,53 @@
|
||||
# Nostr Wallet Connect Coverage
|
||||
|
||||
This note cross-references Bitvid's current Nostr Wallet Connect (NIP-47) implementation with the official
|
||||
specification so we can quickly spot regressions or missing features.
|
||||
|
||||
## URI handling
|
||||
|
||||
* `parseNwcUri()` accepts all aliases we see in the wild (`nostr+walletconnect://`, `walletconnect://`, and `nwc://`)
|
||||
and rejects unknown schemes before continuing. It extracts the wallet pubkey, all relay query parameters, and the
|
||||
32-byte secret required for signing. The helper lowercases the secret, reserializes the URI in canonical form, and
|
||||
derives the client public key from the secret so downstream calls can sign events without leaking user keys.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
* `ensureActiveState()` caches the parsed connection and keeps WebSocket state so repeated actions reuse the same
|
||||
subscription. When a new URI is supplied we tear down the previous socket, update the relay list, and reset
|
||||
encryption state before reconnecting.
|
||||
* `connectSocket()` opens a WebSocket to the relay, subscribes to kind `23195` responses authored by the wallet and
|
||||
addressed to the client pubkey, and hooks message/error/close callbacks so we can retry or surface failures.
|
||||
|
||||
## Request flow
|
||||
|
||||
* `ensureWallet()` validates that the user configured an NWC URI, connects to the relay if needed, and negotiates an
|
||||
encryption scheme before returning the active context.
|
||||
* `encryptRequestPayload()` serializes the JSON-RPC payload, encrypts it with the selected scheme, and creates the
|
||||
Nostr event with kind `23194`, `pubkey` set to the client key, a `p` tag pointing at the wallet, and an
|
||||
`encryption` tag mirroring the algorithm that was used. Events are signed with the secret from the connection URI
|
||||
so relays will accept them.
|
||||
* `sendPayment()` constructs the `pay_invoice` request, attaches a unique `id`, and dispatches it over the relay.
|
||||
|
||||
## Response flow
|
||||
|
||||
* `subscribeToResponses()` issues a `REQ` for kind `23195` events authored by the wallet and tagged for the client.
|
||||
* `handleSocketMessage()` decrypts responses, matches them against pending `id`s (or the `e` tag), clears pending
|
||||
timeouts, and surfaces either the `result` or `error.code`/`error.message` back to callers.
|
||||
|
||||
## Encryption negotiation
|
||||
|
||||
* `requestInfoEvent()` fetches the wallet's replaceable kind `13194` info note and passes the result to
|
||||
`getWalletSupportedEncryption()` so we only pick schemes advertised by the wallet service.
|
||||
* `getEncryptionCandidates()` prefers NIP-44 (`nip44_v2`) when both sides support it and falls back to NIP-04 when
|
||||
necessary. We also remember schemes that failed so future retries skip them.
|
||||
|
||||
## Current gaps
|
||||
|
||||
* We only connect to the first relay supplied by the URI. If a wallet publishes multiple relays we should consider
|
||||
retrying additional ones when the primary is offline.
|
||||
* The client currently implements the `pay_invoice` command. Other optional commands (`make_invoice`,
|
||||
`get_balance`, `multi_pay_invoice`, etc.) and notification handling (kinds `23196`/`23197`) would require follow-up
|
||||
work.
|
||||
* Wallet responses that signal `UNSUPPORTED_ENCRYPTION` as an error today bubble straight back to the caller. The
|
||||
transport already retries once when we encounter that error during submission, but we could add richer UX (e.g.
|
||||
prompting the user to reconnect) in the future.
|
||||
@@ -20,5 +20,8 @@ Run this checklist before publishing releases or merging changes that touch uplo
|
||||
6. **Saved Profile Metadata Refresh**
|
||||
- In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars.
|
||||
- Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh.
|
||||
7. **View Logging Per Identity**
|
||||
- Play the same video until a view event is logged, then log out (or switch to a different pubkey) and repeat the playback.
|
||||
- Confirm two distinct view events appear (e.g., via relay logs or UI counters) and the aggregated view count increments twice.
|
||||
|
||||
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Watch history logging (v2 pipeline)
|
||||
|
||||
BitVid's watch history sync is orchestrated by the
|
||||
[`WatchHistoryService`](../js/watchHistoryService.js). The service collects
|
||||
per-video pointer data during playback, batches it in session storage, and emits
|
||||
encrypted snapshots to relays only when a publish is triggered. Relay payloads
|
||||
stick to pointer identifiers so title, thumbnail, and profile context remain on
|
||||
the client unless an operator opts into local metadata caching.
|
||||
|
||||
## Snapshot composition
|
||||
|
||||
Snapshots publish two event types to the shared `WATCH_HISTORY_KIND` stream:
|
||||
|
||||
1. **Chunk events** — Each chunk carries a deterministic identifier in its
|
||||
`d` tag, advertises its `snapshot` and `chunk` position, and is tagged with
|
||||
`['encrypted','nip04']`. The first chunk is additionally marked as
|
||||
`['head','1']` and may append `"a"` tags that reference previous chunk
|
||||
addresses, allowing readers to reconstruct the full set even when relays
|
||||
deduplicate aggressively.【F:js/nostrEventSchemas.js†L175-L189】【F:js/nostr.js†L2329-L2369】
|
||||
2. **Pointer event** — After chunks are accepted, BitVid emits a compact index
|
||||
event containing the canonical list identifier (`['d', WATCH_HISTORY_LIST_IDENTIFIER]`),
|
||||
the `snapshot` label, the total chunk count, and an `a` tag for each chunk
|
||||
address.【F:js/nostrEventSchemas.js†L157-L170】【F:js/nostr.js†L2425-L2441】
|
||||
|
||||
Chunk content is a NIP-04 encrypted JSON envelope of `{ version: 2, snapshot,
|
||||
chunkIndex, totalChunks, items[] }`. Items are pointer descriptors (`e` or `a`
|
||||
references plus optional relay hints) so that relays never learn which titles
|
||||
were played—only which events or addresses the client can dereference later.【F:js/nostr.js†L2337-L2364】
|
||||
|
||||
## Batching configuration
|
||||
|
||||
Operators can toggle grouped resolution with `WATCH_HISTORY_BATCH_RESOLVE` and
|
||||
optionally cap each response by defining `WATCH_HISTORY_BATCH_PAGE_SIZE` in
|
||||
`config/instance-config.js`. Leaving the page size unset returns the full
|
||||
`WATCH_HISTORY_MAX_ITEMS` window while still letting the UI paginate locally,
|
||||
but setting a positive integer keeps API payloads in lockstep with batched
|
||||
renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr.js†L2987-L2998】【F:js/historyView.js†L1-L38】
|
||||
|
||||
## Snapshot cadence
|
||||
|
||||
`WatchHistoryService.publishView` queues pointer entries while the feature flag
|
||||
is enabled, merging repeats with a one-minute throttle and persisting the queue
|
||||
in `sessionStorage`. Snapshots run when `watchHistoryService.snapshot()` is
|
||||
invoked (e.g., on `beforeunload`, `visibilitychange`, or manual sync actions in
|
||||
the history UI) and clear the queue on success.【F:js/watchHistoryService.js†L872-L917】【F:js/app.js†L6041-L6081】【F:js/app.js†L7467-L7484】
|
||||
If no items are pending, the snapshot resolves with an empty result so UI calls
|
||||
remain idempotent.【F:js/watchHistoryService.js†L932-L944】
|
||||
|
||||
## Republish and backoff
|
||||
|
||||
When a snapshot publish fails but the response is retryable, the service stores
|
||||
the returned `snapshotId`, records the originating reason, and delegates to the
|
||||
Nostr client to retry with exponential backoff. Retries start after two seconds,
|
||||
double per attempt with 25% jitter, cap at five minutes, and stop after eight
|
||||
attempts unless a publish succeeds sooner.【F:js/watchHistoryService.js†L948-L985】【F:js/watchHistoryService.js†L723-L777】【F:js/nostr.js†L1988-L2087】
|
||||
|
||||
## Legacy payload handling
|
||||
|
||||
Readers hydrate history by combining the pointer event, any encrypted chunks,
|
||||
and fallback metadata embedded in legacy watch-history lists. The resolver still
|
||||
accepts the `watch-history:v2:index` identifier, merges pointer tags from the
|
||||
index event, and falls back to plaintext content when decryption fails so older
|
||||
relays remain compatible.【F:config/instance-config.js†L60-L78】【F:js/nostr.js†L2704-L2779】【F:js/nostr.js†L5216-L5264】
|
||||
|
||||
## Local metadata toggles
|
||||
|
||||
By default the service only persists pointer data to relays; richer metadata is
|
||||
stored locally (and opt-in). Users can toggle the "store watch history metadata"
|
||||
preference, which flips a localStorage flag, clears cached entries when
|
||||
disabled, and never pushes the sanitized video/profile cache over the network.【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L232-L313】【F:js/watchHistoryService.js†L331-L376】
|
||||
|
||||
See the [`WatchHistoryService` API](../js/watchHistoryService.js) for
|
||||
event hooks, queue inspection helpers, and manual snapshot controls.
|
||||
+38
-49
@@ -45,25 +45,33 @@
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- Styles -->
|
||||
<script src="js/tracking.js" defer></script>
|
||||
<link href="css/tailwind.generated.css" rel="stylesheet" />
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
<link href="css/markdown.css" rel="stylesheet" />
|
||||
<!--
|
||||
Cache busting: keep these query strings in sync with config/asset-version.js
|
||||
so browsers pick up the latest sidebar markup and styling without
|
||||
hard-refreshing around CDN caches.
|
||||
-->
|
||||
<script src="js/tracking.js?v=2024.10.29" defer></script>
|
||||
<link href="css/tailwind.generated.css?v=2024.10.29" rel="stylesheet" />
|
||||
<link href="css/style.css?v=2024.10.29" rel="stylesheet" />
|
||||
<link href="css/markdown.css?v=2024.10.29" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<!--
|
||||
<body class="sidebar-collapsed">
|
||||
<!--
|
||||
SIDEBAR CONTAINER:
|
||||
The <aside id="sidebar"> inside handles visibility.
|
||||
The <aside id="sidebar"> inside handles visibility.
|
||||
By default, no special position or z-index on #sidebarContainer.
|
||||
-->
|
||||
<div id="sidebarContainer">
|
||||
<!-- components/sidebar.html gets injected here -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<div id="sidebarOverlay" class="md:hidden" aria-hidden="true"></div>
|
||||
|
||||
<!--
|
||||
MAIN CONTENT:
|
||||
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
|
||||
On mobile, we also toggle .sidebar-open to shift the entire content.
|
||||
Desktop spacing now follows the --sidebar-width custom property so the layout stays in
|
||||
sync with the collapsible rail. On mobile we float the sidebar over the content and dim
|
||||
the background overlay.
|
||||
-->
|
||||
<!--
|
||||
`fade-in` is scrubbed by js/index.js after the first animation so that
|
||||
@@ -71,7 +79,11 @@
|
||||
elements that should animate exactly once on initial paint.
|
||||
-->
|
||||
|
||||
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col fade-in">
|
||||
<div
|
||||
id="app"
|
||||
class="px-4 py-8 min-h-screen flex flex-col fade-in"
|
||||
data-sidebar-state="collapsed"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="mb-8 flex items-center w-full">
|
||||
<!-- Mobile hamburger button (hidden on md+) -->
|
||||
@@ -84,6 +96,10 @@
|
||||
<button
|
||||
id="mobileMenuBtn"
|
||||
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60] fade-in"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="sidebar"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
||||
@@ -198,44 +214,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Additional Scripts -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<script type="module">
|
||||
import * as nostrTools from "https://esm.sh/[email protected]";
|
||||
|
||||
const existing =
|
||||
typeof window !== "undefined" &&
|
||||
window.NostrTools &&
|
||||
typeof window.NostrTools === "object"
|
||||
? window.NostrTools
|
||||
: {};
|
||||
|
||||
const merged = { ...existing, ...nostrTools };
|
||||
|
||||
if (
|
||||
!merged.generatePrivateKey &&
|
||||
typeof merged.generateSecretKey === "function"
|
||||
) {
|
||||
merged.generatePrivateKey = merged.generateSecretKey.bind(merged);
|
||||
}
|
||||
|
||||
if (
|
||||
!merged.generateSecretKey &&
|
||||
typeof merged.generatePrivateKey === "function"
|
||||
) {
|
||||
merged.generateSecretKey = merged.generatePrivateKey.bind(merged);
|
||||
}
|
||||
|
||||
window.NostrTools = merged;
|
||||
</script>
|
||||
<script type="module" src="js/config.js"></script>
|
||||
<script type="module" src="js/lists.js"></script>
|
||||
<script type="module" src="js/accessControl.js"></script>
|
||||
<script type="module" src="js/webtorrent.js"></script>
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
<script type="module" src="js/disclaimer.js"></script>
|
||||
<script type="module" src="js/index.js"></script>
|
||||
<!-- Bootstraps nostr-tools before other modules rely on nostrToolsReady -->
|
||||
<script type="module" src="js/nostrToolsBootstrap.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/config.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/lists.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/accessControl.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/webtorrent.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/nostr.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/viewManager.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/bootstrap.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/disclaimer.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/index.js?v=2024.10.29"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
||||
</body>
|
||||
|
||||
+68
-56
@@ -51,81 +51,93 @@ class AccessControl {
|
||||
this.hasLoaded = false;
|
||||
this.lastError = null;
|
||||
this._isRefreshing = false;
|
||||
this._refreshPromise = this._performRefresh();
|
||||
this._refreshPromise.catch((error) => {
|
||||
console.error("Failed to load admin lists:", error);
|
||||
});
|
||||
this._refreshPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
async _performRefresh() {
|
||||
_performRefresh() {
|
||||
if (this._isRefreshing) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
this._isRefreshing = true;
|
||||
try {
|
||||
const state = await loadAdminState();
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
const whitelist = Array.isArray(state?.whitelist)
|
||||
? state.whitelist
|
||||
: [];
|
||||
const blacklist = Array.isArray(state?.blacklist)
|
||||
? state.blacklist
|
||||
: [];
|
||||
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||
this.whitelist = new Set(normalizedWhitelist);
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const state = await loadAdminState();
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
const whitelist = Array.isArray(state?.whitelist)
|
||||
? state.whitelist
|
||||
: [];
|
||||
const blacklist = Array.isArray(state?.blacklist)
|
||||
? state.blacklist
|
||||
: [];
|
||||
|
||||
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
|
||||
const adminGuardSet = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...Array.from(this.editors),
|
||||
]);
|
||||
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (adminGuardSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blacklist = new Set(sanitizedBlacklist);
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||
this.whitelist = new Set(normalizedWhitelist);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = true;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.lastError = error;
|
||||
if (!this.hasLoaded) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.blacklist.clear();
|
||||
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
|
||||
const adminGuardSet = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...Array.from(this.editors),
|
||||
]);
|
||||
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (adminGuardSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blacklist = new Set(sanitizedBlacklist);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = true;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.lastError = error;
|
||||
if (!this.hasLoaded) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.blacklist.clear();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this._isRefreshing = false;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this._isRefreshing = false;
|
||||
}
|
||||
})();
|
||||
|
||||
this._refreshPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const promise = this._performRefresh();
|
||||
this._refreshPromise = promise;
|
||||
promise.catch((error) => {
|
||||
if (this._isRefreshing) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
const operation = this._performRefresh();
|
||||
const tracked = operation.catch((error) => {
|
||||
console.error("Failed to refresh admin lists:", error);
|
||||
throw error;
|
||||
});
|
||||
return promise;
|
||||
this._refreshPromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async ensureReady() {
|
||||
if (!this.hasLoaded && !this._isRefreshing) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
try {
|
||||
await this._refreshPromise;
|
||||
} catch (error) {
|
||||
|
||||
+113
-31
@@ -8,12 +8,10 @@ import {
|
||||
isDevMode,
|
||||
} from "./config.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
|
||||
const LIST_IDENTIFIERS = {
|
||||
editors: "editors",
|
||||
whitelist: "whitelist",
|
||||
blacklist: "blacklist",
|
||||
};
|
||||
import {
|
||||
buildAdminListEvent,
|
||||
ADMIN_LIST_IDENTIFIERS,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
const LEGACY_STORAGE_KEYS = {
|
||||
editors: "bitvid_admin_editors",
|
||||
@@ -38,6 +36,19 @@ function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function isHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return HEX_PUBKEY_REGEX.test(trimmed);
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
@@ -240,17 +251,67 @@ function encodeHexToNpub(hex) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeParticipantTagValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const nip19 = window?.NostrTools?.nip19;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("npub")) {
|
||||
if (nip19?.decode) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded?.type === "npub") {
|
||||
return trimmed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Swallow decode errors so we can still fall back to the raw npub string.
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (isHexPubkey(trimmed)) {
|
||||
const encoded = encodeHexToNpub(trimmed);
|
||||
if (encoded) {
|
||||
return encoded;
|
||||
}
|
||||
if (!canDecodeNpub()) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (nip19?.decode) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded?.type === "npub") {
|
||||
return trimmed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode failures; values that cannot be interpreted as npubs are dropped.
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractNpubsFromEvent(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hexKeys = event.tags
|
||||
const candidates = event.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
const npubs = hexKeys
|
||||
.map((hex) => encodeHexToNpub(hex))
|
||||
const npubs = candidates
|
||||
.map((value) => normalizeParticipantTagValue(value))
|
||||
.filter((npub) => typeof npub === "string" && npub);
|
||||
|
||||
return dedupeNpubs(npubs);
|
||||
@@ -319,18 +380,15 @@ async function loadNostrList(identifier) {
|
||||
|
||||
async function loadNostrState() {
|
||||
const [editors, whitelist, blacklist] = await Promise.all([
|
||||
loadNostrList(LIST_IDENTIFIERS.editors),
|
||||
loadNostrList(LIST_IDENTIFIERS.whitelist),
|
||||
loadNostrList(LIST_IDENTIFIERS.blacklist),
|
||||
loadNostrList(ADMIN_LIST_IDENTIFIERS.editors),
|
||||
loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist),
|
||||
loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist),
|
||||
]);
|
||||
|
||||
return sanitizeAdminState({ editors, whitelist, blacklist });
|
||||
}
|
||||
|
||||
function buildListEvent(listKey, npubs, actorHex) {
|
||||
const identifier = LIST_IDENTIFIERS[listKey];
|
||||
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
|
||||
|
||||
const hexPubkeys = Array.from(
|
||||
new Set(npubs.map((npub) => {
|
||||
try {
|
||||
@@ -360,18 +418,11 @@ function buildListEvent(listKey, npubs, actorHex) {
|
||||
}
|
||||
}
|
||||
|
||||
const tags = [["d", dTagValue]];
|
||||
hexPubkeys.forEach((hex) => {
|
||||
tags.push(["p", hex]);
|
||||
});
|
||||
|
||||
return {
|
||||
kind: 30000,
|
||||
return buildAdminListEvent(listKey, {
|
||||
pubkey: actorHex,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
hexPubkeys,
|
||||
});
|
||||
}
|
||||
|
||||
function publishToRelay(url, signedEvent, listKey) {
|
||||
@@ -401,14 +452,40 @@ function publishToRelay(url, signedEvent, listKey) {
|
||||
}
|
||||
|
||||
if (typeof pub.on === "function") {
|
||||
pub.on("ok", () => finalize(true));
|
||||
pub.on("seen", () => finalize(true));
|
||||
pub.on("failed", (reason) => {
|
||||
const registerHandler = (eventName, handler) => {
|
||||
try {
|
||||
pub.on(eventName, handler);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Relay publish rejected ${eventName} listener:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFailure = (reason) => {
|
||||
const error =
|
||||
reason instanceof Error ? reason : new Error(String(reason || "failed"));
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error(String(reason || "failed"));
|
||||
finalize(false, error);
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let handlerRegistered = false;
|
||||
handlerRegistered =
|
||||
registerHandler("ok", () => finalize(true)) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("seen", () => finalize(true)) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("failed", handleFailure) || handlerRegistered;
|
||||
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof pub.then === "function") {
|
||||
@@ -556,3 +633,8 @@ export async function persistAdminState(actorNpub, updates) {
|
||||
|
||||
return persistNostrState(actorNpub, updates);
|
||||
}
|
||||
|
||||
export const __adminListStoreTestHooks = Object.freeze({
|
||||
extractNpubsFromEvent,
|
||||
normalizeParticipantTagValue,
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// js/applicationContext.js
|
||||
|
||||
let applicationInstance = null;
|
||||
let readyPromise = Promise.resolve();
|
||||
|
||||
export function setApplication(app) {
|
||||
applicationInstance = app || null;
|
||||
}
|
||||
|
||||
export function getApplication() {
|
||||
return applicationInstance;
|
||||
}
|
||||
|
||||
export function setApplicationReady(promise) {
|
||||
if (promise && typeof promise.then === "function") {
|
||||
readyPromise = promise;
|
||||
} else {
|
||||
readyPromise = Promise.resolve(promise);
|
||||
}
|
||||
}
|
||||
|
||||
export function getApplicationReady() {
|
||||
return readyPromise;
|
||||
}
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
// js/bootstrap.js
|
||||
|
||||
import Application from "./app.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView } from "./viewManager.js";
|
||||
|
||||
function mergeServices(overrides = {}) {
|
||||
const merged = { nostrService, r2Service };
|
||||
|
||||
if (overrides && typeof overrides === "object") {
|
||||
if (overrides.nostrService) {
|
||||
merged.nostrService = overrides.nostrService;
|
||||
}
|
||||
if (overrides.r2Service) {
|
||||
merged.r2Service = overrides.r2Service;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function createApplication({ services, loadView: loadViewOverride } = {}) {
|
||||
return new Application({
|
||||
services: mergeServices(services),
|
||||
loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView,
|
||||
});
|
||||
}
|
||||
|
||||
export default createApplication;
|
||||
+1948
-528
File diff suppressed because it is too large.
Load diff
@@ -2,15 +2,27 @@
|
||||
|
||||
import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_DM_IMAGE_URL,
|
||||
BITVID_WEBSITE_URL,
|
||||
MAX_WALLET_DEFAULT_ZAP,
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
DEFAULT_RELAY_URLS_OVERRIDE,
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
WATCH_HISTORY_BATCH_RESOLVE,
|
||||
WATCH_HISTORY_BATCH_PAGE_SIZE,
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
WATCH_HISTORY_FETCH_EVENT_LIMIT,
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
} from "../config/instance-config.js";
|
||||
|
||||
export const isDevMode = true; // Set to false for production
|
||||
@@ -21,6 +33,12 @@ export const isDevMode = true; // Set to false for production
|
||||
|
||||
export const ADMIN_LIST_MODE = "nostr";
|
||||
export { ADMIN_SUPER_NPUB };
|
||||
export { ADMIN_DM_IMAGE_URL };
|
||||
export { BITVID_WEBSITE_URL };
|
||||
export { MAX_WALLET_DEFAULT_ZAP };
|
||||
export { PLATFORM_FEE_PERCENT };
|
||||
export { PLATFORM_LUD16_OVERRIDE };
|
||||
export { DEFAULT_RELAY_URLS_OVERRIDE };
|
||||
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
|
||||
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
|
||||
|
||||
@@ -56,8 +74,14 @@ export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
||||
|
||||
export { WATCH_HISTORY_KIND };
|
||||
export { WATCH_HISTORY_LIST_IDENTIFIER };
|
||||
export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS };
|
||||
export { WATCH_HISTORY_MAX_ITEMS };
|
||||
export { WATCH_HISTORY_BATCH_RESOLVE };
|
||||
export { WATCH_HISTORY_BATCH_PAGE_SIZE };
|
||||
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
|
||||
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
|
||||
export { WATCH_HISTORY_CACHE_TTL_MS };
|
||||
export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS };
|
||||
export { VIEW_COUNT_BACKFILL_MAX_DAYS };
|
||||
export { VIEW_COUNT_CACHE_TTL_MS };
|
||||
export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS };
|
||||
+89
-3
@@ -1,10 +1,14 @@
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: false,
|
||||
FEATURE_PUBLISH_NIP71: false,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.btorrent.xyz",
|
||||
"wss://tracker.fastcast.nz",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://tracker.sloppyta.co:443/announce",
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -17,6 +21,10 @@ const runtimeFlags = (() => {
|
||||
const initial = {
|
||||
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
@@ -92,6 +100,21 @@ export let ACCEPT_LEGACY_V1 = coerceBoolean(
|
||||
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
|
||||
);
|
||||
|
||||
export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
|
||||
export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
|
||||
export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71,
|
||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
||||
);
|
||||
|
||||
export let WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
);
|
||||
@@ -118,6 +141,48 @@ Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
},
|
||||
set(next) {
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_PUBLISH_NIP71;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -132,6 +197,9 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
// Ensure the runtime object reflects the sanitized defaults immediately.
|
||||
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
|
||||
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||
|
||||
export function setUrlFirstEnabled(next) {
|
||||
@@ -144,6 +212,15 @@ export function setAcceptLegacyV1(next) {
|
||||
return ACCEPT_LEGACY_V1;
|
||||
}
|
||||
|
||||
export function setViewFilterIncludeLegacyVideo(next) {
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = next;
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
}
|
||||
|
||||
export function getWatchHistoryV2Enabled() {
|
||||
return FEATURE_WATCH_HISTORY_V2 === true;
|
||||
}
|
||||
|
||||
export function setWssTrackers(next) {
|
||||
runtimeFlags.WSS_TRACKERS = next;
|
||||
return WSS_TRACKERS;
|
||||
@@ -152,8 +229,17 @@ export function setWssTrackers(next) {
|
||||
export function resetRuntimeFlags() {
|
||||
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||
setViewFilterIncludeLegacyVideo(
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
|
||||
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
|
||||
}
|
||||
|
||||
export const RUNTIME_FLAGS = runtimeFlags;
|
||||
|
||||
export function setWatchHistoryV2Enabled(next) {
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next;
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
const DEFAULT_CARD_SELECTOR = ".video-card";
|
||||
|
||||
function defaultIsCardVisible(entry) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const isIntersecting = Boolean(entry.isIntersecting);
|
||||
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
|
||||
return isIntersecting && ratio > 0;
|
||||
}
|
||||
|
||||
function normalizeState(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function createCardObserver(options = {}) {
|
||||
const {
|
||||
rootMargin = "0px",
|
||||
threshold = 0,
|
||||
cardSelector = DEFAULT_CARD_SELECTOR,
|
||||
isCardVisible = defaultIsCardVisible,
|
||||
prepareEntries,
|
||||
onCardVisible,
|
||||
onCardRegister,
|
||||
createState,
|
||||
} = options;
|
||||
|
||||
const containerState = new WeakMap();
|
||||
|
||||
function ensureState(container) {
|
||||
let state = containerState.get(container);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const observedCards = new WeakSet();
|
||||
const customState = normalizeState(
|
||||
typeof createState === "function" ? createState(container) : {}
|
||||
);
|
||||
|
||||
const stateWrapper = {
|
||||
container,
|
||||
observedCards,
|
||||
customState,
|
||||
observer: null,
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
handleEntries(entries, stateWrapper);
|
||||
},
|
||||
{ root: null, rootMargin, threshold }
|
||||
);
|
||||
|
||||
stateWrapper.observer = observer;
|
||||
containerState.set(container, stateWrapper);
|
||||
return stateWrapper;
|
||||
}
|
||||
|
||||
function mapEntries(entries, state) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const context = {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
state: state.customState,
|
||||
};
|
||||
|
||||
if (typeof prepareEntries === "function") {
|
||||
try {
|
||||
const prepared = prepareEntries(entries, context);
|
||||
if (Array.isArray(prepared)) {
|
||||
return prepared;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] prepareEntries failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function handleEntries(entries, state) {
|
||||
const mapped = mapEntries(entries, state);
|
||||
if (!mapped.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapped.forEach((item) => {
|
||||
let entry = item;
|
||||
let card;
|
||||
let meta;
|
||||
|
||||
if (item && typeof item === "object" && "entry" in item) {
|
||||
entry = item.entry;
|
||||
card = item.card instanceof HTMLElement ? item.card : entry?.target;
|
||||
if ("meta" in item) {
|
||||
meta = item.meta;
|
||||
} else if ("data" in item) {
|
||||
meta = item.data;
|
||||
} else if (typeof item.priority !== "undefined") {
|
||||
meta = { priority: item.priority };
|
||||
}
|
||||
} else if (entry && entry.target instanceof HTMLElement) {
|
||||
card = entry.target;
|
||||
}
|
||||
|
||||
if (!(card instanceof HTMLElement) || !entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
let visible = false;
|
||||
try {
|
||||
visible = Boolean(isCardVisible(entry, card, {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
state: state.customState,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] isCardVisible failed", err);
|
||||
visible = false;
|
||||
}
|
||||
|
||||
if (!visible || typeof onCardVisible !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onCardVisible({
|
||||
card,
|
||||
entry,
|
||||
meta,
|
||||
state: state.customState,
|
||||
context: {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] onCardVisible failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observe(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = ensureState(container);
|
||||
const cards = container.querySelectorAll(cardSelector);
|
||||
cards.forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (state.observedCards.has(card)) {
|
||||
return;
|
||||
}
|
||||
state.observedCards.add(card);
|
||||
if (typeof onCardRegister === "function") {
|
||||
try {
|
||||
onCardRegister({
|
||||
card,
|
||||
state: state.customState,
|
||||
context: { container },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] onCardRegister failed", err);
|
||||
}
|
||||
}
|
||||
state.observer.observe(card);
|
||||
});
|
||||
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
handleEntries(records, state);
|
||||
}
|
||||
|
||||
return state.customState;
|
||||
}
|
||||
|
||||
function refresh(container) {
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
handleEntries(records, state);
|
||||
}
|
||||
}
|
||||
|
||||
function getState(container) {
|
||||
const state = containerState.get(container);
|
||||
return state ? state.customState : null;
|
||||
}
|
||||
|
||||
return {
|
||||
observe,
|
||||
refresh,
|
||||
getState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// js/feedEngine/engine.js
|
||||
|
||||
import { isPlainObject } from "./utils.js";
|
||||
|
||||
const DEFAULT_FEED_CONFIG = Object.freeze({
|
||||
timeWindow: null,
|
||||
actorFilters: [],
|
||||
tagFilters: [],
|
||||
sortOrder: "recent",
|
||||
});
|
||||
|
||||
const DEFAULT_CONFIG_SCHEMA = Object.freeze({
|
||||
timeWindow: {
|
||||
type: "relative-window",
|
||||
description:
|
||||
"Restrict results to a rolling time window (e.g., last 24 hours).",
|
||||
default: null,
|
||||
},
|
||||
actorFilters: {
|
||||
type: "string[]",
|
||||
description: "Optional list of author pubkeys to include in the feed.",
|
||||
default: [],
|
||||
},
|
||||
tagFilters: {
|
||||
type: "string[]",
|
||||
description: "Optional list of tag identifiers to include in the feed.",
|
||||
default: [],
|
||||
},
|
||||
sortOrder: {
|
||||
type: "enum",
|
||||
values: ["recent"],
|
||||
description:
|
||||
"Controls the final ordering of the feed. Currently only 'recent' is implemented.",
|
||||
default: "recent",
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeLogger(logger) {
|
||||
if (typeof logger === "function") {
|
||||
return logger;
|
||||
}
|
||||
if (logger && typeof logger.log === "function") {
|
||||
return (...args) => logger.log(...args);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function normalizeArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((entry) => typeof entry === "function");
|
||||
}
|
||||
|
||||
function normalizeDto(candidate) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = candidate.video ?? null;
|
||||
const pointer = candidate.pointer ?? null;
|
||||
const metadata = isPlainObject(candidate.metadata)
|
||||
? { ...candidate.metadata }
|
||||
: {};
|
||||
|
||||
return { video, pointer, metadata };
|
||||
}
|
||||
|
||||
function normalizeItems(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = [];
|
||||
for (const candidate of items) {
|
||||
const dto = normalizeDto(candidate);
|
||||
if (dto) {
|
||||
normalized.push(dto);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createExecutionContext(entry, { config, hooks, runtime, logger }) {
|
||||
const whyLog = [];
|
||||
const resolvedLogger = normalizeLogger(logger);
|
||||
|
||||
const context = {
|
||||
feedName: entry.name,
|
||||
config: { ...entry.defaultConfig, ...(isPlainObject(config) ? config : {}) },
|
||||
hooks: isPlainObject(hooks) ? { ...entry.hooks, ...hooks } : { ...entry.hooks },
|
||||
runtime: isPlainObject(runtime) ? { ...runtime } : {},
|
||||
log: (...args) => {
|
||||
try {
|
||||
resolvedLogger(`[feed:${entry.name}]`, ...args);
|
||||
} catch (error) {
|
||||
console.warn(`[feed:${entry.name}] logger threw`, error);
|
||||
}
|
||||
},
|
||||
addWhy: (detail) => {
|
||||
if (!isPlainObject(detail)) {
|
||||
return;
|
||||
}
|
||||
const record = { feed: entry.name, ...detail };
|
||||
whyLog.push(record);
|
||||
return record;
|
||||
},
|
||||
getWhy: () => whyLog.slice(),
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function normalizeDefinition(name, definition = {}) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
throw new Error("Feed name must be a non-empty string");
|
||||
}
|
||||
|
||||
if (typeof definition.source !== "function") {
|
||||
throw new Error(`Feed \"${name}\" is missing a source function`);
|
||||
}
|
||||
|
||||
const stages = normalizeArray(definition.stages);
|
||||
const decorators = normalizeArray(definition.decorators);
|
||||
const sorter =
|
||||
typeof definition.sorter === "function" ? definition.sorter : null;
|
||||
const hooks = isPlainObject(definition.hooks) ? { ...definition.hooks } : {};
|
||||
const defaultConfig = {
|
||||
...DEFAULT_FEED_CONFIG,
|
||||
...(isPlainObject(definition.defaultConfig) ? definition.defaultConfig : {}),
|
||||
};
|
||||
const configSchema = isPlainObject(definition.configSchema)
|
||||
? { ...DEFAULT_CONFIG_SCHEMA, ...definition.configSchema }
|
||||
: { ...DEFAULT_CONFIG_SCHEMA };
|
||||
|
||||
return {
|
||||
name,
|
||||
source: definition.source,
|
||||
stages,
|
||||
sorter,
|
||||
decorators,
|
||||
hooks,
|
||||
defaultConfig,
|
||||
configSchema,
|
||||
publicDefinition: Object.freeze({
|
||||
name,
|
||||
configDefaults: { ...defaultConfig },
|
||||
configSchema: { ...configSchema },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFeedEngine({ logger } = {}) {
|
||||
const feeds = new Map();
|
||||
const resolvedLogger = normalizeLogger(logger);
|
||||
|
||||
function registerFeed(name, definition) {
|
||||
if (feeds.has(name)) {
|
||||
throw new Error(`Feed \"${name}\" is already registered`);
|
||||
}
|
||||
|
||||
const entry = normalizeDefinition(name, definition);
|
||||
feeds.set(name, entry);
|
||||
return entry.publicDefinition;
|
||||
}
|
||||
|
||||
async function runFeed(name, options = {}) {
|
||||
const entry = feeds.get(name);
|
||||
if (!entry) {
|
||||
throw new Error(`Feed \"${name}\" is not registered`);
|
||||
}
|
||||
|
||||
const context = createExecutionContext(entry, {
|
||||
config: options.config,
|
||||
hooks: options.hooks,
|
||||
runtime: options.runtime,
|
||||
logger: resolvedLogger,
|
||||
});
|
||||
|
||||
const sourceResult = await entry.source(context);
|
||||
let items = normalizeItems(await Promise.resolve(sourceResult));
|
||||
|
||||
for (const stage of entry.stages) {
|
||||
const result = await stage(items, context);
|
||||
if (Array.isArray(result)) {
|
||||
items = normalizeItems(result);
|
||||
} else if (result == null) {
|
||||
items = normalizeItems(items);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.sorter) {
|
||||
const sorted = await entry.sorter(items, context);
|
||||
if (Array.isArray(sorted)) {
|
||||
items = normalizeItems(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
for (const decorator of entry.decorators) {
|
||||
const decorated = await decorator(items, context);
|
||||
if (Array.isArray(decorated)) {
|
||||
items = normalizeItems(decorated);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
videos: items.map((item) => item.video).filter(Boolean),
|
||||
metadata: {
|
||||
why: context.getWhy(),
|
||||
config: context.config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listFeeds() {
|
||||
return Array.from(feeds.values()).map((entry) => entry.publicDefinition);
|
||||
}
|
||||
|
||||
function getFeedDefinition(name) {
|
||||
const entry = feeds.get(name);
|
||||
return entry ? entry.publicDefinition : null;
|
||||
}
|
||||
|
||||
return {
|
||||
registerFeed,
|
||||
runFeed,
|
||||
listFeeds,
|
||||
getFeedDefinition,
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_FEED_CONFIG, DEFAULT_CONFIG_SCHEMA };
|
||||
@@ -0,0 +1,19 @@
|
||||
// js/feedEngine/index.js
|
||||
|
||||
export {
|
||||
createFeedEngine,
|
||||
DEFAULT_FEED_CONFIG,
|
||||
DEFAULT_CONFIG_SCHEMA,
|
||||
} from "./engine.js";
|
||||
export {
|
||||
createDedupeByRootStage,
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
} from "./stages.js";
|
||||
export { createChronologicalSorter } from "./sorters.js";
|
||||
export {
|
||||
createActiveNostrSource,
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
// js/feedEngine/sorters.js
|
||||
|
||||
export function createChronologicalSorter({ direction = "desc" } = {}) {
|
||||
const normalizedDirection = direction === "asc" ? "asc" : "desc";
|
||||
|
||||
return function chronologicalSorter(items = []) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const copy = [...items];
|
||||
copy.sort((a, b) => {
|
||||
const aTs = Number(a?.video?.created_at);
|
||||
const bTs = Number(b?.video?.created_at);
|
||||
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
|
||||
const normalizedBTs = Number.isFinite(bTs) ? bTs : Number.NEGATIVE_INFINITY;
|
||||
|
||||
if (normalizedATs !== normalizedBTs) {
|
||||
const diff = normalizedATs - normalizedBTs;
|
||||
return normalizedDirection === "asc" ? diff : -diff;
|
||||
}
|
||||
|
||||
const aId = typeof a?.video?.id === "string" ? a.video.id : "";
|
||||
const bId = typeof b?.video?.id === "string" ? b.video.id : "";
|
||||
return normalizedDirection === "asc"
|
||||
? aId.localeCompare(bId)
|
||||
: bId.localeCompare(aId);
|
||||
});
|
||||
|
||||
return copy;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// js/feedEngine/sources.js
|
||||
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import { isPlainObject, toArray, toSet } from "./utils.js";
|
||||
|
||||
function resolveService(candidate, fallback) {
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeAuthor(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeActorCandidate(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function createActiveNostrSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
|
||||
return async function activeNostrSource(context = {}) {
|
||||
const options = {
|
||||
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
||||
isAuthorBlocked:
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false,
|
||||
};
|
||||
|
||||
let videos = [];
|
||||
try {
|
||||
videos = await Promise.resolve(
|
||||
resolvedService.getFilteredActiveVideos(options)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.("[active-source] Failed to resolve active videos", error);
|
||||
videos = [];
|
||||
}
|
||||
|
||||
return (Array.isArray(videos) ? videos : []).map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:active",
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
|
||||
return async function subscriptionAuthorsSource(context = {}) {
|
||||
const runtimeAuthors = toArray(
|
||||
context?.runtime?.subscriptionAuthors || context?.runtime?.authors
|
||||
).map(normalizeAuthor);
|
||||
const configAuthors = toArray(context?.config?.actorFilters).map(
|
||||
normalizeAuthor
|
||||
);
|
||||
const hookAuthors = [];
|
||||
|
||||
const hook = context?.hooks?.subscriptions;
|
||||
if (isPlainObject(hook) && typeof hook.resolveAuthors === "function") {
|
||||
try {
|
||||
const resolved = await hook.resolveAuthors(context);
|
||||
hookAuthors.push(...toArray(resolved).map(normalizeAuthor));
|
||||
} catch (error) {
|
||||
context?.log?.("[subscriptions-source] resolveAuthors threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
const authors = new Set(
|
||||
[...runtimeAuthors, ...configAuthors, ...hookAuthors].filter(Boolean)
|
||||
);
|
||||
|
||||
if (!authors.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = {
|
||||
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
||||
isAuthorBlocked:
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false,
|
||||
};
|
||||
|
||||
let videos = [];
|
||||
try {
|
||||
videos = await Promise.resolve(
|
||||
resolvedService.getFilteredActiveVideos(options)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[subscriptions-source] Failed to resolve videos from nostrService",
|
||||
error
|
||||
);
|
||||
videos = [];
|
||||
}
|
||||
|
||||
const filtered = (Array.isArray(videos) ? videos : []).filter((video) => {
|
||||
const author = normalizeAuthor(video?.pubkey);
|
||||
return author && authors.has(author);
|
||||
});
|
||||
|
||||
return filtered.map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:subscriptions",
|
||||
matchedAuthor: normalizeAuthor(video?.pubkey),
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function createWatchHistoryPointerSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, watchHistoryService);
|
||||
|
||||
return async function watchHistoryPointerSource(context = {}) {
|
||||
const actor = normalizeActorCandidate(
|
||||
context?.config?.actor,
|
||||
context?.runtime?.watchHistory?.actor,
|
||||
context?.runtime?.actor
|
||||
);
|
||||
|
||||
if (!resolvedService || typeof resolvedService.getQueuedPointers !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let pointers = [];
|
||||
try {
|
||||
pointers = await Promise.resolve(
|
||||
resolvedService.getQueuedPointers(actor || undefined)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-source] Failed to load queued pointers",
|
||||
error
|
||||
);
|
||||
pointers = [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const hook = context?.hooks?.watchHistory;
|
||||
const resolveVideoHook =
|
||||
isPlainObject(hook) && typeof hook.resolveVideo === "function"
|
||||
? hook.resolveVideo
|
||||
: null;
|
||||
|
||||
for (const pointer of Array.isArray(pointers) ? pointers : []) {
|
||||
const dto = {
|
||||
video: null,
|
||||
pointer,
|
||||
metadata: {
|
||||
source: "watch-history",
|
||||
actor: actor || null,
|
||||
},
|
||||
};
|
||||
|
||||
if (pointer && isPlainObject(pointer) && pointer.video) {
|
||||
dto.video = pointer.video;
|
||||
}
|
||||
|
||||
if (!dto.video && resolveVideoHook) {
|
||||
try {
|
||||
const resolvedVideo = await resolveVideoHook(pointer, context);
|
||||
if (resolvedVideo) {
|
||||
dto.video = resolvedVideo;
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-source] resolveVideo hook threw",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// js/feedEngine/stages.js
|
||||
|
||||
import { getApplication } from "../applicationContext.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
|
||||
import { isPlainObject, toSet } from "./utils.js";
|
||||
|
||||
function resolveDedupeFunction(customDedupe) {
|
||||
if (typeof customDedupe === "function") {
|
||||
return customDedupe;
|
||||
}
|
||||
|
||||
const app = getApplication?.();
|
||||
if (app && typeof app.dedupeVideosByRoot === "function") {
|
||||
return (videos) => app.dedupeVideosByRoot(videos);
|
||||
}
|
||||
|
||||
return (videos) => dedupeToNewestByRoot(videos);
|
||||
}
|
||||
|
||||
export function createDedupeByRootStage({
|
||||
stageName = "dedupe-by-root",
|
||||
dedupe: customDedupe,
|
||||
} = {}) {
|
||||
const dedupeFn = resolveDedupeFunction(customDedupe);
|
||||
|
||||
return async function dedupeByRootStage(items = [], context = {}) {
|
||||
const videos = items.map((item) => item?.video).filter(Boolean);
|
||||
const dedupedVideos = dedupeFn(videos) || [];
|
||||
const allowedIds = new Set();
|
||||
|
||||
for (const video of dedupedVideos) {
|
||||
if (video && typeof video.id === "string") {
|
||||
allowedIds.add(video.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedIds.size) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const keep = [];
|
||||
for (const item of items) {
|
||||
const video = item?.video;
|
||||
const videoId = video && typeof video.id === "string" ? video.id : "";
|
||||
if (!videoId || allowedIds.has(videoId)) {
|
||||
keep.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rootId =
|
||||
typeof video?.videoRootId === "string" && video.videoRootId
|
||||
? video.videoRootId
|
||||
: videoId;
|
||||
|
||||
if (typeof item?.metadata === "object" && item.metadata) {
|
||||
item.metadata.droppedByStage = stageName;
|
||||
}
|
||||
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "dedupe",
|
||||
reason: "older-root-version",
|
||||
videoId,
|
||||
rootId,
|
||||
});
|
||||
}
|
||||
|
||||
return keep;
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlacklistFilterStage({
|
||||
stageName = "blacklist-filter",
|
||||
shouldIncludeVideo,
|
||||
} = {}) {
|
||||
const includeFn =
|
||||
typeof shouldIncludeVideo === "function"
|
||||
? shouldIncludeVideo
|
||||
: (video, options) => nostrService.shouldIncludeVideo(video, options);
|
||||
|
||||
return async function blacklistFilterStage(items = [], context = {}) {
|
||||
const blacklist = toSet(context?.runtime?.blacklistedEventIds);
|
||||
const isAuthorBlocked =
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false;
|
||||
|
||||
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
const video = item?.video;
|
||||
if (!video || typeof video !== "object") {
|
||||
results.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
let include = false;
|
||||
try {
|
||||
include = includeFn(video, options) !== false;
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] shouldIncludeVideo threw`, error);
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!include) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "blacklist",
|
||||
videoId: typeof video.id === "string" ? video.id : null,
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSuppressionHook(context) {
|
||||
if (!context || typeof context !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromHooks = context?.hooks?.watchHistory;
|
||||
if (isPlainObject(fromHooks) && typeof fromHooks.shouldSuppress === "function") {
|
||||
return fromHooks.shouldSuppress;
|
||||
}
|
||||
|
||||
const runtime = context?.runtime?.watchHistory;
|
||||
if (isPlainObject(runtime) && typeof runtime.shouldSuppress === "function") {
|
||||
return runtime.shouldSuppress;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWatchHistorySuppressionStage({
|
||||
stageName = "watch-history-suppression",
|
||||
shouldSuppress,
|
||||
} = {}) {
|
||||
return async function watchHistorySuppressionStage(items = [], context = {}) {
|
||||
const hook =
|
||||
typeof shouldSuppress === "function" ? shouldSuppress : resolveSuppressionHook(context);
|
||||
|
||||
if (typeof hook !== "function") {
|
||||
return items;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
let suppress = false;
|
||||
try {
|
||||
suppress = await hook(item, context);
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] suppression hook threw`, error);
|
||||
suppress = false;
|
||||
}
|
||||
|
||||
if (suppress) {
|
||||
const detail = {
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "watch-history",
|
||||
};
|
||||
if (item?.video && typeof item.video.id === "string") {
|
||||
detail.videoId = item.video.id;
|
||||
}
|
||||
if (item?.pointer) {
|
||||
detail.pointer = item.pointer;
|
||||
} else if (item?.metadata && item.metadata.pointerKey) {
|
||||
detail.pointerKey = item.metadata.pointerKey;
|
||||
}
|
||||
context?.addWhy?.(detail);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// js/feedEngine/utils.js
|
||||
|
||||
export function isPlainObject(value) {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function toSet(values) {
|
||||
if (values instanceof Set) {
|
||||
return new Set(values);
|
||||
}
|
||||
if (Array.isArray(values)) {
|
||||
return new Set(values);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
export function toArray(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return [...value];
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// js/feedEngine/watchHistoryFeed.js
|
||||
|
||||
import { pointerKey, normalizePointerInput } from "../nostr.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js";
|
||||
import {
|
||||
isWatchHistoryDebugEnabled,
|
||||
logWatchHistoryDebug,
|
||||
} from "../watchHistoryDebug.js";
|
||||
import {
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
} from "./stages.js";
|
||||
import { isPlainObject } from "./utils.js";
|
||||
|
||||
const WATCH_HISTORY_FEED_LOG_NAMESPACE = "watchHistoryFeed";
|
||||
|
||||
function debugInfo(message, details) {
|
||||
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "info", message, details);
|
||||
}
|
||||
|
||||
function debugWarn(message, details) {
|
||||
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "warn", message, details);
|
||||
}
|
||||
|
||||
function normalizeActorCandidate(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveWatchedAt(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (!Number.isFinite(candidate)) {
|
||||
continue;
|
||||
}
|
||||
let normalized = Math.floor(Number(candidate));
|
||||
if (normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (normalized > 10_000_000_000) {
|
||||
normalized = Math.floor(normalized / 1000);
|
||||
}
|
||||
if (normalized > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveResumeSeconds(candidate) {
|
||||
const candidates = [
|
||||
candidate?.resumeSeconds,
|
||||
candidate?.resume,
|
||||
candidate?.resumeAt,
|
||||
candidate?.resumePosition,
|
||||
candidate?.resumeTime,
|
||||
candidate?.progress?.resume,
|
||||
candidate?.progress?.seconds,
|
||||
candidate?.progress?.position,
|
||||
candidate?.pointer?.resumeSeconds,
|
||||
candidate?.pointer?.resume,
|
||||
candidate?.pointer?.resumeAt,
|
||||
];
|
||||
|
||||
for (const entry of candidates) {
|
||||
if (!Number.isFinite(entry)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = Math.max(0, Math.floor(entry));
|
||||
if (normalized > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCompleted(candidate) {
|
||||
const candidates = [
|
||||
candidate?.completed,
|
||||
candidate?.complete,
|
||||
candidate?.progress?.completed,
|
||||
candidate?.progress?.complete,
|
||||
candidate?.pointer?.completed,
|
||||
candidate?.pointer?.complete,
|
||||
];
|
||||
return candidates.some((value) => value === true);
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(raw) {
|
||||
const pointer = normalizePointerInput(raw?.pointer || raw);
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = pointerKey(pointer);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const watchedAt = resolveWatchedAt(
|
||||
raw?.watchedAt,
|
||||
raw?.timestamp,
|
||||
raw?.created_at,
|
||||
pointer?.watchedAt,
|
||||
raw?.metadata?.watchedAt,
|
||||
pointer?.metadata?.watchedAt,
|
||||
);
|
||||
|
||||
const resumeAt = (() => {
|
||||
const fromPointer = Number.isFinite(pointer?.resumeAt)
|
||||
? Math.max(0, Math.floor(pointer.resumeAt))
|
||||
: Number.isFinite(pointer?.metadata?.resumeAt)
|
||||
? Math.max(0, Math.floor(pointer.metadata.resumeAt))
|
||||
: null;
|
||||
if (fromPointer !== null) {
|
||||
return fromPointer;
|
||||
}
|
||||
return resolveResumeSeconds(raw);
|
||||
})();
|
||||
|
||||
const completed =
|
||||
pointer?.completed === true ||
|
||||
pointer?.metadata?.completed === true ||
|
||||
resolveCompleted(raw);
|
||||
|
||||
const pointerVideo =
|
||||
(pointer?.video && isPlainObject(pointer.video) ? pointer.video : null) ||
|
||||
(pointer?.metadata?.video && isPlainObject(pointer.metadata.video)
|
||||
? pointer.metadata.video
|
||||
: null);
|
||||
|
||||
const pointerProfile =
|
||||
(pointer?.profile && isPlainObject(pointer.profile) ? pointer.profile : null) ||
|
||||
(pointer?.metadata?.profile && isPlainObject(pointer.metadata.profile)
|
||||
? pointer.metadata.profile
|
||||
: null);
|
||||
|
||||
return {
|
||||
pointer,
|
||||
pointerKey: key,
|
||||
watchedAt,
|
||||
video:
|
||||
isPlainObject(raw?.video) && raw.video
|
||||
? raw.video
|
||||
: pointerVideo,
|
||||
metadata: {
|
||||
source: "watch-history",
|
||||
pointerKey: key,
|
||||
watchedAt,
|
||||
resumeAt,
|
||||
completed,
|
||||
session: raw?.session === true || pointer.session === true,
|
||||
video: pointerVideo,
|
||||
profile: pointerProfile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistorySource({ service = watchHistoryService } = {}) {
|
||||
return async function watchHistorySource(context = {}) {
|
||||
const actor = normalizeActorCandidate(
|
||||
context?.runtime?.watchHistory?.actor,
|
||||
context?.config?.actor,
|
||||
context?.runtime?.actor,
|
||||
);
|
||||
|
||||
if (!service || typeof service.loadLatest !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let items = [];
|
||||
try {
|
||||
items = await Promise.resolve(service.loadLatest(actor || undefined));
|
||||
} catch (error) {
|
||||
context?.log?.("[watch-history-feed] Failed to load history", error);
|
||||
items = [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const raw of Array.isArray(items) ? items : []) {
|
||||
const normalized = normalizeHistoryEntry(raw);
|
||||
if (normalized) {
|
||||
results.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (a.watchedAt !== b.watchedAt) {
|
||||
return b.watchedAt - a.watchedAt;
|
||||
}
|
||||
return a.pointerKey.localeCompare(b.pointerKey);
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistoryHydratorStage({
|
||||
service = watchHistoryService,
|
||||
metadataResolver,
|
||||
} = {}) {
|
||||
const resolver = metadataResolver || createWatchHistoryMetadataResolver();
|
||||
|
||||
return async function watchHistoryHydratorStage(items = [], context = {}) {
|
||||
const shouldStoreMetadata =
|
||||
typeof service?.shouldStoreMetadata === "function"
|
||||
? service.shouldStoreMetadata() !== false
|
||||
: true;
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointerKeyValue =
|
||||
typeof item.pointerKey === "string" && item.pointerKey
|
||||
? item.pointerKey
|
||||
: pointerKey(item.pointer);
|
||||
|
||||
const pointerType =
|
||||
typeof item?.pointer?.type === "string" ? item.pointer.type : null;
|
||||
const pointerValue =
|
||||
typeof item?.pointer?.value === "string" ? item.pointer.value : null;
|
||||
|
||||
const watchedAtValue = Number.isFinite(item?.watchedAt)
|
||||
? Math.max(0, Math.floor(Number(item.watchedAt)))
|
||||
: Number.isFinite(item?.metadata?.watchedAt)
|
||||
? Math.max(0, Math.floor(Number(item.metadata.watchedAt)))
|
||||
: 0;
|
||||
|
||||
let video = item.video || null;
|
||||
let profile = item.metadata?.profile || null;
|
||||
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Hydrating watch history item.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
hasInitialVideo: Boolean(video),
|
||||
hasInitialProfile: Boolean(profile),
|
||||
watchedAt: watchedAtValue || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (pointerKeyValue && typeof service?.getLocalMetadata === "function") {
|
||||
try {
|
||||
const stored = service.getLocalMetadata(pointerKeyValue);
|
||||
if (stored) {
|
||||
const hadVideo = Boolean(video);
|
||||
const hadProfile = Boolean(profile);
|
||||
video = video || stored.video || null;
|
||||
profile = profile || stored.profile || null;
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Loaded cached metadata for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
hadVideo,
|
||||
hadProfile,
|
||||
cachedVideo: Boolean(stored.video),
|
||||
cachedProfile: Boolean(stored.profile),
|
||||
resultingVideo: Boolean(video),
|
||||
resultingProfile: Boolean(profile),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to read cached metadata",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to read cached metadata for pointer.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video && resolver?.resolveVideo) {
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Resolving video via metadata resolver.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
});
|
||||
}
|
||||
try {
|
||||
video = await resolver.resolveVideo(item.pointer);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
if (video) {
|
||||
debugInfo("Metadata resolver returned video candidate.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
videoId: typeof video.id === "string" ? video.id : null,
|
||||
title:
|
||||
typeof video.title === "string" && video.title.trim()
|
||||
? video.title
|
||||
: null,
|
||||
invalid: Boolean(video.invalid),
|
||||
});
|
||||
} else {
|
||||
debugWarn("Metadata resolver returned no video candidate.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to resolve video from pointer",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Metadata resolver threw while resolving video.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (video?.pubkey && !profile && resolver?.resolveProfile) {
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Resolving profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
}
|
||||
try {
|
||||
profile = resolver.resolveProfile(video.pubkey) || null;
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
if (profile) {
|
||||
const label =
|
||||
profile.display_name ||
|
||||
profile.name ||
|
||||
profile.username ||
|
||||
profile.nip05 ||
|
||||
null;
|
||||
debugInfo("Resolved profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
label: label || null,
|
||||
});
|
||||
} else {
|
||||
debugWarn("Resolver returned no profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to resolve profile for pointer",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Profile resolver threw while hydrating metadata.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
...(isPlainObject(item.metadata) ? item.metadata : {}),
|
||||
pointerKey: pointerKeyValue,
|
||||
watchedAt: watchedAtValue || null,
|
||||
resumeAt: item.metadata?.resumeAt ?? null,
|
||||
completed: item.metadata?.completed ?? false,
|
||||
session: item.metadata?.session === true,
|
||||
video: video || null,
|
||||
profile: profile || null,
|
||||
};
|
||||
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
const summary = {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
watchedAt: watchedAtValue || null,
|
||||
videoId: typeof video?.id === "string" ? video.id : null,
|
||||
title:
|
||||
typeof video?.title === "string" && video.title?.trim()
|
||||
? video.title
|
||||
: null,
|
||||
invalid: Boolean(video?.invalid),
|
||||
hasProfile: Boolean(profile),
|
||||
};
|
||||
if (video) {
|
||||
debugInfo("Hydrated watch history entry metadata.", summary);
|
||||
} else {
|
||||
debugWarn("Hydrated entry is missing resolved video metadata.", summary);
|
||||
}
|
||||
}
|
||||
|
||||
const nextItem = {
|
||||
...item,
|
||||
pointerKey: pointerKeyValue,
|
||||
watchedAt: watchedAtValue,
|
||||
video: video || null,
|
||||
metadata,
|
||||
};
|
||||
results.push(nextItem);
|
||||
|
||||
if (pointerKeyValue) {
|
||||
if (shouldStoreMetadata) {
|
||||
try {
|
||||
service?.setLocalMetadata?.(pointerKeyValue, {
|
||||
video: metadata.video,
|
||||
profile: metadata.profile,
|
||||
});
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Stored metadata cache entry for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
cachedVideo: Boolean(metadata.video),
|
||||
cachedProfile: Boolean(metadata.profile),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to persist metadata cache",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to persist metadata cache entry.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof service?.removeLocalMetadata === "function") {
|
||||
try {
|
||||
service.removeLocalMetadata(pointerKeyValue);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Cleared metadata cache entry for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to clear cached metadata",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to clear cached metadata entry.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistorySorter() {
|
||||
return function watchHistorySorter(items = []) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const copy = [...items];
|
||||
copy.sort((a, b) => {
|
||||
if (a?.watchedAt !== b?.watchedAt) {
|
||||
return (b?.watchedAt || 0) - (a?.watchedAt || 0);
|
||||
}
|
||||
const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : "";
|
||||
const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : "";
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
return copy;
|
||||
};
|
||||
}
|
||||
|
||||
export function createWatchHistoryFeedDefinition({
|
||||
service = watchHistoryService,
|
||||
nostr = nostrService,
|
||||
metadataResolverFactory = createWatchHistoryMetadataResolver,
|
||||
shouldIncludeVideo,
|
||||
} = {}) {
|
||||
const metadataResolver = metadataResolverFactory({ nostr });
|
||||
|
||||
const blacklistStage = createBlacklistFilterStage({
|
||||
shouldIncludeVideo:
|
||||
typeof shouldIncludeVideo === "function"
|
||||
? shouldIncludeVideo
|
||||
: (video, options) =>
|
||||
(nostr?.shouldIncludeVideo
|
||||
? nostr.shouldIncludeVideo(video, options)
|
||||
: true),
|
||||
});
|
||||
|
||||
return {
|
||||
source: createWatchHistorySource({ service }),
|
||||
stages: [
|
||||
createWatchHistoryHydratorStage({ service, metadataResolver }),
|
||||
blacklistStage,
|
||||
createWatchHistorySuppressionStage(),
|
||||
],
|
||||
sorter: createWatchHistorySorter(),
|
||||
defaultConfig: {
|
||||
actorFilters: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWatchHistoryFeed(engine, options = {}) {
|
||||
if (!engine || typeof engine.registerFeed !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = createWatchHistoryFeedDefinition(options);
|
||||
return engine.registerFeed("watch-history", definition);
|
||||
}
|
||||
|
||||
+80
-59
@@ -1,7 +1,26 @@
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { infoHashFromMagnet } from "./magnets.js";
|
||||
import { TorrentClient, torrentClient } from "./webtorrent.js";
|
||||
|
||||
const containerState = new WeakMap();
|
||||
const badgeUpdateListeners = new Set();
|
||||
|
||||
function notifyBadgeUpdate(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
badgeUpdateListeners.forEach((listener) => {
|
||||
if (typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
listener(payload);
|
||||
} catch (err) {
|
||||
console.warn("[gridHealth] badge listener failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ROOT_MARGIN = "200px 0px";
|
||||
|
||||
function now() {
|
||||
@@ -249,28 +268,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
|
||||
const probeCache = new Map();
|
||||
const probeInflight = new Map();
|
||||
|
||||
function ensureState(container) {
|
||||
let state = containerState.get(container);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const pendingByCard = new WeakMap();
|
||||
const observedCards = new WeakSet();
|
||||
state = { observer: null, pendingByCard, observedCards };
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
processObserverEntries(entries, state);
|
||||
},
|
||||
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
|
||||
);
|
||||
|
||||
state.observer = observer;
|
||||
containerState.set(container, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function getViewportCenter() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
@@ -389,18 +386,6 @@ function prioritizeEntries(entries, viewportCenter) {
|
||||
}));
|
||||
}
|
||||
|
||||
function processObserverEntries(entries, state) {
|
||||
if (!state || !entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const viewportCenter = getViewportCenter();
|
||||
const prioritized = prioritizeEntries(entries, viewportCenter);
|
||||
prioritized.forEach(({ entry, priority }) => {
|
||||
const card = entry.target;
|
||||
handleCardVisible({ card, pendingByCard: state.pendingByCard, priority });
|
||||
});
|
||||
}
|
||||
|
||||
function getIntersectionRect(entry) {
|
||||
if (!entry) {
|
||||
return null;
|
||||
@@ -513,7 +498,8 @@ function setBadge(card, state, details) {
|
||||
});
|
||||
badge.setAttribute("aria-label", tooltip);
|
||||
badge.setAttribute("title", tooltip);
|
||||
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
|
||||
const ariaLive = entry.role === "alert" ? "assertive" : "polite";
|
||||
badge.setAttribute("aria-live", ariaLive);
|
||||
badge.setAttribute("role", entry.role);
|
||||
badge.dataset.streamHealthState = normalizedState;
|
||||
if (hasPeerCount) {
|
||||
@@ -521,6 +507,20 @@ function setBadge(card, state, details) {
|
||||
} else if (badge.dataset.streamHealthPeers) {
|
||||
delete badge.dataset.streamHealthPeers;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
card,
|
||||
state: normalizedState,
|
||||
peers: hasPeerCount ? peersValue : null,
|
||||
reason: details && typeof details.reason === "string" ? details.reason : null,
|
||||
checkedAt:
|
||||
details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null,
|
||||
text: badge.textContent,
|
||||
tooltip,
|
||||
role: entry.role,
|
||||
ariaLive,
|
||||
};
|
||||
notifyBadgeUpdate(payload);
|
||||
}
|
||||
|
||||
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||
@@ -539,6 +539,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(pendingByCard instanceof WeakMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingByCard.has(card)) {
|
||||
return;
|
||||
}
|
||||
@@ -590,36 +594,53 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||
});
|
||||
}
|
||||
|
||||
export function attachHealthBadges(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const state = ensureState(container);
|
||||
const cards = container.querySelectorAll(".video-card");
|
||||
cards.forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
const gridCardObserver = createCardObserver({
|
||||
rootMargin: ROOT_MARGIN,
|
||||
threshold: 0.01,
|
||||
createState: () => ({ pendingByCard: new WeakMap() }),
|
||||
prepareEntries: (entries) => {
|
||||
const viewportCenter = getViewportCenter();
|
||||
return prioritizeEntries(entries, viewportCenter);
|
||||
},
|
||||
isCardVisible: (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (state.observedCards.has(card)) {
|
||||
return;
|
||||
}
|
||||
state.observedCards.add(card);
|
||||
state.observer.observe(card);
|
||||
const isIntersecting = Boolean(entry.isIntersecting);
|
||||
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
|
||||
return isIntersecting && ratio > 0;
|
||||
},
|
||||
onCardRegister: ({ card }) => {
|
||||
if (!card.dataset.magnet) {
|
||||
setBadge(card, "unhealthy", { reason: "missing-source" });
|
||||
}
|
||||
});
|
||||
processObserverEntries(state.observer.takeRecords(), state);
|
||||
}
|
||||
},
|
||||
onCardVisible: ({ card, meta, state }) => {
|
||||
if (!state || !(state.pendingByCard instanceof WeakMap)) {
|
||||
return;
|
||||
}
|
||||
const priority =
|
||||
meta && typeof meta === "object" && Number.isFinite(meta.priority)
|
||||
? meta.priority
|
||||
: PRIORITY_BASELINE;
|
||||
handleCardVisible({
|
||||
card,
|
||||
pendingByCard: state.pendingByCard,
|
||||
priority,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function refreshHealthBadges(container) {
|
||||
export function attachHealthBadges(container, options = {}) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
if (options && typeof options.onUpdate === "function") {
|
||||
badgeUpdateListeners.add(options.onUpdate);
|
||||
}
|
||||
const records = state.observer.takeRecords();
|
||||
processObserverEntries(records, state);
|
||||
gridCardObserver.observe(container);
|
||||
}
|
||||
|
||||
export function refreshHealthBadges(container) {
|
||||
gridCardObserver.refresh(container);
|
||||
}
|
||||
+1870
-1254
File diff suppressed because it is too large.
Load diff
+449
-150
@@ -1,7 +1,50 @@
|
||||
// js/index.js
|
||||
|
||||
import { validateInstanceConfig } from "../config/validate-config.js";
|
||||
import { ASSET_VERSION } from "../config/asset-version.js";
|
||||
import "./bufferPolyfill.js";
|
||||
import { trackPageView } from "./analytics.js";
|
||||
import Application from "./app.js";
|
||||
import { setApplication, setApplicationReady } from "./applicationContext.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView, viewInitRegistry } from "./viewManager.js";
|
||||
|
||||
validateInstanceConfig();
|
||||
|
||||
let application = null;
|
||||
let applicationReadyPromise = Promise.resolve();
|
||||
|
||||
function startApplication() {
|
||||
if (application) {
|
||||
return applicationReadyPromise;
|
||||
}
|
||||
|
||||
application = new Application({
|
||||
services: {
|
||||
nostrService,
|
||||
r2Service,
|
||||
},
|
||||
loadView,
|
||||
});
|
||||
|
||||
setApplication(application);
|
||||
|
||||
const startupPromise = (async () => {
|
||||
await application.init();
|
||||
if (typeof application.start === "function") {
|
||||
await application.start();
|
||||
}
|
||||
})();
|
||||
|
||||
applicationReadyPromise = startupPromise;
|
||||
setApplicationReady(startupPromise);
|
||||
|
||||
startupPromise.catch((error) => {
|
||||
console.error("Application failed to initialize:", error);
|
||||
});
|
||||
|
||||
return startupPromise;
|
||||
}
|
||||
|
||||
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
|
||||
@@ -60,13 +103,26 @@ document.addEventListener("animationend", handleFadeInAnimationComplete, true);
|
||||
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
|
||||
|
||||
// 1) Load modals (login, application, etc.)
|
||||
const withAssetVersion = (url) => {
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}v=${encodeURIComponent(ASSET_VERSION)}`;
|
||||
};
|
||||
|
||||
const fetchPartial = async (url) => {
|
||||
const response = await fetch(withAssetVersion(url), { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
async function loadModal(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
|
||||
const sanitizedHtml = html.replace(
|
||||
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
|
||||
@@ -84,11 +140,7 @@ async function loadModal(url) {
|
||||
// 2) Load sidebar
|
||||
async function loadSidebar(url, containerId) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
document.getElementById(containerId).innerHTML = html;
|
||||
console.log(url, "loaded into", containerId);
|
||||
} catch (err) {
|
||||
@@ -99,11 +151,7 @@ async function loadSidebar(url, containerId) {
|
||||
// 3) Load the disclaimer (now separate)
|
||||
async function loadDisclaimer(url, containerId) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
document.getElementById(containerId).insertAdjacentHTML("beforeend", html);
|
||||
console.log(url, "disclaimer loaded into", containerId);
|
||||
} catch (err) {
|
||||
@@ -111,138 +159,376 @@ async function loadDisclaimer(url, containerId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Load everything: modals, sidebar, disclaimers
|
||||
Promise.all([
|
||||
// Existing modals
|
||||
loadModal("components/login-modal.html"),
|
||||
loadModal("components/application-form.html"),
|
||||
loadModal("components/content-appeals-form.html"),
|
||||
async function bootstrapInterface() {
|
||||
await Promise.all([
|
||||
loadModal("components/login-modal.html"),
|
||||
loadModal("components/application-form.html"),
|
||||
loadModal("components/content-appeals-form.html"),
|
||||
loadModal("components/general-feedback-form.html"),
|
||||
loadModal("components/feature-request-form.html"),
|
||||
loadModal("components/bug-fix-form.html"),
|
||||
]);
|
||||
|
||||
// New forms
|
||||
loadModal("components/general-feedback-form.html"),
|
||||
loadModal("components/feature-request-form.html"),
|
||||
loadModal("components/bug-fix-form.html"),
|
||||
])
|
||||
.then(() => {
|
||||
console.log("Modals loaded.");
|
||||
return loadSidebar("components/sidebar.html", "sidebarContainer");
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Sidebar loaded.");
|
||||
console.log("Modals loaded.");
|
||||
|
||||
// Attach mobile menu button toggle logic (for sidebar)
|
||||
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const app = document.getElementById("app");
|
||||
if (mobileMenuBtn && sidebar && app) {
|
||||
mobileMenuBtn.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("sidebar-open");
|
||||
app.classList.toggle("sidebar-open");
|
||||
});
|
||||
await loadSidebar("components/sidebar.html", "sidebarContainer");
|
||||
console.log("Sidebar loaded.");
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const collapseToggle = document.getElementById("sidebarCollapseToggle");
|
||||
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
|
||||
sidebar.setAttribute("data-footer-state", "collapsed");
|
||||
}
|
||||
if (!collapseToggle) {
|
||||
console.warn("Sidebar collapse toggle not found; skipping density controls.");
|
||||
}
|
||||
|
||||
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
|
||||
const sidebarOverlay = document.getElementById("sidebarOverlay");
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebarCollapsed";
|
||||
const SIDEBAR_WIDTH_EXPANDED = "16rem";
|
||||
const SIDEBAR_WIDTH_COLLAPSED = "4rem";
|
||||
const DEFAULT_SIDEBAR_COLLAPSED = true;
|
||||
let isSidebarCollapsed = DEFAULT_SIDEBAR_COLLAPSED;
|
||||
let isFooterDropupExpanded = false;
|
||||
let syncFooterDropupFn = null;
|
||||
|
||||
const readStoredSidebarCollapsed = () => {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||
if (storedValue === null) {
|
||||
return DEFAULT_SIDEBAR_COLLAPSED;
|
||||
}
|
||||
return storedValue === "true";
|
||||
} catch (error) {
|
||||
console.warn("Unable to read sidebar collapse state from storage:", error);
|
||||
return DEFAULT_SIDEBAR_COLLAPSED;
|
||||
}
|
||||
};
|
||||
|
||||
// Attach "More" button toggle logic for footer links
|
||||
const footerDropdownButton = document.getElementById(
|
||||
"footerDropdownButton"
|
||||
const persistSidebarCollapsed = (collapsed) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
collapsed ? "true" : "false",
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Unable to persist sidebar collapse state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const applySidebarDensity = (collapsed) => {
|
||||
const widthTargets = [document.documentElement, document.body].filter(
|
||||
(element) => element instanceof HTMLElement,
|
||||
);
|
||||
if (footerDropdownButton) {
|
||||
footerDropdownButton.addEventListener("click", () => {
|
||||
const footerLinksContainer = document.getElementById(
|
||||
"footerLinksContainer"
|
||||
);
|
||||
if (!footerLinksContainer) return;
|
||||
footerLinksContainer.classList.toggle("hidden");
|
||||
if (footerLinksContainer.classList.contains("hidden")) {
|
||||
footerDropdownButton.innerHTML = "More ▼";
|
||||
} else {
|
||||
footerDropdownButton.innerHTML = "Less ▲";
|
||||
}
|
||||
});
|
||||
if (sidebar instanceof HTMLElement) {
|
||||
widthTargets.push(sidebar);
|
||||
}
|
||||
const appShell = document.getElementById("app");
|
||||
if (appShell instanceof HTMLElement) {
|
||||
widthTargets.push(appShell);
|
||||
}
|
||||
const state = collapsed ? "collapsed" : "expanded";
|
||||
const nextWidth = collapsed
|
||||
? SIDEBAR_WIDTH_COLLAPSED
|
||||
: SIDEBAR_WIDTH_EXPANDED;
|
||||
|
||||
// Load and set up sidebar navigation
|
||||
return import("./sidebar.js").then((module) => {
|
||||
module.setupSidebarNavigation();
|
||||
widthTargets.forEach((element) => {
|
||||
element.style.setProperty("--sidebar-width", nextWidth);
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Now load the disclaimer
|
||||
return loadDisclaimer("components/disclaimer.html", "modalContainer");
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Disclaimer loaded.");
|
||||
|
||||
// 1) Login button => open login modal
|
||||
const loginNavBtn = document.getElementById("loginButton");
|
||||
if (loginNavBtn) {
|
||||
loginNavBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
if (appShell instanceof HTMLElement) {
|
||||
appShell.dataset.sidebarState = state;
|
||||
}
|
||||
|
||||
// 2) Close login modal
|
||||
const closeLoginBtn = document.getElementById("closeLoginModal");
|
||||
if (closeLoginBtn) {
|
||||
closeLoginBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
[sidebar, document.body]
|
||||
.filter((element) => element instanceof HTMLElement)
|
||||
.forEach((element) => {
|
||||
element.classList.toggle("sidebar-collapsed", collapsed);
|
||||
element.classList.toggle("sidebar-expanded", !collapsed);
|
||||
element.setAttribute("data-state", state);
|
||||
});
|
||||
|
||||
if (collapseToggle) {
|
||||
const actionLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
|
||||
collapseToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
collapseToggle.setAttribute("data-state", state);
|
||||
collapseToggle.setAttribute("aria-label", actionLabel);
|
||||
collapseToggle.setAttribute("title", actionLabel);
|
||||
}
|
||||
|
||||
// 3) "Application Form" => open application form
|
||||
const openAppFormBtn = document.getElementById("openApplicationModal");
|
||||
if (openAppFormBtn) {
|
||||
openAppFormBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
if (
|
||||
collapsed &&
|
||||
isFooterDropupExpanded &&
|
||||
typeof syncFooterDropupFn === "function"
|
||||
) {
|
||||
syncFooterDropupFn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncSidebarDensityToViewport = (isDesktopViewport) => {
|
||||
if (!collapseToggle) {
|
||||
applySidebarDensity(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Close application form
|
||||
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
|
||||
if (closeNostrFormBtn) {
|
||||
closeNostrFormBtn.addEventListener("click", () => {
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.add("hidden");
|
||||
}
|
||||
// If user hasn't seen disclaimer, show it
|
||||
if (!localStorage.getItem("hasSeenDisclaimer")) {
|
||||
const disclaimerModal = document.getElementById("disclaimerModal");
|
||||
if (disclaimerModal) {
|
||||
disclaimerModal.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (isDesktopViewport) {
|
||||
isSidebarCollapsed = readStoredSidebarCollapsed();
|
||||
applySidebarDensity(isSidebarCollapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once everything is loaded, handle the query params (modal? v?) & disclaimers
|
||||
handleQueryParams();
|
||||
applySidebarDensity(false);
|
||||
};
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
if (collapseToggle) {
|
||||
isSidebarCollapsed = readStoredSidebarCollapsed();
|
||||
}
|
||||
|
||||
// Also run once on initial load
|
||||
handleHashChange();
|
||||
const setSidebarState = (isOpen) => {
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle("sidebar-open", isOpen);
|
||||
}
|
||||
document.body.classList.toggle("sidebar-open", isOpen);
|
||||
if (mobileMenuBtn) {
|
||||
mobileMenuBtn.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
return import("./disclaimer.js");
|
||||
})
|
||||
.then(({ default: disclaimerModal }) => {
|
||||
disclaimerModal.init();
|
||||
disclaimerModal.show();
|
||||
const closeSidebar = () => setSidebarState(false);
|
||||
const isMobileViewport = () => {
|
||||
if (typeof window.matchMedia === "function") {
|
||||
return window.matchMedia("(max-width: 767px)").matches;
|
||||
}
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (!sidebar) return;
|
||||
const isMobile = isMobileViewport();
|
||||
if (!isMobile) return;
|
||||
const shouldOpen = !sidebar.classList.contains("sidebar-open");
|
||||
setSidebarState(shouldOpen);
|
||||
};
|
||||
|
||||
let desktopQuery = null;
|
||||
const isDesktopViewport = () => {
|
||||
if (desktopQuery) {
|
||||
return desktopQuery.matches;
|
||||
}
|
||||
return window.innerWidth >= 768;
|
||||
};
|
||||
|
||||
if (collapseToggle) {
|
||||
collapseToggle.addEventListener("click", (event) => {
|
||||
const desktop = isDesktopViewport();
|
||||
if (!desktop) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextCollapsed = !isSidebarCollapsed;
|
||||
isSidebarCollapsed = nextCollapsed;
|
||||
applySidebarDensity(nextCollapsed);
|
||||
persistSidebarCollapsed(nextCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileMenuBtn && sidebar) {
|
||||
mobileMenuBtn.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.addEventListener("click", closeSidebar);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && sidebar && sidebar.classList.contains("sidebar-open")) {
|
||||
closeSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof window.matchMedia === "function") {
|
||||
desktopQuery = window.matchMedia("(min-width: 768px)");
|
||||
syncSidebarDensityToViewport(desktopQuery.matches);
|
||||
|
||||
const onDesktopChange = (event) => {
|
||||
if (event.matches) {
|
||||
closeSidebar();
|
||||
syncSidebarDensityToViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
syncSidebarDensityToViewport(false);
|
||||
};
|
||||
|
||||
if (typeof desktopQuery.addEventListener === "function") {
|
||||
desktopQuery.addEventListener("change", onDesktopChange);
|
||||
} else if (typeof desktopQuery.addListener === "function") {
|
||||
desktopQuery.addListener(onDesktopChange);
|
||||
}
|
||||
} else {
|
||||
syncSidebarDensityToViewport(window.innerWidth >= 768);
|
||||
}
|
||||
|
||||
const footerDropdownButton = document.getElementById("footerDropdownButton");
|
||||
const footerLinksContainer = document.getElementById("footerLinksContainer");
|
||||
const footerDropdownLabel = document.getElementById("footerDropdownText");
|
||||
const footerDropdownIcon = document.getElementById("footerDropdownIcon");
|
||||
|
||||
if (footerDropdownButton && footerLinksContainer) {
|
||||
const sidebarFooter = footerDropdownButton.closest(".sidebar-footer");
|
||||
|
||||
syncFooterDropupFn = (expanded) => {
|
||||
const nextState = expanded ? "expanded" : "collapsed";
|
||||
const actionLabel = expanded ? "Show fewer sidebar links" : "Show more sidebar links";
|
||||
|
||||
footerDropdownButton.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
footerDropdownButton.dataset.state = nextState;
|
||||
footerDropdownButton.setAttribute("aria-label", actionLabel);
|
||||
footerDropdownButton.setAttribute("title", actionLabel);
|
||||
|
||||
if (footerDropdownLabel) {
|
||||
footerDropdownLabel.textContent = expanded ? "Less" : "More";
|
||||
}
|
||||
|
||||
if (footerDropdownIcon) {
|
||||
footerDropdownIcon.classList.toggle("is-rotated", expanded);
|
||||
}
|
||||
|
||||
footerLinksContainer.classList.remove("hidden");
|
||||
footerLinksContainer.setAttribute("aria-hidden", expanded ? "false" : "true");
|
||||
footerLinksContainer.dataset.state = nextState;
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.setAttribute("data-footer-state", nextState);
|
||||
}
|
||||
|
||||
if (sidebarFooter instanceof HTMLElement) {
|
||||
sidebarFooter.dataset.footerState = nextState;
|
||||
}
|
||||
isFooterDropupExpanded = expanded;
|
||||
};
|
||||
|
||||
footerDropdownButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const expanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
|
||||
|
||||
if (isSidebarCollapsed && !expanded) {
|
||||
const nextCollapsed = false;
|
||||
isSidebarCollapsed = nextCollapsed;
|
||||
applySidebarDensity(nextCollapsed);
|
||||
persistSidebarCollapsed(nextCollapsed);
|
||||
}
|
||||
|
||||
if (typeof syncFooterDropupFn === "function") {
|
||||
syncFooterDropupFn(!expanded);
|
||||
}
|
||||
});
|
||||
|
||||
const initialExpanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
|
||||
if (typeof syncFooterDropupFn === "function") {
|
||||
syncFooterDropupFn(initialExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sidebarModule = await import("./sidebar.js");
|
||||
if (typeof sidebarModule.setupSidebarNavigation === "function") {
|
||||
sidebarModule.setupSidebarNavigation({ closeSidebar });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set up sidebar navigation:", error);
|
||||
}
|
||||
|
||||
await loadDisclaimer("components/disclaimer.html", "modalContainer");
|
||||
console.log("Disclaimer loaded.");
|
||||
|
||||
const loginNavBtn = document.getElementById("loginButton");
|
||||
if (loginNavBtn) {
|
||||
loginNavBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeLoginBtn = document.getElementById("closeLoginModal");
|
||||
if (closeLoginBtn) {
|
||||
closeLoginBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const openAppFormBtn = document.getElementById("openApplicationModal");
|
||||
if (openAppFormBtn) {
|
||||
openAppFormBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
|
||||
if (closeNostrFormBtn) {
|
||||
closeNostrFormBtn.addEventListener("click", () => {
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.add("hidden");
|
||||
}
|
||||
if (!localStorage.getItem("hasSeenDisclaimer")) {
|
||||
const disclaimerModal = document.getElementById("disclaimerModal");
|
||||
if (disclaimerModal) {
|
||||
disclaimerModal.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleQueryParams();
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
await handleHashChange();
|
||||
|
||||
const { default: disclaimerModal } = await import("./disclaimer.js");
|
||||
disclaimerModal.init();
|
||||
disclaimerModal.show();
|
||||
}
|
||||
|
||||
async function initializeInterface() {
|
||||
startApplication();
|
||||
|
||||
try {
|
||||
await bootstrapInterface();
|
||||
} catch (error) {
|
||||
console.error("Failed to bootstrap Bitvid interface:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function onDomReady() {
|
||||
initializeInterface().catch((error) => {
|
||||
console.error("Unhandled error during Bitvid initialization:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", onDomReady, { once: true });
|
||||
} else {
|
||||
onDomReady();
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
HELPER FUNCTIONS FOR QUERY AND HASH
|
||||
-------------------------------------------- */
|
||||
@@ -257,6 +543,10 @@ export function setHashView(viewName) {
|
||||
url.searchParams.delete("v");
|
||||
const newUrl = url.pathname + url.search + `#view=${viewName}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
|
||||
}
|
||||
|
||||
handleHashChange();
|
||||
}
|
||||
|
||||
@@ -362,37 +652,46 @@ function handleQueryParams() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
async function handleHashChange() {
|
||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||
|
||||
try {
|
||||
await applicationReadyPromise;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Proceeding with hash handling despite application initialization failure:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const hash = window.location.hash || "";
|
||||
// Use a regex that captures up to the first ampersand or end of string.
|
||||
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
|
||||
const match = hash.match(/^#view=([^&]+)/);
|
||||
|
||||
if (!match || !match[1]) {
|
||||
// No valid "#view=..." => default to "most-recent-videos"
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView("views/most-recent-videos.html").then(() => {
|
||||
const initFn = viewInitRegistry["most-recent-videos"];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
try {
|
||||
if (!match || !match[1]) {
|
||||
// No valid "#view=..." => default to "most-recent-videos"
|
||||
await loadView("views/most-recent-videos.html");
|
||||
const initFn = viewInitRegistry["most-recent-videos"];
|
||||
if (typeof initFn === "function") {
|
||||
await initFn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const viewName = match[1]; // only the chunk before any '&'
|
||||
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
|
||||
}
|
||||
const viewUrl = `views/${viewName}.html`;
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
await loadView(viewUrl);
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
await initFn();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle hash change:", error);
|
||||
}
|
||||
|
||||
const viewName = match[1]; // only the chunk before any '&'
|
||||
const viewUrl = `views/${viewName}.html`;
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView(viewUrl).then(() => {
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+31
-205
@@ -1,224 +1,50 @@
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
buildMagnetUri,
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
formatAbsoluteUrl,
|
||||
normalizeMagnetInput,
|
||||
resolveAppProtocol,
|
||||
extractMagnetHints as sharedExtractMagnetHints,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
export const extractMagnetHints = sharedExtractMagnetHints;
|
||||
|
||||
function getOriginProtocol() {
|
||||
if (typeof window !== "undefined" && window.location?.protocol) {
|
||||
return window.location.protocol.toLowerCase();
|
||||
}
|
||||
return "https:";
|
||||
}
|
||||
|
||||
function decodeLoose(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function tryFormatAbsoluteUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.toString();
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and enrich a magnet string entered in the upload form so that it
|
||||
* always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of
|
||||
* the legacy magnetUtils helper but returns a plain string for ease of use.
|
||||
*/
|
||||
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
|
||||
if (HEX_INFO_HASH.test(working)) {
|
||||
working = `magnet:?xt=urn:btih:${working.toLowerCase()}`;
|
||||
if (!isMagnet) {
|
||||
return canonicalValue;
|
||||
}
|
||||
|
||||
working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`);
|
||||
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
return working;
|
||||
}
|
||||
|
||||
let fragment = "";
|
||||
const hashIndex = working.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const [, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = "magnet:";
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let value = rawVal.trim();
|
||||
if (key.toLowerCase() === "xt" && value) {
|
||||
const decoded = decodeLoose(value);
|
||||
if (decoded) {
|
||||
value = decoded;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value,
|
||||
lowerKey: key.toLowerCase(),
|
||||
compareValue: normalizeForComparison(value),
|
||||
});
|
||||
}
|
||||
|
||||
const appendUniqueParam = (key, value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return;
|
||||
}
|
||||
const lowerKey = key.toLowerCase();
|
||||
const compareValue = normalizeForComparison(trimmedValue);
|
||||
if (compareValue) {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === lowerKey && param.compareValue === compareValue
|
||||
);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value: trimmedValue,
|
||||
lowerKey,
|
||||
compareValue,
|
||||
});
|
||||
};
|
||||
|
||||
for (const tracker of WSS_TRACKERS) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
appendUniqueParam("tr", trimmedTracker);
|
||||
}
|
||||
ensureTrackers(params);
|
||||
|
||||
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
|
||||
if (normalizedXs) {
|
||||
appendUniqueParam("xs", normalizedXs);
|
||||
ensureTorrentHint(params, normalizedXs, { requireHttp: false });
|
||||
}
|
||||
|
||||
const originProtocol = getOriginProtocol();
|
||||
const rawWs = typeof ws === "string" ? ws.trim() : "";
|
||||
if (rawWs) {
|
||||
const formattedWs = tryFormatAbsoluteUrl(rawWs);
|
||||
const lowerWs = formattedWs.toLowerCase();
|
||||
const isHttpSeed = lowerWs.startsWith("http://");
|
||||
const allowHttpSeed = originProtocol === "http:";
|
||||
if (!isHttpSeed || allowHttpSeed) {
|
||||
appendUniqueParam("ws", formattedWs);
|
||||
}
|
||||
const formattedWs = formatAbsoluteUrl(rawWs);
|
||||
const allowHttpSeed = resolveAppProtocol() === "http:";
|
||||
ensureWebSeeds(params, formattedWs, {
|
||||
allowHttp: allowHttpSeed,
|
||||
allowUnparsed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
|
||||
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||
}
|
||||
|
||||
export function extractMagnetHints(rawValue) {
|
||||
const hints = { ws: "", xs: "" };
|
||||
if (typeof rawValue !== "string") {
|
||||
return hints;
|
||||
}
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
const queryIndex = trimmed.indexOf("?");
|
||||
if (queryIndex === -1 || queryIndex === trimmed.length - 1) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
const query = trimmed.slice(queryIndex + 1);
|
||||
if (!query) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
const parts = query.split("&");
|
||||
for (const part of parts) {
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
const [rawKey, rawValue = ""] = part.split("=", 2);
|
||||
if (!rawKey) {
|
||||
continue;
|
||||
}
|
||||
const lowerKey = rawKey.trim().toLowerCase();
|
||||
if (lowerKey !== "ws" && lowerKey !== "xs") {
|
||||
continue;
|
||||
}
|
||||
if (hints[lowerKey]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let decoded = rawValue;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawValue);
|
||||
} catch (err) {
|
||||
// Ignore decode errors and use the raw value.
|
||||
}
|
||||
const clean = typeof decoded === "string" ? decoded.trim() : "";
|
||||
if (!clean) {
|
||||
continue;
|
||||
}
|
||||
hints[lowerKey] = clean;
|
||||
}
|
||||
|
||||
return hints;
|
||||
return buildMagnetUri(normalizedScheme, params, fragment);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
safeDecodeURIComponent,
|
||||
safeDecodeURIComponentLoose,
|
||||
} from "./utils/safeDecode.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const BTIH_PREFIX = "urn:btih:";
|
||||
const MAGNET_SCHEME = "magnet:";
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
|
||||
export function safeDecodeMagnet(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let decoded = value.trim();
|
||||
if (!decoded) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (!decoded.includes("%")) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidate = safeDecodeURIComponent(decoded);
|
||||
if (!candidate || candidate === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = candidate.trim();
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname.replace(/\/?$/, "");
|
||||
const normalizedPath = pathname ? pathname : "";
|
||||
return (
|
||||
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
} catch (err) {
|
||||
return trimmed.replace(/\/?$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function createParam(key, value) {
|
||||
const trimmedValue = typeof value === "string" ? value.trim() : "";
|
||||
const decoded = safeDecodeURIComponentLoose(trimmedValue);
|
||||
const comparisonBasis = decoded || trimmedValue;
|
||||
return {
|
||||
key,
|
||||
value: trimmedValue,
|
||||
lowerKey: key.toLowerCase(),
|
||||
decoded,
|
||||
comparison: normalizeForComparison(comparisonBasis),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMagnetInput(rawValue) {
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
if (!initial) {
|
||||
return {
|
||||
initial,
|
||||
canonicalValue: "",
|
||||
didMutate: false,
|
||||
isMagnet: false,
|
||||
normalizedScheme: "",
|
||||
fragment: "",
|
||||
params: [],
|
||||
};
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
let didMutate = false;
|
||||
|
||||
if (HEX_INFO_HASH.test(working)) {
|
||||
working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
ENCODED_BTih_PATTERN.lastIndex = 0;
|
||||
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
|
||||
didMutate = true;
|
||||
return `xt=${BTIH_PREFIX}${hash}`;
|
||||
});
|
||||
working = decodedXt;
|
||||
|
||||
let fragment = "";
|
||||
const hashIndex = working.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const canonicalValue = working;
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
return {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate: didMutate || canonicalValue !== initial,
|
||||
isMagnet: false,
|
||||
normalizedScheme: "",
|
||||
fragment: "",
|
||||
params: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [schemePart, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = MAGNET_SCHEME;
|
||||
if (schemePart !== normalizedScheme) {
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const lowerKey = key.toLowerCase();
|
||||
let value = rawVal.trim();
|
||||
if (lowerKey === "xt" && value) {
|
||||
const decoded = safeDecodeURIComponentLoose(value);
|
||||
if (decoded && decoded !== value) {
|
||||
value = decoded;
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
params.push(createParam(key, value));
|
||||
}
|
||||
|
||||
return {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate,
|
||||
isMagnet: true,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMagnetUri(normalizedScheme, params, fragment) {
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`;
|
||||
}
|
||||
|
||||
export function appendUniqueParam(params, key, value) {
|
||||
if (!Array.isArray(params)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = createParam(key, value);
|
||||
if (!candidate.value && candidate.value !== "") {
|
||||
return false;
|
||||
}
|
||||
if (candidate.comparison) {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison
|
||||
);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value
|
||||
);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
params.push(candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureTrackers(params, trackers = WSS_TRACKERS) {
|
||||
let didMutate = false;
|
||||
for (const tracker of trackers) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!trimmedTracker) {
|
||||
continue;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
if (appendUniqueParam(params, "tr", trimmedTracker)) {
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
return didMutate;
|
||||
}
|
||||
|
||||
export function sanitizeHttpUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ensureTorrentHint(params, candidate, { requireHttp = true } = {}) {
|
||||
if (requireHttp) {
|
||||
const sanitized = sanitizeHttpUrl(candidate);
|
||||
if (!sanitized) {
|
||||
return false;
|
||||
}
|
||||
return appendUniqueParam(params, "xs", sanitized);
|
||||
}
|
||||
|
||||
if (typeof candidate !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return appendUniqueParam(params, "xs", trimmed);
|
||||
}
|
||||
|
||||
export function resolveAppProtocol(explicitProtocol) {
|
||||
if (typeof explicitProtocol === "string" && explicitProtocol.trim()) {
|
||||
return explicitProtocol.trim().toLowerCase();
|
||||
}
|
||||
if (typeof window !== "undefined" && window.location?.protocol) {
|
||||
return window.location.protocol.toLowerCase();
|
||||
}
|
||||
return "https:";
|
||||
}
|
||||
|
||||
export function ensureWebSeeds(
|
||||
params,
|
||||
seeds,
|
||||
{ allowHttp = false, allowUnparsed = false, logger } = {}
|
||||
) {
|
||||
const list = Array.isArray(seeds)
|
||||
? seeds
|
||||
: typeof seeds === "string"
|
||||
? [seeds]
|
||||
: [];
|
||||
let didMutate = false;
|
||||
for (const seed of list) {
|
||||
if (typeof seed !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedSeed = seed.trim();
|
||||
if (!trimmedSeed) {
|
||||
continue;
|
||||
}
|
||||
let finalValue = trimmedSeed;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmedSeed);
|
||||
const protocol = parsed.protocol;
|
||||
if (protocol === "https:") {
|
||||
finalValue = parsed.toString();
|
||||
} else if (protocol === "http:") {
|
||||
if (!allowHttp) {
|
||||
if (typeof logger === "function") {
|
||||
logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
finalValue = parsed.toString();
|
||||
} else if (!allowUnparsed) {
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!allowUnparsed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (appendUniqueParam(params, "ws", finalValue)) {
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
return didMutate;
|
||||
}
|
||||
|
||||
export function formatAbsoluteUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.toString();
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMagnetHints(rawValue) {
|
||||
const { params } = normalizeMagnetInput(rawValue);
|
||||
const hints = { ws: "", xs: "" };
|
||||
for (const param of params) {
|
||||
if (param.lowerKey === "ws" && !hints.ws) {
|
||||
hints.ws = param.decoded || param.value;
|
||||
} else if (param.lowerKey === "xs" && !hints.xs) {
|
||||
hints.xs = param.decoded || param.value;
|
||||
}
|
||||
if (hints.ws && hints.xs) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
+44
-261
@@ -1,101 +1,19 @@
|
||||
// js/magnetUtils.js
|
||||
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
buildMagnetUri,
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
normalizeMagnetInput,
|
||||
resolveAppProtocol,
|
||||
safeDecodeMagnet as sharedSafeDecodeMagnet,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
export { WSS_TRACKERS };
|
||||
export const safeDecodeMagnet = sharedSafeDecodeMagnet;
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const BTIH_PREFIX = "urn:btih:";
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
|
||||
function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname.replace(/\/?$/, "");
|
||||
const normalizedPath = pathname ? pathname : "";
|
||||
return (
|
||||
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
} catch (err) {
|
||||
return trimmed.replace(/\/?$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHttpUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function safeDecodeMagnet(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let decoded = value.trim();
|
||||
if (!decoded) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (!decoded.includes("%")) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = decodeURIComponent(decoded);
|
||||
if (!candidate) {
|
||||
break;
|
||||
}
|
||||
if (candidate === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = candidate.trim();
|
||||
} catch (err) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a magnet URI while preserving legacy payload quirks and augmenting it
|
||||
* for browser playback.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links.
|
||||
* - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization
|
||||
* beyond decoding legacy `%3A` segments.
|
||||
* - Only appends browser-safe WSS trackers in addition to whatever the caller provides.
|
||||
* - Skips insecure `http:` web seeds unless the application itself is running over HTTP.
|
||||
*
|
||||
* Note: `didChange` may be reported even if the resulting magnet string is textually
|
||||
* identical to the input. Callers should not rely on `didChange` to detect a modified
|
||||
* URI.
|
||||
*/
|
||||
export function normalizeAndAugmentMagnet(
|
||||
rawValue,
|
||||
{
|
||||
@@ -107,83 +25,32 @@ export function normalizeAndAugmentMagnet(
|
||||
appProtocol,
|
||||
} = {}
|
||||
) {
|
||||
const log = typeof logger === "function" ? logger : () => {};
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return { magnet: "", didChange: false };
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
let didMutate = false;
|
||||
const bareHashMatch = HEX_INFO_HASH.test(working);
|
||||
if (bareHashMatch) {
|
||||
working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
const hashIndex = working.indexOf("#");
|
||||
let fragment = "";
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
|
||||
didMutate = true;
|
||||
return `xt=${BTIH_PREFIX}${hash}`;
|
||||
});
|
||||
working = decodedXt;
|
||||
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
if (!isMagnet) {
|
||||
const magnet = canonicalValue;
|
||||
return {
|
||||
magnet: working,
|
||||
didChange: didMutate || working !== initial,
|
||||
magnet,
|
||||
didChange: didMutate || magnet !== initial,
|
||||
};
|
||||
}
|
||||
|
||||
const [schemePart, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = "magnet:";
|
||||
if (schemePart !== normalizedScheme) {
|
||||
didMutate = true;
|
||||
}
|
||||
let didChange = didMutate;
|
||||
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
|
||||
const decodeLoose = (value) => {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawValue = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let value = rawValue.trim();
|
||||
if (key.toLowerCase() === "xt" && value) {
|
||||
const decoded = decodeLoose(value);
|
||||
if (decoded !== value) {
|
||||
value = decoded;
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value,
|
||||
decoded: decodeLoose(value),
|
||||
});
|
||||
if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
|
||||
@@ -192,118 +59,34 @@ export function normalizeAndAugmentMagnet(
|
||||
? xs.trim()
|
||||
: "";
|
||||
|
||||
const safeTorrentHint = sanitizeHttpUrl(torrentHint);
|
||||
if (torrentHint) {
|
||||
if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) {
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
const seedInputs = Array.isArray(webSeed)
|
||||
? webSeed
|
||||
: typeof webSeed === "string"
|
||||
? [webSeed]
|
||||
: [];
|
||||
|
||||
const resolvedProtocol = typeof appProtocol === "string" && appProtocol
|
||||
? appProtocol.toLowerCase()
|
||||
: typeof window !== "undefined" && window.location && window.location.protocol
|
||||
? window.location.protocol.toLowerCase()
|
||||
: "https:";
|
||||
const resolvedProtocol = resolveAppProtocol(appProtocol);
|
||||
|
||||
const existingTrackers = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "tr")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const trackerCandidates = [
|
||||
...WSS_TRACKERS,
|
||||
...extraTrackers,
|
||||
];
|
||||
|
||||
for (const tracker of trackerCandidates) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!trimmedTracker) {
|
||||
continue;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
const normalizedTracker = normalizeForComparison(trimmedTracker);
|
||||
if (!normalizedTracker || existingTrackers.has(normalizedTracker)) {
|
||||
continue;
|
||||
}
|
||||
params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker });
|
||||
existingTrackers.add(normalizedTracker);
|
||||
didMutate = true;
|
||||
if (
|
||||
ensureWebSeeds(params, seedInputs, {
|
||||
allowHttp: resolvedProtocol === "http:",
|
||||
allowUnparsed: false,
|
||||
logger,
|
||||
})
|
||||
) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
if (safeTorrentHint) {
|
||||
const existingXs = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "xs")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const normalizedXs = normalizeForComparison(safeTorrentHint);
|
||||
if (normalizedXs && !existingXs.has(normalizedXs)) {
|
||||
params.push({
|
||||
key: "xs",
|
||||
value: safeTorrentHint,
|
||||
decoded: safeTorrentHint,
|
||||
});
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (seedInputs.length) {
|
||||
const existingWs = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "ws")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
for (const seedInput of seedInputs) {
|
||||
if (typeof seedInput !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedSeed = seedInput.trim();
|
||||
if (!trimmedSeed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsedSeed = new URL(trimmedSeed);
|
||||
const seedProtocol = parsedSeed.protocol;
|
||||
const allowHttpSeed = resolvedProtocol === "http:";
|
||||
if (
|
||||
seedProtocol === "https:" ||
|
||||
(seedProtocol === "http:" && allowHttpSeed)
|
||||
) {
|
||||
const seedValue = parsedSeed.toString();
|
||||
const normalizedSeed = normalizeForComparison(seedValue);
|
||||
if (normalizedSeed && !existingWs.has(normalizedSeed)) {
|
||||
params.push({ key: "ws", value: seedValue, decoded: seedValue });
|
||||
existingWs.add(normalizedSeed);
|
||||
didMutate = true;
|
||||
}
|
||||
} else if (seedProtocol === "http:") {
|
||||
log(
|
||||
`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore invalid web seed values silently.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
|
||||
const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||
const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment);
|
||||
|
||||
return {
|
||||
magnet: finalMagnet,
|
||||
didChange: didMutate || finalMagnet !== initial,
|
||||
didChange: didChange || finalMagnet !== initial,
|
||||
};
|
||||
}
|
||||
+116
-35
@@ -1,4 +1,54 @@
|
||||
import parseMagnet from "https://esm.sh/[email protected].7";
|
||||
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
function decodeBase32ToHex(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
const bytes = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
return "";
|
||||
}
|
||||
buffer = (buffer << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
const byte = (buffer >> bits) & 0xff;
|
||||
bytes.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function extractInfoHashFromXt(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!lower.startsWith("urn:btih:")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const payload = trimmed.slice(9);
|
||||
return normalizeInfoHash(payload);
|
||||
}
|
||||
|
||||
function normalizeInfoHash(candidate) {
|
||||
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
||||
@@ -9,13 +59,8 @@ function normalizeInfoHash(candidate) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
|
||||
try {
|
||||
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
} catch (err) {
|
||||
console.warn("Failed to normalize base32 info hash", err);
|
||||
}
|
||||
const hex = decodeBase32ToHex(trimmed);
|
||||
return hex ? hex.toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -28,39 +73,75 @@ export function infoHashFromMagnet(magnet) {
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
try {
|
||||
const parsed = parseMagnet(magnet);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse magnet for info hash", err);
|
||||
return null;
|
||||
}
|
||||
const parsed = parseMagnetLite(magnet);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function trackersFromMagnet(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = parseMagnet(magnet);
|
||||
if (!parsed || !Array.isArray(parsed.announce)) {
|
||||
return [];
|
||||
}
|
||||
const deduped = new Set();
|
||||
parsed.announce.forEach((url) => {
|
||||
if (typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
deduped.add(trimmed);
|
||||
});
|
||||
return Array.from(deduped);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse magnet trackers", err);
|
||||
const parsed = parseMagnetLite(magnet);
|
||||
if (!parsed || !Array.isArray(parsed.announce)) {
|
||||
return [];
|
||||
}
|
||||
const deduped = new Set();
|
||||
parsed.announce.forEach((url) => {
|
||||
if (typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
deduped.add(trimmed);
|
||||
});
|
||||
return Array.from(deduped);
|
||||
}
|
||||
|
||||
function parseMagnetLite(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return { infoHash: "", announce: [] };
|
||||
}
|
||||
|
||||
const trimmed = magnet.trim();
|
||||
if (!trimmed) {
|
||||
return { infoHash: "", announce: [] };
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const queryIndex = lower.indexOf("?");
|
||||
const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : "";
|
||||
const params = paramsPart.split("&");
|
||||
const announce = [];
|
||||
let infoHash = "";
|
||||
|
||||
for (const param of params) {
|
||||
if (!param) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, ...rawValueParts] = param.split("=");
|
||||
const rawValue = rawValueParts.join("=");
|
||||
const key = decodeURIComponent((rawKey || "").replace(/\+/g, " ")).toLowerCase();
|
||||
const value = decodeURIComponent((rawValue || "").replace(/\+/g, " "));
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "xt") {
|
||||
const derived = extractInfoHashFromXt(value);
|
||||
if (derived && !infoHash) {
|
||||
infoHash = derived;
|
||||
}
|
||||
} else if (key === "tr") {
|
||||
if (value) {
|
||||
announce.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { infoHash, announce };
|
||||
}
|
||||
+6616
-2090
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,875 @@
|
||||
import {
|
||||
ADMIN_LIST_NAMESPACE,
|
||||
isDevMode,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
} from "./config.js";
|
||||
|
||||
export const NOTE_TYPES = Object.freeze({
|
||||
VIDEO_POST: "videoPost",
|
||||
VIDEO_MIRROR: "videoMirror",
|
||||
NIP71_VIDEO: "nip71Video",
|
||||
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
||||
REPOST: "repost",
|
||||
RELAY_LIST: "relayList",
|
||||
VIEW_EVENT: "viewEvent",
|
||||
WATCH_HISTORY_INDEX: "watchHistoryIndex",
|
||||
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
USER_BLOCK_LIST: "userBlockList",
|
||||
ADMIN_MODERATION_LIST: "adminModerationList",
|
||||
ADMIN_BLACKLIST: "adminBlacklist",
|
||||
ADMIN_WHITELIST: "adminWhitelist",
|
||||
});
|
||||
|
||||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||||
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
|
||||
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
|
||||
moderation: "editors",
|
||||
editors: "editors",
|
||||
whitelist: "whitelist",
|
||||
blacklist: "blacklist",
|
||||
});
|
||||
|
||||
const DEFAULT_APPEND_TAGS = [];
|
||||
|
||||
let cachedUtf8Encoder = null;
|
||||
|
||||
function ensureValidUtf8Content(value) {
|
||||
let normalized = "";
|
||||
if (typeof value === "string") {
|
||||
normalized = value;
|
||||
} else if (value === undefined || value === null) {
|
||||
normalized = "";
|
||||
} else if (typeof value === "object") {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
normalized = typeof stringified === "string" ? stringified : "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Failed to serialize view event content:",
|
||||
error
|
||||
);
|
||||
}
|
||||
normalized = "";
|
||||
}
|
||||
} else {
|
||||
normalized = String(value);
|
||||
}
|
||||
|
||||
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
|
||||
cachedUtf8Encoder = new TextEncoder();
|
||||
}
|
||||
|
||||
if (cachedUtf8Encoder) {
|
||||
try {
|
||||
cachedUtf8Encoder.encode(normalized);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
|
||||
error
|
||||
);
|
||||
}
|
||||
const sanitized = Array.from(normalized)
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code < 0xd800 || code > 0xdfff;
|
||||
})
|
||||
.join("");
|
||||
try {
|
||||
cachedUtf8Encoder.encode(sanitized);
|
||||
normalized = sanitized;
|
||||
} catch (encodeError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
|
||||
encodeError
|
||||
);
|
||||
}
|
||||
normalized = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const BASE_SCHEMAS = {
|
||||
[NOTE_TYPES.VIDEO_POST]: {
|
||||
type: NOTE_TYPES.VIDEO_POST,
|
||||
label: "Video post",
|
||||
kind: 30078,
|
||||
topicTag: { name: "t", value: "video" },
|
||||
identifierTag: { name: "d" },
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
schemaVersion: 3,
|
||||
fields: [
|
||||
{ key: "version", type: "number", required: true },
|
||||
{ key: "title", type: "string", required: true },
|
||||
{ key: "url", type: "string", required: false },
|
||||
{ key: "magnet", type: "string", required: false },
|
||||
{ key: "thumbnail", type: "string", required: false },
|
||||
{ key: "description", type: "string", required: false },
|
||||
{ key: "mode", type: "string", required: false },
|
||||
{ key: "videoRootId", type: "string", required: true },
|
||||
{ key: "deleted", type: "boolean", required: false },
|
||||
{ key: "isPrivate", type: "boolean", required: false },
|
||||
{ key: "enableComments", type: "boolean", required: false },
|
||||
{ key: "ws", type: "string", required: false },
|
||||
{ key: "xs", type: "string", required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.VIDEO_MIRROR]: {
|
||||
type: NOTE_TYPES.VIDEO_MIRROR,
|
||||
label: "NIP-94 mirror",
|
||||
kind: 1063,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional alt text carried alongside hosted URL metadata.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.REPOST]: {
|
||||
type: NOTE_TYPES.REPOST,
|
||||
label: "NIP-18 repost",
|
||||
kind: 6,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Content field intentionally empty for pure repost events.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_VIDEO]: {
|
||||
type: NOTE_TYPES.NIP71_VIDEO,
|
||||
label: "NIP-71 video (normal)",
|
||||
kind: 21,
|
||||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Summary or description for the video body; tags carry structured metadata.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
|
||||
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
|
||||
label: "NIP-71 video (short)",
|
||||
kind: 22,
|
||||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Summary or description for the short-form video; structured fields live in tags.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.RELAY_LIST]: {
|
||||
type: NOTE_TYPES.RELAY_LIST,
|
||||
label: "Relay list metadata",
|
||||
kind: 10002,
|
||||
relayTagName: "r",
|
||||
readMarker: "read",
|
||||
writeMarker: "write",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.VIEW_EVENT]: {
|
||||
type: NOTE_TYPES.VIEW_EVENT,
|
||||
label: "View counter",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
topicTag: { name: "t", value: "view" },
|
||||
identifierTag: { name: "d" },
|
||||
sessionTag: { name: "session", value: "true" },
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional plaintext content used for diagnostics.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
|
||||
label: "Watch history index",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
},
|
||||
snapshotTagName: "snapshot",
|
||||
totalTagName: "chunks",
|
||||
chunkPointerTagName: "a",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
description:
|
||||
"JSON payload describing the active snapshot and chunk count.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_CHUNK,
|
||||
label: "Watch history snapshot",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
},
|
||||
encryptionTag: { name: "encrypted", value: "nip04" },
|
||||
snapshotTagName: "snapshot",
|
||||
chunkTagName: "chunk",
|
||||
headTag: { name: "head", value: "1" },
|
||||
headTagIndex: 2,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description:
|
||||
"Encrypted JSON payload containing chunked watch history entries.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
|
||||
type: NOTE_TYPES.SUBSCRIPTION_LIST,
|
||||
label: "Subscription list",
|
||||
kind: 30002,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
},
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description: "Encrypted JSON: { subPubkeys: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST,
|
||||
label: "User block list",
|
||||
kind: 30002,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: BLOCK_LIST_IDENTIFIER,
|
||||
},
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description: "Encrypted JSON: { blockedPubkeys: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
|
||||
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
|
||||
label: "Admin moderation list",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_BLACKLIST]: {
|
||||
type: NOTE_TYPES.ADMIN_BLACKLIST,
|
||||
label: "Admin blacklist",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_WHITELIST]: {
|
||||
type: NOTE_TYPES.ADMIN_WHITELIST,
|
||||
label: "Admin whitelist",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
};
|
||||
|
||||
let schemaOverrides = {};
|
||||
|
||||
function clone(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(clone);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const result = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
result[key] = clone(nested);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mergeDeep(base, override) {
|
||||
const result = clone(base);
|
||||
if (!override || typeof override !== "object") {
|
||||
return result;
|
||||
}
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
result[key] = mergeDeep(base?.[key] ?? {}, value);
|
||||
} else {
|
||||
result[key] = clone(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveOverride(type) {
|
||||
const local = schemaOverrides?.[type];
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const runtimeOverrides = window.BitVidNostrEventOverrides;
|
||||
if (runtimeOverrides && runtimeOverrides[type]) {
|
||||
return runtimeOverrides[type];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setNostrEventSchemaOverrides(overrides = {}) {
|
||||
if (overrides && typeof overrides === "object") {
|
||||
schemaOverrides = overrides;
|
||||
} else {
|
||||
schemaOverrides = {};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.BitVidNostrEventOverrides = schemaOverrides;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNostrEventSchema(type) {
|
||||
const base = BASE_SCHEMAS[type];
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
const override = resolveOverride(type);
|
||||
return mergeDeep(base, override);
|
||||
}
|
||||
|
||||
export function getAllNostrEventSchemas() {
|
||||
const entries = {};
|
||||
for (const type of Object.keys(BASE_SCHEMAS)) {
|
||||
entries[type] = getNostrEventSchema(type);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function appendSchemaTags(tags, schema) {
|
||||
if (!Array.isArray(schema?.appendTags)) {
|
||||
return tags;
|
||||
}
|
||||
schema.appendTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
dTagValue,
|
||||
content,
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
|
||||
const tags = [];
|
||||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
if (schema?.identifierTag?.name && dTagValue) {
|
||||
tags.push([schema.identifierTag.name, dTagValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 30078,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : JSON.stringify(content ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVideoMirrorEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
tags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
|
||||
const combinedTags = [];
|
||||
appendSchemaTags(combinedTags, schema);
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: schema?.kind ?? 1063,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: combinedTags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRepostEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
eventId = "",
|
||||
eventRelay = "",
|
||||
address = "",
|
||||
addressRelay = "",
|
||||
authorPubkey = "",
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
|
||||
const tags = [];
|
||||
|
||||
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
||||
const normalizedEventRelay = typeof eventRelay === "string" ? eventRelay.trim() : "";
|
||||
if (normalizedEventId) {
|
||||
if (normalizedEventRelay) {
|
||||
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
||||
} else {
|
||||
tags.push(["e", normalizedEventId]);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAddress = typeof address === "string" ? address.trim() : "";
|
||||
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
|
||||
if (normalizedAddress) {
|
||||
if (normalizedAddressRelay) {
|
||||
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
|
||||
} else {
|
||||
tags.push(["a", normalizedAddress]);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAuthorPubkey =
|
||||
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
|
||||
if (normalizedAuthorPubkey) {
|
||||
tags.push(["p", normalizedAuthorPubkey]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 6,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRelayListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
relays = [],
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
|
||||
const tags = [];
|
||||
const relayTagName = schema?.relayTagName || "r";
|
||||
const readMarker = schema?.readMarker || "read";
|
||||
const writeMarker = schema?.writeMarker || "write";
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
relays.forEach((entry) => {
|
||||
let url = "";
|
||||
let mode = "";
|
||||
if (typeof entry === "string") {
|
||||
url = entry;
|
||||
} else if (entry && typeof entry === "object") {
|
||||
if (typeof entry.url === "string") {
|
||||
url = entry.url;
|
||||
}
|
||||
if (typeof entry.mode === "string") {
|
||||
mode = entry.mode;
|
||||
} else if (typeof entry.marker === "string") {
|
||||
mode = entry.marker;
|
||||
} else if (entry.read === true && entry.write === false) {
|
||||
mode = "read";
|
||||
} else if (entry.write === true && entry.read === false) {
|
||||
mode = "write";
|
||||
}
|
||||
} else if (Array.isArray(entry) && entry.length >= 1) {
|
||||
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
|
||||
if (entry.length > 1 && typeof entry[1] === "string") {
|
||||
mode = entry[1];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
|
||||
if (normalizedMode === "read") {
|
||||
tags.push([relayTagName, normalizedUrl, readMarker]);
|
||||
} else if (normalizedMode === "write") {
|
||||
tags.push([relayTagName, normalizedUrl, writeMarker]);
|
||||
} else {
|
||||
tags.push([relayTagName, normalizedUrl]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 10002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildViewEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags = [],
|
||||
dedupeTag,
|
||||
includeSessionTag = false,
|
||||
additionalTags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
const tags = [];
|
||||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
|
||||
const pointerTagName = schema?.pointerTagName;
|
||||
if (pointerValue && pointerTagName) {
|
||||
tags.push([pointerTagName, pointerValue]);
|
||||
}
|
||||
const normalizedPointerTags = [];
|
||||
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(pointerTags)) {
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
normalizedPointerTags.forEach((tag) => {
|
||||
tags.push(tag);
|
||||
});
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dedupeTag && schema?.identifierTag?.name) {
|
||||
const identifierName = schema.identifierTag.name;
|
||||
const hasDedupe = tags.some(
|
||||
(tag) => tag[0] === identifierName && tag[1] === dedupeTag
|
||||
);
|
||||
if (!hasDedupe) {
|
||||
tags.push([identifierName, dedupeTag]);
|
||||
}
|
||||
}
|
||||
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
|
||||
const hasSession = tags.some(
|
||||
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
|
||||
);
|
||||
if (!hasSession) {
|
||||
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
|
||||
}
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryIndexEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
snapshotId,
|
||||
totalChunks,
|
||||
chunkAddresses = [],
|
||||
additionalTags = [],
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || WATCH_HISTORY_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
const snapshotTagName = schema?.snapshotTagName || "snapshot";
|
||||
if (snapshotTagName && snapshotId) {
|
||||
tags.push([snapshotTagName, snapshotId]);
|
||||
}
|
||||
const totalTagName = schema?.totalTagName || "chunks";
|
||||
if (totalTagName && Number.isFinite(totalChunks)) {
|
||||
tags.push([totalTagName, String(Math.max(0, Math.floor(totalChunks)))]);
|
||||
}
|
||||
const pointerTagName = schema?.chunkPointerTagName || "a";
|
||||
chunkAddresses.forEach((address) => {
|
||||
if (typeof address === "string" && address) {
|
||||
tags.push([pointerTagName, address]);
|
||||
}
|
||||
});
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let resolvedContent = content;
|
||||
if (resolvedContent === undefined) {
|
||||
const payload = {};
|
||||
if (snapshotId) {
|
||||
payload.snapshot = snapshotId;
|
||||
}
|
||||
if (Number.isFinite(totalChunks)) {
|
||||
payload.totalChunks = Math.max(0, Math.floor(totalChunks));
|
||||
}
|
||||
resolvedContent = JSON.stringify(payload);
|
||||
} else if (typeof resolvedContent !== "string") {
|
||||
resolvedContent = JSON.stringify(resolvedContent ?? {});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content:
|
||||
typeof resolvedContent === "string"
|
||||
? resolvedContent
|
||||
: String(resolvedContent ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryChunkEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
chunkIdentifier,
|
||||
snapshotId,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
pointerTags = [],
|
||||
chunkAddresses = [],
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = chunkIdentifier || schema?.identifierTag?.value;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) {
|
||||
tags.push([schema.encryptionTag.name, schema.encryptionTag.value]);
|
||||
}
|
||||
const snapshotTagName = schema?.snapshotTagName;
|
||||
if (snapshotTagName && snapshotId) {
|
||||
tags.push([snapshotTagName, snapshotId]);
|
||||
}
|
||||
const chunkTagName = schema?.chunkTagName;
|
||||
if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") {
|
||||
tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]);
|
||||
}
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
|
||||
if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) {
|
||||
const insertionIndex = Number.isInteger(schema?.headTagIndex)
|
||||
? Math.max(0, Math.min(tags.length, schema.headTagIndex))
|
||||
: Math.min(tags.length, 2);
|
||||
tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]);
|
||||
chunkAddresses.forEach((address) => {
|
||||
if (typeof address === "string" && address) {
|
||||
tags.push(["a", address]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSubscriptionListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBlockListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAdminNoteType(listKey) {
|
||||
switch (listKey) {
|
||||
case "moderation":
|
||||
case "editors":
|
||||
return NOTE_TYPES.ADMIN_MODERATION_LIST;
|
||||
case "whitelist":
|
||||
return NOTE_TYPES.ADMIN_WHITELIST;
|
||||
case "blacklist":
|
||||
return NOTE_TYPES.ADMIN_BLACKLIST;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
|
||||
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
|
||||
if (!schema) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 30000,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: [],
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
|
||||
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
|
||||
}
|
||||
const participantTagName = schema?.participantTagName || "p";
|
||||
hexPubkeys.forEach((hex) => {
|
||||
if (typeof hex === "string" && hex.trim()) {
|
||||
tags.push([participantTagName, hex.trim()]);
|
||||
}
|
||||
});
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30000,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.BitVidNostrEvents = {
|
||||
NOTE_TYPES,
|
||||
getSchema: getNostrEventSchema,
|
||||
getAllSchemas: getAllNostrEventSchemas,
|
||||
setOverrides: setNostrEventSchemaOverrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
|
||||
export const RELAY_PUBLISH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeReason(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "publish failed";
|
||||
}
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error.trim();
|
||||
}
|
||||
if (error === undefined || error === null) {
|
||||
return "publish failed";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch (_) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export class RelayPublishError extends Error {
|
||||
constructor(message, results = [], options = {}) {
|
||||
const finalMessage =
|
||||
typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: "Failed to publish event to any relay.";
|
||||
super(finalMessage);
|
||||
this.name = "RelayPublishError";
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: null;
|
||||
if (contextValue) {
|
||||
this.context = contextValue;
|
||||
}
|
||||
|
||||
this.relayResults = Array.isArray(results)
|
||||
? results.map((result) => ({
|
||||
url: result?.url || "",
|
||||
success: !!result?.success,
|
||||
error: result?.error || null,
|
||||
reason: normalizeReason(result?.error),
|
||||
}))
|
||||
: [];
|
||||
|
||||
this.relayFailures = this.relayResults
|
||||
.filter((entry) => !entry.success)
|
||||
.map((entry) => ({
|
||||
url: entry.url,
|
||||
error: entry.error,
|
||||
reason: entry.reason,
|
||||
}));
|
||||
|
||||
this.acceptedRelays = this.relayResults
|
||||
.filter((entry) => entry.success)
|
||||
.map((entry) => entry.url);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishEventToRelay(pool, url, event, options = {}) {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
||||
? options.timeoutMs
|
||||
: RELAY_PUBLISH_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finalize = (success, error = null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({ url, success, error });
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
finalize(false, new Error("publish timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const pub = pool?.publish?.([url], event);
|
||||
|
||||
if (pub && typeof pub.on === "function") {
|
||||
const registerHandler = (eventName, handler) => {
|
||||
try {
|
||||
pub.on(eventName, handler);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[nostrPublish] Relay publish handle rejected ${eventName} listener:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleFailure = (reason) => {
|
||||
clearTimeout(timeoutId);
|
||||
const err =
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error(String(reason || "publish failed"));
|
||||
finalize(false, err);
|
||||
};
|
||||
|
||||
let handlerRegistered = false;
|
||||
handlerRegistered =
|
||||
registerHandler("ok", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("failed", handleFailure) || handlerRegistered;
|
||||
|
||||
// [email protected] removed the optional "seen" callback from relay
|
||||
// publish handles. Attempting to register it now produces an
|
||||
// asynchronous TypeError that bubbles up as an unhandled rejection and
|
||||
// prevents watch-history snapshots from ever resolving. We therefore
|
||||
// skip registering a "seen" listener and rely on "ok" (per NIP-20) for
|
||||
// success notifications. Legacy relays that only emit "seen" fall back
|
||||
// to the optimistic success path below because no handlers end up
|
||||
// registered.
|
||||
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pub && typeof pub.then === "function") {
|
||||
pub
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function publishEventToRelays(pool, urls, event, options = {}) {
|
||||
const list = Array.isArray(urls) ? urls : [];
|
||||
return Promise.all(
|
||||
list.map((url) => publishEventToRelay(pool, url, event, options)),
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizePublishResults(results = []) {
|
||||
const accepted = [];
|
||||
const failed = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result?.success) {
|
||||
accepted.push(result);
|
||||
} else {
|
||||
failed.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted, failed };
|
||||
}
|
||||
|
||||
export function assertAnyRelayAccepted(results = [], options = {}) {
|
||||
const summary = summarizePublishResults(results);
|
||||
if (summary.accepted.length > 0) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: "";
|
||||
const messageOverride =
|
||||
typeof options.message === "string" && options.message.trim()
|
||||
? options.message.trim()
|
||||
: "";
|
||||
|
||||
const message =
|
||||
messageOverride ||
|
||||
(contextValue
|
||||
? `Failed to publish ${contextValue} to any relay.`
|
||||
: "Failed to publish event to any relay.");
|
||||
|
||||
throw new RelayPublishError(message, results, { context: contextValue });
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
const REMOTE_IMPORT_TIMEOUT = 4500;
|
||||
const LOCAL_IMPORT_TIMEOUT = 2500;
|
||||
const SCRIPT_FALLBACK_TIMEOUT = 6000;
|
||||
const CDN_BUNDLE_URL =
|
||||
"https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js";
|
||||
const LOCAL_BUNDLE_RELATIVE_URL = "../vendor/nostr-tools.bundle.min.js";
|
||||
|
||||
const moduleUrl =
|
||||
typeof import.meta !== "undefined" && import.meta?.url ? import.meta.url : null;
|
||||
|
||||
const resolveRelativeUrl = (relativePath) => {
|
||||
if (!relativePath) {
|
||||
return relativePath;
|
||||
}
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(relativePath)) {
|
||||
return relativePath;
|
||||
}
|
||||
if (moduleUrl) {
|
||||
try {
|
||||
return new URL(relativePath, moduleUrl).href;
|
||||
} catch (error) {
|
||||
// Fall through to other resolution strategies
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined" && window?.location?.href) {
|
||||
try {
|
||||
return new URL(relativePath, window.location.href).href;
|
||||
} catch (error) {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
return relativePath;
|
||||
};
|
||||
|
||||
const LOCAL_BUNDLE_URL = resolveRelativeUrl(LOCAL_BUNDLE_RELATIVE_URL);
|
||||
|
||||
let bootstrapPromise = null;
|
||||
|
||||
const getGlobalScope = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasWorkingNip04 = (candidate) =>
|
||||
!!(
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
typeof candidate.encrypt === "function" &&
|
||||
typeof candidate.decrypt === "function"
|
||||
);
|
||||
|
||||
const toSerializableError = (error) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return { message: error };
|
||||
}
|
||||
try {
|
||||
return { ...error };
|
||||
} catch (serializationError) {
|
||||
return { message: String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const withTimeout = (factory, ms, label) =>
|
||||
new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
let settled = false;
|
||||
let timeoutId;
|
||||
const finalize = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve({
|
||||
label,
|
||||
duration: Date.now() - start,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
const runner = Promise.resolve().then(factory);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
finalize({
|
||||
ok: false,
|
||||
timedOut: true,
|
||||
error: new Error(
|
||||
`[bitvid][nostr-tools] ${label} timed out after ${ms}ms`
|
||||
),
|
||||
});
|
||||
runner.catch(() => {});
|
||||
}, ms);
|
||||
|
||||
runner.then(
|
||||
(value) => finalize({ ok: true, value, timedOut: false }),
|
||||
(error) => finalize({ ok: false, error, timedOut: false })
|
||||
);
|
||||
});
|
||||
|
||||
const discoverLocalEntrypoints = (scope) => {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entrypoints = [];
|
||||
const configuredEntrypoints = scope.__BITVID_LOCAL_NOSTR_TOOLS_ENTRY__;
|
||||
if (typeof configuredEntrypoints === "string") {
|
||||
entrypoints.push(configuredEntrypoints);
|
||||
} else if (Array.isArray(configuredEntrypoints)) {
|
||||
configuredEntrypoints
|
||||
.filter((candidate) => typeof candidate === "string" && candidate)
|
||||
.forEach((candidate) => entrypoints.push(candidate));
|
||||
}
|
||||
|
||||
if (scope.document) {
|
||||
const localScripts = scope.document.querySelectorAll(
|
||||
"script[data-nostr-tools-entry]"
|
||||
);
|
||||
localScripts.forEach((script) => {
|
||||
const candidate = script.getAttribute("data-nostr-tools-entry");
|
||||
if (candidate) {
|
||||
entrypoints.push(candidate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(new Set(entrypoints));
|
||||
};
|
||||
|
||||
const extractToolsFromModule = (moduleLike) => {
|
||||
if (!moduleLike || typeof moduleLike !== "object") {
|
||||
return null;
|
||||
}
|
||||
const { default: defaultExport, ...named } = moduleLike;
|
||||
if (defaultExport && typeof defaultExport === "object") {
|
||||
return { ...defaultExport, ...named };
|
||||
}
|
||||
return { ...named };
|
||||
};
|
||||
|
||||
const ensureGlobalReadyPromise = (scope, promise) => {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(scope, "nostrToolsReady", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: promise,
|
||||
});
|
||||
} catch (error) {
|
||||
scope.nostrToolsReady = promise;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScriptBundle = (scope, url, label) => {
|
||||
if (!scope?.document) {
|
||||
return Promise.resolve({
|
||||
label,
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
duration: 0,
|
||||
error: new Error("Document is unavailable for script loading."),
|
||||
});
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { document } = scope;
|
||||
const target = document.head || document.body || document.documentElement;
|
||||
if (!target) {
|
||||
reject(new Error("Unable to locate a DOM target for script injection."));
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = url;
|
||||
script.crossOrigin = "anonymous";
|
||||
|
||||
const cleanup = () => {
|
||||
script.onload = null;
|
||||
script.onerror = null;
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
|
||||
script.onload = () => {
|
||||
cleanup();
|
||||
resolve(scope?.NostrTools || null);
|
||||
};
|
||||
script.onerror = (event) => {
|
||||
cleanup();
|
||||
if (event?.error instanceof Error) {
|
||||
reject(event.error);
|
||||
} else {
|
||||
reject(new Error(`nostr-tools bundle failed to load: ${url}`));
|
||||
}
|
||||
};
|
||||
|
||||
target.appendChild(script);
|
||||
}),
|
||||
SCRIPT_FALLBACK_TIMEOUT,
|
||||
label
|
||||
);
|
||||
};
|
||||
|
||||
export function bootstrapNostrTools() {
|
||||
if (bootstrapPromise) {
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
const scope = getGlobalScope();
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
if (!scope) {
|
||||
const error = {
|
||||
ok: false,
|
||||
reason: "Global scope is unavailable.",
|
||||
attempts: [],
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
const attempts = [];
|
||||
|
||||
const remoteImports = [
|
||||
withTimeout(
|
||||
() => import("https://esm.sh/[email protected]"),
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-main"
|
||||
),
|
||||
withTimeout(
|
||||
() =>
|
||||
import(
|
||||
"https://esm.sh/[email protected]?target=es2022&exports=nip04"
|
||||
),
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-nip04"
|
||||
),
|
||||
];
|
||||
|
||||
const localEntrypoints = discoverLocalEntrypoints(scope);
|
||||
const localImports = localEntrypoints.map((entry, index) =>
|
||||
withTimeout(
|
||||
() => import(entry),
|
||||
LOCAL_IMPORT_TIMEOUT,
|
||||
`local-${index}`
|
||||
)
|
||||
);
|
||||
|
||||
const dynamicResults = await Promise.allSettled([
|
||||
...remoteImports,
|
||||
...localImports,
|
||||
]);
|
||||
|
||||
const resolvedModules = [];
|
||||
let resolvedNip04 = null;
|
||||
|
||||
dynamicResults.forEach((settledResult) => {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
return;
|
||||
}
|
||||
const result = settledResult.value;
|
||||
attempts.push({
|
||||
target: result.label,
|
||||
ok: result.ok,
|
||||
timedOut: !!result.timedOut,
|
||||
duration: result.duration,
|
||||
error: result.ok ? null : toSerializableError(result.error),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
if (result.label === "esm-nip04" && hasWorkingNip04(result.value?.nip04)) {
|
||||
resolvedNip04 = result.value.nip04;
|
||||
return;
|
||||
}
|
||||
const extracted = extractToolsFromModule(result.value);
|
||||
if (extracted && Object.keys(extracted).length > 0) {
|
||||
resolvedModules.push({ source: result.label, tools: extracted });
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dynamicResults.forEach((settledResult) => {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
const { reason } = settledResult;
|
||||
attempts.push({
|
||||
target: "unknown",
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
duration: 0,
|
||||
error: toSerializableError(reason),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = settledResult.value;
|
||||
if (result && !attempts.find((entry) => entry.target === result.label)) {
|
||||
attempts.push({
|
||||
target: result.label,
|
||||
ok: result.ok,
|
||||
timedOut: !!result.timedOut,
|
||||
duration: result.duration,
|
||||
error: result.ok ? null : toSerializableError(result.error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allDynamicTimedOut =
|
||||
attempts.length > 0 &&
|
||||
attempts.every(
|
||||
(attempt) =>
|
||||
attempt &&
|
||||
attempt.target &&
|
||||
attempt.target.startsWith("esm")
|
||||
? attempt.timedOut
|
||||
: true
|
||||
);
|
||||
|
||||
const shouldAttemptScriptFallback =
|
||||
!!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut);
|
||||
|
||||
if (shouldAttemptScriptFallback) {
|
||||
const scriptSources = [
|
||||
LOCAL_BUNDLE_URL
|
||||
? { url: LOCAL_BUNDLE_URL, label: "local-bundle" }
|
||||
: null,
|
||||
{ url: CDN_BUNDLE_URL, label: "cdn-bundle" },
|
||||
].filter(Boolean);
|
||||
|
||||
for (const source of scriptSources) {
|
||||
const scriptLoadResult = await loadScriptBundle(
|
||||
scope,
|
||||
source.url,
|
||||
source.label
|
||||
);
|
||||
|
||||
attempts.push({
|
||||
target: scriptLoadResult.label,
|
||||
ok: scriptLoadResult.ok,
|
||||
timedOut: !!scriptLoadResult.timedOut,
|
||||
duration: scriptLoadResult.duration,
|
||||
error: scriptLoadResult.ok
|
||||
? null
|
||||
: toSerializableError(scriptLoadResult.error),
|
||||
});
|
||||
|
||||
if (!scriptLoadResult.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extracted = extractToolsFromModule(scriptLoadResult.value);
|
||||
if (extracted && Object.keys(extracted).length > 0) {
|
||||
resolvedModules.push({ source: source.label, tools: extracted });
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingGlobalTools = scope?.NostrTools;
|
||||
if (existingGlobalTools && typeof existingGlobalTools === "object") {
|
||||
resolvedModules.push({ source: "global", tools: existingGlobalTools });
|
||||
if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) {
|
||||
resolvedNip04 = existingGlobalTools.nip04;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedModules.length === 0 && !resolvedNip04) {
|
||||
const failure = {
|
||||
ok: false,
|
||||
reason: "Failed to resolve any nostr-tools helpers.",
|
||||
attempts,
|
||||
};
|
||||
console.warn("[bitvid] nostr-tools bootstrap failed", failure);
|
||||
return failure;
|
||||
}
|
||||
|
||||
const toolSources = resolvedModules.map((entry) => entry.tools);
|
||||
const canonicalTools = Object.assign({}, ...toolSources);
|
||||
|
||||
const normalizedGeneratePrivateKey =
|
||||
typeof canonicalTools.generatePrivateKey === "function"
|
||||
? canonicalTools.generatePrivateKey
|
||||
: typeof canonicalTools.generateSecretKey === "function"
|
||||
? canonicalTools.generateSecretKey
|
||||
: undefined;
|
||||
|
||||
const normalizedGenerateSecretKey =
|
||||
typeof canonicalTools.generateSecretKey === "function"
|
||||
? canonicalTools.generateSecretKey
|
||||
: typeof canonicalTools.generatePrivateKey === "function"
|
||||
? canonicalTools.generatePrivateKey
|
||||
: undefined;
|
||||
|
||||
if (normalizedGeneratePrivateKey) {
|
||||
canonicalTools.generatePrivateKey = normalizedGeneratePrivateKey;
|
||||
}
|
||||
if (normalizedGenerateSecretKey) {
|
||||
canonicalTools.generateSecretKey = normalizedGenerateSecretKey;
|
||||
}
|
||||
|
||||
if (resolvedNip04) {
|
||||
canonicalTools.nip04 = resolvedNip04;
|
||||
console.info("[bitvid] Initialized nostr nip04 helpers.");
|
||||
} else {
|
||||
console.warn(
|
||||
"[bitvid] NIP-04 helpers unavailable after bootstrap attempts."
|
||||
);
|
||||
}
|
||||
|
||||
const frozenCanonical = Object.freeze({ ...canonicalTools });
|
||||
|
||||
try {
|
||||
Object.defineProperty(scope, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: frozenCanonical,
|
||||
});
|
||||
} catch (error) {
|
||||
scope.__BITVID_CANONICAL_NOSTR_TOOLS__ = frozenCanonical;
|
||||
}
|
||||
|
||||
let activeTools = canonicalTools;
|
||||
const mergeWithCanonical = (candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return activeTools;
|
||||
}
|
||||
const merged = Object.assign({}, frozenCanonical, candidate);
|
||||
if (!merged.nip04 && frozenCanonical.nip04) {
|
||||
merged.nip04 = frozenCanonical.nip04;
|
||||
}
|
||||
if (
|
||||
typeof merged.generatePrivateKey !== "function" &&
|
||||
typeof merged.generateSecretKey === "function"
|
||||
) {
|
||||
merged.generatePrivateKey = merged.generateSecretKey;
|
||||
}
|
||||
if (
|
||||
typeof merged.generateSecretKey !== "function" &&
|
||||
typeof merged.generatePrivateKey === "function"
|
||||
) {
|
||||
merged.generateSecretKey = merged.generatePrivateKey;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const applyActive = (candidate) => {
|
||||
activeTools = mergeWithCanonical(candidate);
|
||||
return activeTools;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(scope, "NostrTools", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return activeTools;
|
||||
},
|
||||
set(value) {
|
||||
applyActive(value);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[bitvid] Failed to install NostrTools guard.", error);
|
||||
}
|
||||
|
||||
applyActive(existingGlobalTools);
|
||||
scope.NostrTools = canonicalTools;
|
||||
|
||||
return frozenCanonical;
|
||||
})()
|
||||
.catch((error) => ({
|
||||
ok: false,
|
||||
reason: "nostr-tools bootstrap threw unexpectedly.",
|
||||
error: toSerializableError(error),
|
||||
}));
|
||||
|
||||
ensureGlobalReadyPromise(scope, bootstrapPromise);
|
||||
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
const nostrToolsReady = bootstrapNostrTools();
|
||||
|
||||
export { nostrToolsReady };
|
||||
export default nostrToolsReady;
|
||||
|
||||
await nostrToolsReady;
|
||||
@@ -0,0 +1,368 @@
|
||||
const DB_NAME = "bitvidSettings";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "kv";
|
||||
const KEY_PREFIX = "nwcSettings:";
|
||||
const LOCALSTORAGE_PREFIX = "bitvid:nwcSettings:";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const MIN_DEFAULT_ZAP = 0;
|
||||
const MAX_DEFAULT_ZAP = 100000000;
|
||||
|
||||
const DEFAULT_SETTINGS = Object.freeze({
|
||||
nwcUri: "",
|
||||
defaultZap: null,
|
||||
lastChecked: null,
|
||||
version: "",
|
||||
});
|
||||
|
||||
function createClone(value) {
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to open settings DB"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1") && typeof window !== "undefined") {
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode?.(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode errors and fall through to return null.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStorageKeys(pubkey) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid pubkey for NWC settings.");
|
||||
}
|
||||
return {
|
||||
normalized,
|
||||
dbKey: `${KEY_PREFIX}${normalized}`,
|
||||
fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeUri(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sanitizeDefaultZap(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(numeric);
|
||||
if (!Number.isFinite(rounded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(MAX_DEFAULT_ZAP, Math.max(MIN_DEFAULT_ZAP, rounded));
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (!Number.isFinite(rounded)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, rounded);
|
||||
}
|
||||
|
||||
function sanitizeVersion(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeSettings(raw) {
|
||||
const base = createClone(DEFAULT_SETTINGS);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "nwcUri")) {
|
||||
base.nwcUri = sanitizeUri(raw.nwcUri);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "defaultZap")) {
|
||||
base.defaultZap = sanitizeDefaultZap(raw.defaultZap);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "lastChecked")) {
|
||||
base.lastChecked = sanitizeTimestamp(raw.lastChecked);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "version")) {
|
||||
base.version = sanitizeVersion(raw.version);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function mergeSettings(existing, partial) {
|
||||
const source = partial && typeof partial === "object" ? partial : {};
|
||||
return normalizeSettings({ ...existing, ...source });
|
||||
}
|
||||
|
||||
function emitToast(message, { variant = "error", error } = {}) {
|
||||
const text = typeof message === "string" ? message.trim() : "";
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bitvid:toast", {
|
||||
detail: {
|
||||
message: text,
|
||||
variant,
|
||||
source: "nwc-settings",
|
||||
error,
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch (eventError) {
|
||||
console.warn("[nwcSettings] Failed to dispatch toast event", eventError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyFailure(message, error) {
|
||||
const text = message || "NWC settings storage error.";
|
||||
console.warn(`[nwcSettings] ${text}`, error);
|
||||
emitToast(text, { variant: "error", error });
|
||||
}
|
||||
|
||||
function notifyWarning(message, error) {
|
||||
const text = message || "NWC settings storage warning.";
|
||||
console.warn(`[nwcSettings] ${text}`, error);
|
||||
emitToast(text, { variant: "warning", error });
|
||||
}
|
||||
|
||||
async function readFromIndexedDb(dbKey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(dbKey);
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to load NWC settings."));
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to read wallet settings from IndexedDB.", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readFromLocalStorage(fallbackKey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const raw = localStorage.getItem(fallbackKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
notifyFailure(
|
||||
"Unable to read wallet settings from local storage fallback.",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToIndexedDb(dbKey, value) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(value, dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to persist NWC settings."));
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to save wallet settings to IndexedDB.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToLocalStorage(fallbackKey, value) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem(fallbackKey, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to save wallet settings to local storage fallback.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFromIndexedDb(dbKey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to clear NWC settings."));
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to remove wallet settings from IndexedDB.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFromLocalStorage(fallbackKey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
localStorage.removeItem(fallbackKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to remove wallet settings from local storage fallback.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultNwcSettings() {
|
||||
return createClone(DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
export async function loadNwcSettings(pubkey) {
|
||||
const { dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const stored = await readFromIndexedDb(dbKey);
|
||||
if (stored) {
|
||||
return normalizeSettings(stored);
|
||||
}
|
||||
|
||||
const fallback = readFromLocalStorage(fallbackKey);
|
||||
if (fallback) {
|
||||
return normalizeSettings(fallback);
|
||||
}
|
||||
|
||||
return normalizeSettings(null);
|
||||
}
|
||||
|
||||
export async function saveNwcSettings(pubkey, partial = {}) {
|
||||
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const existing = await readFromIndexedDb(dbKey);
|
||||
const fallbackExisting = existing || readFromLocalStorage(fallbackKey);
|
||||
const merged = mergeSettings(fallbackExisting || DEFAULT_SETTINGS, partial);
|
||||
|
||||
const idbPersisted = await writeToIndexedDb(dbKey, merged);
|
||||
const localPersisted = writeToLocalStorage(fallbackKey, merged);
|
||||
|
||||
if (!idbPersisted && !localPersisted) {
|
||||
notifyFailure(
|
||||
"Failed to persist wallet settings. Recent changes were not saved.",
|
||||
new Error(`Unable to save settings for ${normalized}`)
|
||||
);
|
||||
throw new Error("Unable to persist NWC settings.");
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function clearNwcSettings(pubkey) {
|
||||
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const idbCleared = await deleteFromIndexedDb(dbKey);
|
||||
const localCleared = deleteFromLocalStorage(fallbackKey);
|
||||
|
||||
if (!idbCleared && !localCleared) {
|
||||
notifyFailure(
|
||||
"Failed to clear wallet settings from browser storage.",
|
||||
new Error(`Unable to clear settings for ${normalized}`)
|
||||
);
|
||||
throw new Error("Unable to clear NWC settings.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// js/payments/lnurl.js
|
||||
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const CHARKEY = new Map(
|
||||
Array.from(CHARSET).map((char, index) => [char, index])
|
||||
);
|
||||
const GENERATORS = [
|
||||
0x3b6a57b2,
|
||||
0x26508e6d,
|
||||
0x1ea119fa,
|
||||
0x3d4233dd,
|
||||
0x2a1462b3,
|
||||
];
|
||||
|
||||
const DEFAULT_COMMENT_MAX = 0;
|
||||
|
||||
function hrpExpand(hrp) {
|
||||
const chars = Array.from(hrp);
|
||||
const left = chars.map((char) => char.charCodeAt(0) >> 5);
|
||||
const right = chars.map((char) => char.charCodeAt(0) & 31);
|
||||
return [...left, 0, ...right];
|
||||
}
|
||||
|
||||
function polymod(values) {
|
||||
let chk = 1;
|
||||
for (const value of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ value;
|
||||
for (let i = 0; i < GENERATORS.length; i += 1) {
|
||||
if (((top >> i) & 1) !== 0) {
|
||||
chk ^= GENERATORS[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
|
||||
function verifyChecksum(hrp, data) {
|
||||
return polymod([...hrpExpand(hrp), ...data]) === 1;
|
||||
}
|
||||
|
||||
function bech32Decode(input) {
|
||||
if (typeof input !== "string") {
|
||||
throw new Error("LNURL must be a string.");
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("LNURL cannot be empty.");
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const upper = trimmed.toUpperCase();
|
||||
if (trimmed !== lower && trimmed !== upper) {
|
||||
throw new Error("LNURL cannot mix upper and lower case characters.");
|
||||
}
|
||||
|
||||
const normalized = lower;
|
||||
const separatorIndex = normalized.lastIndexOf("1");
|
||||
if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) {
|
||||
throw new Error("Invalid LNURL bech32 payload.");
|
||||
}
|
||||
|
||||
const hrp = normalized.slice(0, separatorIndex);
|
||||
const dataPart = normalized.slice(separatorIndex + 1);
|
||||
|
||||
const data = [];
|
||||
for (const char of dataPart) {
|
||||
if (!CHARKEY.has(char)) {
|
||||
throw new Error("LNURL payload includes invalid characters.");
|
||||
}
|
||||
data.push(CHARKEY.get(char));
|
||||
}
|
||||
|
||||
if (!verifyChecksum(hrp, data)) {
|
||||
throw new Error("LNURL checksum is invalid.");
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: hrp,
|
||||
words: data.slice(0, -6),
|
||||
};
|
||||
}
|
||||
|
||||
function convertWords(words, fromBits, toBits, { pad = true } = {}) {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
const maxValue = (1 << toBits) - 1;
|
||||
const result = [];
|
||||
|
||||
for (const word of words) {
|
||||
if (word < 0 || word >> fromBits !== 0) {
|
||||
throw new Error("Invalid bech32 word value.");
|
||||
}
|
||||
value = (value << fromBits) | word;
|
||||
bits += fromBits;
|
||||
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits;
|
||||
result.push((value >> bits) & maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (pad) {
|
||||
if (bits > 0) {
|
||||
result.push((value << (toBits - bits)) & maxValue);
|
||||
}
|
||||
} else if (bits >= fromBits || ((value << (toBits - bits)) & maxValue) !== 0) {
|
||||
throw new Error("Excess padding in bech32 payload.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeLnurlBech32(encoded) {
|
||||
const { prefix, words } = bech32Decode(encoded);
|
||||
if (prefix !== "lnurl") {
|
||||
throw new Error("Only bech32 LNURL encodings are supported.");
|
||||
}
|
||||
|
||||
const bytes = convertWords(words, 5, 8, { pad: false });
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
return decoder.decode(Uint8Array.from(bytes));
|
||||
}
|
||||
|
||||
function ensureFetchFunction(fetcher) {
|
||||
if (typeof fetcher === "function") {
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
if (typeof fetch === "function") {
|
||||
return fetch;
|
||||
}
|
||||
|
||||
if (typeof globalThis.fetch === "function") {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
throw new Error("Fetch API is not available in this environment.");
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("LNURL endpoint is missing.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveLightningAddress(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("Lightning address is required.");
|
||||
}
|
||||
|
||||
if (trimmed.toLowerCase().startsWith("lnurl")) {
|
||||
const decoded = decodeLnurlBech32(trimmed);
|
||||
return {
|
||||
type: "lud06",
|
||||
url: sanitizeUrl(decoded),
|
||||
address: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.includes("@")) {
|
||||
const [namePart, domainPart] = trimmed.split("@");
|
||||
const name = (namePart || "").trim();
|
||||
const domain = (domainPart || "").trim();
|
||||
if (!name || !domain) {
|
||||
throw new Error("Invalid Lightning address format.");
|
||||
}
|
||||
const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(
|
||||
name.toLowerCase()
|
||||
)}`;
|
||||
return {
|
||||
type: "lud16",
|
||||
url,
|
||||
address: `${name}@${domain}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return {
|
||||
type: "url",
|
||||
url: trimmed,
|
||||
address: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unsupported Lightning address format.");
|
||||
}
|
||||
|
||||
function normalizeNumeric(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback;
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
export async function fetchPayServiceData(url, { fetcher } = {}) {
|
||||
const targetUrl = sanitizeUrl(url);
|
||||
const fetchFn = ensureFetchFunction(fetcher);
|
||||
|
||||
const response = await fetchFn(targetUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load LNURL metadata (${response.status}).`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("LNURL endpoint did not return JSON.");
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("LNURL metadata response was invalid.");
|
||||
}
|
||||
|
||||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "LNURL endpoint returned an error.";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const callback = sanitizeUrl(payload.callback);
|
||||
const minSendable = Math.max(0, normalizeNumeric(payload.minSendable, 0));
|
||||
const maxSendable = Math.max(minSendable, normalizeNumeric(payload.maxSendable, minSendable));
|
||||
const commentAllowed = Math.max(0, Math.round(normalizeNumeric(payload.commentAllowed, DEFAULT_COMMENT_MAX)));
|
||||
const allowsNostr = payload.allowsNostr === true;
|
||||
const nostrPubkey =
|
||||
typeof payload.nostrPubkey === "string" ? payload.nostrPubkey.trim() : "";
|
||||
|
||||
let metadata = [];
|
||||
if (typeof payload.metadata === "string" && payload.metadata.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(payload.metadata);
|
||||
if (Array.isArray(parsed)) {
|
||||
metadata = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore metadata parse errors – it is optional.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tag: typeof payload.tag === "string" ? payload.tag : "payRequest",
|
||||
callback,
|
||||
minSendable,
|
||||
maxSendable,
|
||||
metadata,
|
||||
commentAllowed,
|
||||
allowsNostr,
|
||||
nostrPubkey,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInvoiceAmount(metadata, amountSats) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
throw new Error("LNURL metadata is required to validate invoice amounts.");
|
||||
}
|
||||
|
||||
const amount = Math.round(Number(amountSats));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error("Zap amount must be a positive integer.");
|
||||
}
|
||||
|
||||
const amountMsats = amount * 1000;
|
||||
const min = Math.max(0, Math.round(Number(metadata.minSendable || 0)));
|
||||
const max = Math.max(min, Math.round(Number(metadata.maxSendable || amountMsats)));
|
||||
|
||||
if (min && amountMsats < min) {
|
||||
throw new Error(`Amount is below the minimum sendable value (${min / 1000} sats).`);
|
||||
}
|
||||
|
||||
if (max && amountMsats > max) {
|
||||
throw new Error(`Amount exceeds the maximum sendable value (${max / 1000} sats).`);
|
||||
}
|
||||
|
||||
return { amountMsats };
|
||||
}
|
||||
|
||||
function truncateComment(comment, limit) {
|
||||
const trimmed = typeof comment === "string" ? comment.trim() : "";
|
||||
if (!limit || limit <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= limit) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function requestInvoice(
|
||||
metadata,
|
||||
{ amountSats, amountMsats, comment, zapRequest, fetcher } = {}
|
||||
) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
throw new Error("LNURL metadata is required to request an invoice.");
|
||||
}
|
||||
|
||||
const fetchFn = ensureFetchFunction(fetcher);
|
||||
const targetAmount = Number.isFinite(amountMsats)
|
||||
? Math.round(amountMsats)
|
||||
: validateInvoiceAmount(metadata, amountSats).amountMsats;
|
||||
|
||||
const callbackUrl = new URL(metadata.callback);
|
||||
callbackUrl.searchParams.set("amount", String(targetAmount));
|
||||
|
||||
if (comment && metadata.commentAllowed > 0) {
|
||||
const truncated = truncateComment(comment, metadata.commentAllowed);
|
||||
if (truncated) {
|
||||
callbackUrl.searchParams.set("comment", truncated);
|
||||
}
|
||||
}
|
||||
|
||||
if (zapRequest) {
|
||||
callbackUrl.searchParams.set("nostr", zapRequest);
|
||||
}
|
||||
|
||||
const response = await fetchFn(callbackUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch LNURL invoice (${response.status}).`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("LNURL invoice response was not JSON.");
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("LNURL invoice response was invalid.");
|
||||
}
|
||||
|
||||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "LNURL invoice request failed.";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const invoice = typeof payload.pr === "string" ? payload.pr.trim() : "";
|
||||
if (!invoice) {
|
||||
throw new Error("LNURL endpoint did not return an invoice.");
|
||||
}
|
||||
|
||||
return {
|
||||
invoice,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
bech32Decode,
|
||||
decodeLnurlBech32,
|
||||
convertWords,
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
// js/payments/platformAddress.js
|
||||
|
||||
import { PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB } from "../config.js";
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { setProfileCacheEntry } from "../state/cache.js";
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
let cachedAddress = null;
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readToolkitFromScope(scope = globalScope) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
if (canonical) {
|
||||
candidates.push(canonical);
|
||||
}
|
||||
|
||||
const direct = scope.NostrTools;
|
||||
if (direct) {
|
||||
candidates.push(direct);
|
||||
}
|
||||
|
||||
const nestedWindow =
|
||||
scope.window && scope.window !== scope && typeof scope.window === "object"
|
||||
? scope.window
|
||||
: null;
|
||||
if (nestedWindow) {
|
||||
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
|
||||
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
|
||||
}
|
||||
if (nestedWindow.NostrTools) {
|
||||
candidates.push(nestedWindow.NostrTools);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const __platformNostrToolsBootstrap = await (async () => {
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
if (result && typeof result === "object" && result.ok === false) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: result,
|
||||
};
|
||||
}
|
||||
const normalized = normalizeToolkitCandidate(result);
|
||||
if (normalized) {
|
||||
return { toolkit: normalized, failure: null };
|
||||
}
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
let cachedNostrTools = __platformNostrToolsBootstrap.toolkit || null;
|
||||
const nostrToolsInitializationFailure =
|
||||
__platformNostrToolsBootstrap.failure || null;
|
||||
|
||||
if (!cachedNostrTools && nostrToolsInitializationFailure) {
|
||||
console.warn(
|
||||
"[platformAddress] nostr-tools helpers unavailable after bootstrap.",
|
||||
nostrToolsInitializationFailure
|
||||
);
|
||||
}
|
||||
|
||||
function rememberNostrTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedNostrTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedNostrTools() {
|
||||
const fallback = readToolkitFromScope();
|
||||
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
|
||||
rememberNostrTools(fallback);
|
||||
} else if (!cachedNostrTools && fallback) {
|
||||
rememberNostrTools(fallback);
|
||||
}
|
||||
return cachedNostrTools || fallback || null;
|
||||
}
|
||||
|
||||
async function ensureNostrTools() {
|
||||
if (cachedNostrTools) {
|
||||
return cachedNostrTools;
|
||||
}
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberNostrTools(result);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[platformAddress] Failed to resolve nostr-tools helpers.",
|
||||
error
|
||||
);
|
||||
}
|
||||
if (!cachedNostrTools) {
|
||||
rememberNostrTools(readToolkitFromScope());
|
||||
}
|
||||
return cachedNostrTools || null;
|
||||
}
|
||||
|
||||
function getGlobalWindow() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
const win = getGlobalWindow();
|
||||
const tools = getCachedNostrTools();
|
||||
if (tools?.nip04) {
|
||||
return tools;
|
||||
}
|
||||
const canonical = win?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
|
||||
if (canonical && tools && !tools.nip04 && canonical.nip04) {
|
||||
try {
|
||||
tools.nip04 = canonical.nip04;
|
||||
return tools;
|
||||
} catch (error) {
|
||||
return { ...canonical, ...tools, nip04: canonical.nip04 };
|
||||
}
|
||||
}
|
||||
return tools || canonical || null;
|
||||
}
|
||||
|
||||
function decodeAdminPubkey() {
|
||||
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
const tools = getNostrTools();
|
||||
const decoder = tools?.nip19?.decode;
|
||||
if (typeof decoder === "function") {
|
||||
try {
|
||||
const decoded = decoder(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to decode ADMIN_SUPER_NPUB", error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldUseOverride() {
|
||||
return typeof PLATFORM_LUD16_OVERRIDE === "string" && PLATFORM_LUD16_OVERRIDE.trim();
|
||||
}
|
||||
|
||||
function getOverrideAddress() {
|
||||
const trimmed = PLATFORM_LUD16_OVERRIDE.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function isCacheFresh(entry) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.expiresAt !== "number") {
|
||||
return false;
|
||||
}
|
||||
return entry.expiresAt > Date.now();
|
||||
}
|
||||
|
||||
async function fetchAdminMetadata(adminPubkey) {
|
||||
if (!adminPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pool;
|
||||
try {
|
||||
pool = await nostrClient.ensurePool();
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to initialize Nostr pool", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relayUrls = Array.isArray(nostrClient?.relays)
|
||||
? nostrClient.relays
|
||||
: [];
|
||||
if (relayUrls.length === 0) {
|
||||
console.warn("[platformAddress] Nostr client is not ready.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await pool.list(relayUrls, [
|
||||
{ kinds: [0], authors: [adminPubkey], limit: 1 },
|
||||
]);
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newest = null;
|
||||
for (const event of events) {
|
||||
if (!event || event.pubkey !== adminPubkey || !event.content) {
|
||||
continue;
|
||||
}
|
||||
if (!newest || event.created_at > newest.created_at) {
|
||||
newest = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newest?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(newest.content);
|
||||
const lightningAddress =
|
||||
typeof data.lud16 === "string" && data.lud16.trim()
|
||||
? data.lud16.trim()
|
||||
: typeof data.lud06 === "string" && data.lud06.trim()
|
||||
? data.lud06.trim()
|
||||
: "";
|
||||
|
||||
if (lightningAddress) {
|
||||
setProfileCacheEntry(adminPubkey, data, { persist: false });
|
||||
}
|
||||
|
||||
return lightningAddress || null;
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to fetch admin metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformLightningAddress({ forceRefresh = false } = {}) {
|
||||
if (shouldUseOverride()) {
|
||||
return getOverrideAddress();
|
||||
}
|
||||
|
||||
if (!forceRefresh && isCacheFresh(cachedAddress)) {
|
||||
return cachedAddress.value;
|
||||
}
|
||||
|
||||
const adminPubkey = decodeAdminPubkey();
|
||||
if (!adminPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (forceRefresh !== true && cachedAddress?.value) {
|
||||
return cachedAddress.value;
|
||||
}
|
||||
|
||||
const lightningAddress = await fetchAdminMetadata(adminPubkey);
|
||||
if (lightningAddress) {
|
||||
cachedAddress = {
|
||||
value: lightningAddress,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
return lightningAddress || cachedAddress?.value || null;
|
||||
}
|
||||
|
||||
export function __resetPlatformAddressCache() {
|
||||
cachedAddress = null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// js/payments/platformFee.js
|
||||
|
||||
import { PLATFORM_FEE_PERCENT } from "../config.js";
|
||||
|
||||
export function parsePercentValue(value) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : NaN;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
if (trimmed.includes("/")) {
|
||||
const [firstRaw, secondRaw] = trimmed.split("/").map((part) => part.trim());
|
||||
if (firstRaw && secondRaw) {
|
||||
const first = Number(firstRaw);
|
||||
const second = Number(secondRaw);
|
||||
if (Number.isFinite(first) && Number.isFinite(second) && first >= 0 && second >= 0) {
|
||||
const total = first + second;
|
||||
if (total > 0) {
|
||||
return (second / total) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const percentStripped = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed;
|
||||
const directNumeric = Number(percentStripped);
|
||||
if (Number.isFinite(directNumeric)) {
|
||||
return directNumeric;
|
||||
}
|
||||
|
||||
const match = percentStripped.match(/-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const fallback = Number(match[0]);
|
||||
return Number.isFinite(fallback) ? fallback : NaN;
|
||||
}
|
||||
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return Number.isFinite(value) ? Number(value) : NaN;
|
||||
}
|
||||
|
||||
export function clampPercent(value) {
|
||||
const numeric = parsePercentValue(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(0, numeric));
|
||||
}
|
||||
|
||||
export function getDefaultPlatformFeePercent() {
|
||||
const parsed = parsePercentValue(PLATFORM_FEE_PERCENT);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return clampPercent(parsed);
|
||||
}
|
||||
|
||||
export function resolvePlatformFeePercent(overrideValue) {
|
||||
const parsedOverride = parsePercentValue(overrideValue);
|
||||
if (Number.isFinite(parsedOverride)) {
|
||||
return clampPercent(parsedOverride);
|
||||
}
|
||||
return getDefaultPlatformFeePercent();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { resolvePlatformFeePercent } from "./platformFee.js";
|
||||
import {
|
||||
resolveLightningAddress as defaultResolveLightningAddress,
|
||||
fetchPayServiceData as defaultFetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
} from "./lnurl.js";
|
||||
|
||||
const METADATA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const lightningMetadataCache = new Map();
|
||||
const lightningMetadataByUrl = new Map();
|
||||
let cachedPlatformLightningAddress = "";
|
||||
|
||||
export function normalizeLightningAddressKey(address) {
|
||||
return typeof address === "string" ? address.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isMetadataEntryFresh(entry) {
|
||||
if (!entry || typeof entry.fetchedAt !== "number") {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - entry.fetchedAt < METADATA_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
export function rememberLightningMetadata(entry) {
|
||||
if (!entry || !entry.key) {
|
||||
return entry;
|
||||
}
|
||||
lightningMetadataCache.set(entry.key, entry);
|
||||
if (entry.resolved?.url) {
|
||||
lightningMetadataByUrl.set(entry.resolved.url, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getCachedLightningEntry(address) {
|
||||
const key = normalizeLightningAddressKey(address);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const entry = lightningMetadataCache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.metadata && isMetadataEntryFresh(entry)) {
|
||||
return entry;
|
||||
}
|
||||
if (entry.metadata && !entry.promise) {
|
||||
return entry;
|
||||
}
|
||||
return entry.metadata ? entry : null;
|
||||
}
|
||||
|
||||
export function getCachedMetadataByUrl(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return lightningMetadataByUrl.get(url) || null;
|
||||
}
|
||||
|
||||
export async function fetchLightningMetadata(
|
||||
address,
|
||||
{
|
||||
resolveLightningAddress = defaultResolveLightningAddress,
|
||||
fetchPayServiceData = defaultFetchPayServiceData,
|
||||
} = {}
|
||||
) {
|
||||
const key = normalizeLightningAddressKey(address);
|
||||
if (!key) {
|
||||
throw new Error("Lightning address is required.");
|
||||
}
|
||||
|
||||
const cached = lightningMetadataCache.get(key);
|
||||
if (cached && cached.metadata && isMetadataEntryFresh(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (cached?.promise) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const resolved = cached?.resolved || (await resolveLightningAddress(address));
|
||||
const metadata = await fetchPayServiceData(resolved.url);
|
||||
const entry = {
|
||||
key,
|
||||
address: resolved.address || address,
|
||||
resolved,
|
||||
metadata,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
rememberLightningMetadata(entry);
|
||||
return entry;
|
||||
})();
|
||||
|
||||
lightningMetadataCache.set(key, {
|
||||
...(cached || {}),
|
||||
key,
|
||||
promise: fetchPromise,
|
||||
});
|
||||
|
||||
try {
|
||||
return await fetchPromise;
|
||||
} catch (error) {
|
||||
const current = lightningMetadataCache.get(key);
|
||||
if (current?.promise === fetchPromise) {
|
||||
lightningMetadataCache.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateZapShares(amount, overrideFee = null) {
|
||||
const numericAmount = Math.max(0, Math.round(Number(amount) || 0));
|
||||
const feePercent = resolvePlatformFeePercent(
|
||||
typeof overrideFee === "undefined" ? null : overrideFee
|
||||
);
|
||||
const platformShare = Math.floor((numericAmount * feePercent) / 100);
|
||||
const creatorShare = numericAmount - platformShare;
|
||||
return {
|
||||
total: numericAmount,
|
||||
creatorShare,
|
||||
platformShare,
|
||||
feePercent,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeShareType(type) {
|
||||
if (type === "platform") {
|
||||
return "Platform";
|
||||
}
|
||||
if (type === "creator") {
|
||||
return "Creator";
|
||||
}
|
||||
return "Lightning";
|
||||
}
|
||||
|
||||
export function formatMinRequirement(metadata) {
|
||||
if (!metadata || typeof metadata.minSendable !== "number") {
|
||||
return null;
|
||||
}
|
||||
if (metadata.minSendable <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.ceil(metadata.minSendable / 1000);
|
||||
}
|
||||
|
||||
export function setCachedPlatformLightningAddress(address) {
|
||||
cachedPlatformLightningAddress =
|
||||
typeof address === "string" ? address.trim() : "";
|
||||
return cachedPlatformLightningAddress;
|
||||
}
|
||||
|
||||
export function getCachedPlatformLightningAddress() {
|
||||
return cachedPlatformLightningAddress;
|
||||
}
|
||||
|
||||
export function clearZapCaches() {
|
||||
lightningMetadataCache.clear();
|
||||
lightningMetadataByUrl.clear();
|
||||
cachedPlatformLightningAddress = "";
|
||||
}
|
||||
|
||||
export { validateInvoiceAmount };
|
||||
@@ -0,0 +1,464 @@
|
||||
// js/payments/zapSplit.js
|
||||
|
||||
import { ADMIN_SUPER_NPUB } from "../config.js";
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
import {
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
} from "./lnurl.js";
|
||||
import { ensureWallet, sendPayment } from "./nwcClient.js";
|
||||
import { getPlatformLightningAddress } from "./platformAddress.js";
|
||||
import {
|
||||
clampPercent,
|
||||
parsePercentValue,
|
||||
resolvePlatformFeePercent,
|
||||
} from "./platformFee.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const ZAP_KIND = 9734;
|
||||
const DEFAULT_DEPS = Object.freeze({
|
||||
lnurl: Object.freeze({
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
}),
|
||||
wallet: Object.freeze({ ensureWallet, sendPayment }),
|
||||
platformAddress: Object.freeze({ getPlatformLightningAddress }),
|
||||
});
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readToolkitFromScope(scope = globalScope) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
if (canonical) {
|
||||
candidates.push(canonical);
|
||||
}
|
||||
|
||||
const direct = scope.NostrTools;
|
||||
if (direct) {
|
||||
candidates.push(direct);
|
||||
}
|
||||
|
||||
const nestedWindow =
|
||||
scope.window && scope.window !== scope && typeof scope.window === "object"
|
||||
? scope.window
|
||||
: null;
|
||||
if (nestedWindow) {
|
||||
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
|
||||
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
|
||||
}
|
||||
if (nestedWindow.NostrTools) {
|
||||
candidates.push(nestedWindow.NostrTools);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const __zapNostrToolsBootstrap = await (async () => {
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
if (result && typeof result === "object" && result.ok === false) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: result,
|
||||
};
|
||||
}
|
||||
const normalized = normalizeToolkitCandidate(result);
|
||||
if (normalized) {
|
||||
return { toolkit: normalized, failure: null };
|
||||
}
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
let cachedNostrTools = __zapNostrToolsBootstrap.toolkit || null;
|
||||
const nostrToolsInitializationFailure = __zapNostrToolsBootstrap.failure || null;
|
||||
|
||||
if (!cachedNostrTools && nostrToolsInitializationFailure) {
|
||||
console.warn(
|
||||
"[zapSplit] nostr-tools helpers unavailable after bootstrap.",
|
||||
nostrToolsInitializationFailure
|
||||
);
|
||||
}
|
||||
|
||||
function rememberNostrTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedNostrTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedNostrTools() {
|
||||
const fallback = readToolkitFromScope();
|
||||
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
|
||||
rememberNostrTools(fallback);
|
||||
} else if (!cachedNostrTools && fallback) {
|
||||
rememberNostrTools(fallback);
|
||||
}
|
||||
return cachedNostrTools || fallback || null;
|
||||
}
|
||||
|
||||
async function ensureNostrTools() {
|
||||
if (cachedNostrTools) {
|
||||
return cachedNostrTools;
|
||||
}
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberNostrTools(result);
|
||||
} catch (error) {
|
||||
console.warn("[zapSplit] Failed to resolve nostr-tools helpers.", error);
|
||||
}
|
||||
if (!cachedNostrTools) {
|
||||
rememberNostrTools(readToolkitFromScope());
|
||||
}
|
||||
return cachedNostrTools || null;
|
||||
}
|
||||
|
||||
function getOverridePlatformFee() {
|
||||
return resolvePlatformFeePercent(globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__);
|
||||
}
|
||||
|
||||
function sanitizeAmount(amount) {
|
||||
const numeric = Number(amount);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
throw new Error("Zap amount must be a positive integer.");
|
||||
}
|
||||
const rounded = Math.round(numeric);
|
||||
if (rounded <= 0) {
|
||||
throw new Error("Zap amount must be greater than zero.");
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function sanitizeComment(comment) {
|
||||
if (typeof comment !== "string") {
|
||||
return "";
|
||||
}
|
||||
return comment.trim();
|
||||
}
|
||||
|
||||
function sanitizeAddress(address) {
|
||||
const trimmed = typeof address === "string" ? address.trim() : "";
|
||||
return trimmed || "";
|
||||
}
|
||||
|
||||
function mergeDependencies(overrides = {}) {
|
||||
const lnurlDeps = {
|
||||
...DEFAULT_DEPS.lnurl,
|
||||
...(overrides.lnurl || {}),
|
||||
};
|
||||
const walletDeps = {
|
||||
...DEFAULT_DEPS.wallet,
|
||||
...(overrides.wallet || {}),
|
||||
};
|
||||
const platformDeps = {
|
||||
...DEFAULT_DEPS.platformAddress,
|
||||
...(overrides.platformAddress || {}),
|
||||
};
|
||||
|
||||
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
const tools = getCachedNostrTools();
|
||||
if (tools?.nip04) {
|
||||
return tools;
|
||||
}
|
||||
const scope = globalScope || (typeof window !== "undefined" ? window : globalThis);
|
||||
const canonical = scope?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
|
||||
if (canonical && tools && !tools.nip04 && canonical.nip04) {
|
||||
try {
|
||||
tools.nip04 = canonical.nip04;
|
||||
return tools;
|
||||
} catch (error) {
|
||||
return { ...canonical, ...tools, nip04: canonical.nip04 };
|
||||
}
|
||||
}
|
||||
return tools || canonical || null;
|
||||
}
|
||||
|
||||
function decodeAdminPubkey() {
|
||||
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
const tools = getNostrTools();
|
||||
const decoder = tools?.nip19?.decode;
|
||||
if (typeof decoder === "function") {
|
||||
try {
|
||||
const decoded = decoder(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function derivePointerTag(videoEvent) {
|
||||
if (!videoEvent || typeof videoEvent !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(videoEvent.tags)) {
|
||||
return null;
|
||||
}
|
||||
const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]);
|
||||
if (!dTag) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`;
|
||||
return ["a", value];
|
||||
}
|
||||
|
||||
function buildZapRequest({
|
||||
wallet,
|
||||
recipientPubkey,
|
||||
videoEvent,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl,
|
||||
}) {
|
||||
const tools = getNostrTools();
|
||||
if (!tools?.getEventHash || !tools?.signEvent) {
|
||||
throw new Error("NostrTools is required to sign zap requests.");
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (recipientPubkey) {
|
||||
tags.push(["p", recipientPubkey]);
|
||||
}
|
||||
if (videoEvent?.id) {
|
||||
tags.push(["e", videoEvent.id]);
|
||||
}
|
||||
const pointerTag = derivePointerTag(videoEvent);
|
||||
if (pointerTag) {
|
||||
tags.push(pointerTag);
|
||||
}
|
||||
if (lnurl) {
|
||||
tags.push(["lnurl", lnurl]);
|
||||
}
|
||||
if (Number.isFinite(amountSats)) {
|
||||
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: ZAP_KIND,
|
||||
content: comment || "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
pubkey: wallet?.clientPubkey || "",
|
||||
};
|
||||
|
||||
event.id = tools.getEventHash(event);
|
||||
event.sig = tools.signEvent(event, wallet?.secretKey);
|
||||
|
||||
return JSON.stringify(event);
|
||||
}
|
||||
|
||||
function determineRecipientPubkey({ recipientType, metadata, videoEvent }) {
|
||||
if (metadata?.nostrPubkey && HEX64_REGEX.test(metadata.nostrPubkey)) {
|
||||
return metadata.nostrPubkey.toLowerCase();
|
||||
}
|
||||
if (recipientType === "creator" && typeof videoEvent?.pubkey === "string") {
|
||||
return videoEvent.pubkey;
|
||||
}
|
||||
if (recipientType === "platform") {
|
||||
return decodeAdminPubkey();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processShare({
|
||||
recipientType,
|
||||
address,
|
||||
amountSats,
|
||||
comment,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
}) {
|
||||
const resolved = deps.lnurl.resolveLightningAddress(address);
|
||||
const metadata = await deps.lnurl.fetchPayServiceData(resolved.url);
|
||||
const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats);
|
||||
|
||||
let zapRequest = null;
|
||||
if (metadata.allowsNostr === true || metadata.nostrPubkey) {
|
||||
const recipientPubkey = determineRecipientPubkey({
|
||||
recipientType,
|
||||
metadata,
|
||||
videoEvent,
|
||||
});
|
||||
if (recipientPubkey) {
|
||||
zapRequest = buildZapRequest({
|
||||
wallet,
|
||||
recipientPubkey,
|
||||
videoEvent,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl: resolved.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const invoice = await deps.lnurl.requestInvoice(metadata, {
|
||||
amountMsats,
|
||||
comment,
|
||||
zapRequest,
|
||||
});
|
||||
|
||||
const payment = await deps.wallet.sendPayment(invoice.invoice, {
|
||||
amountSats,
|
||||
zapRequest,
|
||||
});
|
||||
|
||||
return {
|
||||
recipientType,
|
||||
address,
|
||||
amount: amountSats,
|
||||
lnurl: resolved,
|
||||
metadata,
|
||||
invoice,
|
||||
payment,
|
||||
zapRequest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function splitAndZap(
|
||||
{ videoEvent, amountSats, comment = "", walletSettings } = {},
|
||||
dependencies = {}
|
||||
) {
|
||||
if (!videoEvent || typeof videoEvent !== "object") {
|
||||
throw new Error("A video event is required to zap.");
|
||||
}
|
||||
|
||||
const amount = sanitizeAmount(amountSats);
|
||||
const note = sanitizeComment(comment);
|
||||
const deps = mergeDependencies(dependencies);
|
||||
|
||||
const wallet = await deps.wallet.ensureWallet({ settings: walletSettings });
|
||||
|
||||
const creatorAddress = sanitizeAddress(videoEvent.lightningAddress);
|
||||
if (!creatorAddress) {
|
||||
throw new Error("This creator has not configured a Lightning address yet.");
|
||||
}
|
||||
|
||||
const platformFee = clampPercent(getOverridePlatformFee());
|
||||
const platformShare = Math.floor((amount * platformFee) / 100);
|
||||
const creatorShare = amount - platformShare;
|
||||
|
||||
let platformAddress = null;
|
||||
if (platformShare > 0) {
|
||||
platformAddress = sanitizeAddress(
|
||||
await deps.platformAddress.getPlatformLightningAddress({ forceRefresh: false })
|
||||
);
|
||||
if (!platformAddress) {
|
||||
throw new Error("Platform Lightning address is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
const receipts = [];
|
||||
|
||||
if (creatorShare > 0) {
|
||||
receipts.push(
|
||||
await processShare({
|
||||
recipientType: "creator",
|
||||
address: creatorAddress,
|
||||
amountSats: creatorShare,
|
||||
comment: note,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (platformShare > 0) {
|
||||
receipts.push(
|
||||
await processShare({
|
||||
recipientType: "platform",
|
||||
address: platformAddress,
|
||||
amountSats: platformShare,
|
||||
comment: note,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
totalAmount: amount,
|
||||
creatorShare,
|
||||
platformShare,
|
||||
receipts,
|
||||
};
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
buildZapRequest,
|
||||
determineRecipientPubkey,
|
||||
derivePointerTag,
|
||||
mergeDependencies,
|
||||
parsePercentValue,
|
||||
clampPercent,
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
|
||||
import { buildRelayListEvent } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
const MODE_SEQUENCE = ["both", "read", "write"];
|
||||
|
||||
const FAST_RELAY_FETCH_LIMIT = 3;
|
||||
const FAST_RELAY_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_RELAY_TIMEOUT_MS = 6000;
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRelayUrl(input) {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let candidate = input.trim();
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^[a-z]+:\/\//i.test(candidate)) {
|
||||
candidate = `wss://${candidate}`;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
let pathname = parsed.pathname || "";
|
||||
if (pathname !== "/") {
|
||||
pathname = pathname.replace(/\/+$/, "");
|
||||
}
|
||||
if (pathname === "/") {
|
||||
pathname = "";
|
||||
}
|
||||
const search = parsed.search || "";
|
||||
const hash = parsed.hash || "";
|
||||
|
||||
return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
function deriveMode(marker) {
|
||||
if (typeof marker !== "string") {
|
||||
return "both";
|
||||
}
|
||||
const normalized = marker.trim().toLowerCase();
|
||||
if (normalized === "read") {
|
||||
return "read";
|
||||
}
|
||||
if (normalized === "write") {
|
||||
return "write";
|
||||
}
|
||||
return "both";
|
||||
}
|
||||
|
||||
function createEntry(url, mode = "both") {
|
||||
const normalizedMode = deriveMode(mode);
|
||||
return {
|
||||
url,
|
||||
mode: normalizedMode,
|
||||
read: normalizedMode !== "write",
|
||||
write: normalizedMode !== "read",
|
||||
};
|
||||
}
|
||||
|
||||
function cloneEntry(entry) {
|
||||
return {
|
||||
url: entry.url,
|
||||
mode: entry.mode,
|
||||
read: !!entry.read,
|
||||
write: !!entry.write,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEntries(entries) {
|
||||
return JSON.stringify(
|
||||
entries.map((entry) => ({ url: entry.url, mode: entry.mode }))
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayTags(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Map();
|
||||
const order = [];
|
||||
|
||||
tags.forEach((tag) => {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
return;
|
||||
}
|
||||
if (tag[0] !== "r") {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = normalizeRelayUrl(tag[1]);
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
if (!seen.has(normalizedUrl)) {
|
||||
seen.set(normalizedUrl, { read: false, write: false });
|
||||
order.push(normalizedUrl);
|
||||
}
|
||||
const record = seen.get(normalizedUrl);
|
||||
const mode = deriveMode(tag[2]);
|
||||
if (mode === "read") {
|
||||
record.read = true;
|
||||
} else if (mode === "write") {
|
||||
record.write = true;
|
||||
} else {
|
||||
record.read = true;
|
||||
record.write = true;
|
||||
}
|
||||
});
|
||||
|
||||
return order.map((url) => {
|
||||
const record = seen.get(url) || { read: true, write: true };
|
||||
if (record.read && record.write) {
|
||||
return createEntry(url, "both");
|
||||
}
|
||||
if (record.read) {
|
||||
return createEntry(url, "read");
|
||||
}
|
||||
if (record.write) {
|
||||
return createEntry(url, "write");
|
||||
}
|
||||
return createEntry(url, "both");
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEntryInput(entry) {
|
||||
if (typeof entry === "string") {
|
||||
return { url: entry, mode: "both" };
|
||||
}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const url = typeof entry.url === "string" ? entry.url : "";
|
||||
let mode = entry.mode || entry.marker;
|
||||
if (!mode) {
|
||||
if (entry.read === true && entry.write === false) {
|
||||
mode = "read";
|
||||
} else if (entry.write === true && entry.read === false) {
|
||||
mode = "write";
|
||||
}
|
||||
}
|
||||
return { url, mode: deriveMode(mode) };
|
||||
}
|
||||
|
||||
class RelayPreferencesManager {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
this.entryIndex = new Map();
|
||||
this.lastEvent = null;
|
||||
this.loadedPubkey = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.defaultEntries = DEFAULT_RELAY_URLS.map((url) => {
|
||||
const normalized = normalizeRelayUrl(url) || url;
|
||||
return createEntry(normalized, "both");
|
||||
});
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
getEntries() {
|
||||
return this.entries.map((entry) => cloneEntry(entry));
|
||||
}
|
||||
|
||||
getAllRelayUrls() {
|
||||
return this.entries.map((entry) => entry.url);
|
||||
}
|
||||
|
||||
getReadRelayUrls() {
|
||||
return this.entries.filter((entry) => entry.read).map((entry) => entry.url);
|
||||
}
|
||||
|
||||
getWriteRelayUrls() {
|
||||
return this.entries.filter((entry) => entry.write).map((entry) => entry.url);
|
||||
}
|
||||
|
||||
syncClient() {
|
||||
if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") {
|
||||
nostrClient.applyRelayPreferences({
|
||||
all: this.getAllRelayUrls(),
|
||||
read: this.getReadRelayUrls(),
|
||||
write: this.getWriteRelayUrls(),
|
||||
});
|
||||
} else if (nostrClient) {
|
||||
nostrClient.relays = this.getAllRelayUrls();
|
||||
}
|
||||
}
|
||||
|
||||
setEntries(entries, { allowEmpty = false, updateClient = true } = {}) {
|
||||
const nextEntries = [];
|
||||
const indexMap = new Map();
|
||||
|
||||
if (Array.isArray(entries)) {
|
||||
entries.forEach((item) => {
|
||||
const resolved = resolveEntryInput(item);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = normalizeRelayUrl(resolved.url);
|
||||
if (!normalizedUrl || indexMap.has(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = createEntry(normalizedUrl, resolved.mode);
|
||||
indexMap.set(normalizedUrl, nextEntries.length);
|
||||
nextEntries.push(normalizedEntry);
|
||||
});
|
||||
}
|
||||
|
||||
if (!nextEntries.length && !allowEmpty) {
|
||||
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
if (updateClient) {
|
||||
this.syncClient();
|
||||
}
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
this.entries = nextEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
|
||||
if (!this.entries.length && !allowEmpty) {
|
||||
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
}
|
||||
|
||||
if (updateClient) {
|
||||
this.syncClient();
|
||||
}
|
||||
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
addRelay(url, mode = "both") {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid WSS relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (this.entryIndex.has(normalizedUrl)) {
|
||||
return {
|
||||
changed: false,
|
||||
reason: "duplicate",
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries();
|
||||
nextEntries.push({ url: normalizedUrl, mode });
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
updateRelayMode(url, mode = "both") {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
return { changed: false, reason: "missing" };
|
||||
}
|
||||
|
||||
const normalizedMode = deriveMode(mode);
|
||||
const index = this.entryIndex.get(normalizedUrl);
|
||||
const current = this.entries[index];
|
||||
if (current.mode === normalizedMode) {
|
||||
return { changed: false, entry: cloneEntry(current) };
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries();
|
||||
nextEntries[index].mode = normalizedMode;
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
cycleRelayMode(url) {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
const error = new Error("Relay not found.");
|
||||
error.code = "missing";
|
||||
throw error;
|
||||
}
|
||||
const index = this.entryIndex.get(normalizedUrl);
|
||||
const currentMode = this.entries[index].mode || "both";
|
||||
const currentIdx = MODE_SEQUENCE.indexOf(currentMode);
|
||||
const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length];
|
||||
return this.updateRelayMode(normalizedUrl, nextMode);
|
||||
}
|
||||
|
||||
removeRelay(url) {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL to remove.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
return { changed: false, reason: "missing" };
|
||||
}
|
||||
if (this.entries.length <= 1) {
|
||||
const error = new Error("At least one relay is required.");
|
||||
error.code = "minimum";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl);
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
restoreDefaults() {
|
||||
const before = serializeEntries(this.entries);
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
const after = serializeEntries(this.entries);
|
||||
return { changed: before !== after };
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.lastEvent = null;
|
||||
this.loadedPubkey = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
}
|
||||
|
||||
getPublishTargets(customTargets = null) {
|
||||
const targets = new Set();
|
||||
const list =
|
||||
Array.isArray(customTargets) && customTargets.length
|
||||
? customTargets
|
||||
: this.getAllRelayUrls();
|
||||
|
||||
list.forEach((candidate) => {
|
||||
const normalized = normalizeRelayUrl(candidate);
|
||||
if (normalized) {
|
||||
targets.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
DEFAULT_RELAY_URLS.forEach((url) => {
|
||||
const normalized = normalizeRelayUrl(url) || url;
|
||||
targets.add(normalized);
|
||||
});
|
||||
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
async loadRelayList(pubkey) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
this.loadedPubkey = normalizedPubkey;
|
||||
|
||||
if (!normalizedPubkey) {
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: false, reason: "invalid-pubkey" };
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: false, reason: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 };
|
||||
const targetRelays = this.getPublishTargets();
|
||||
const applyEvents = (events, { skipIfEmpty = false } = {}) => {
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
if (skipIfEmpty) {
|
||||
return null;
|
||||
}
|
||||
this.lastEvent = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", events: [] };
|
||||
}
|
||||
|
||||
const sorted = events
|
||||
.filter((event) => event && event.pubkey === normalizedPubkey)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
|
||||
if (!sorted.length) {
|
||||
if (skipIfEmpty) {
|
||||
return null;
|
||||
}
|
||||
this.lastEvent = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", events: [] };
|
||||
}
|
||||
|
||||
const newest = sorted[0];
|
||||
this.lastEvent = newest;
|
||||
|
||||
const parsed = parseRelayTags(newest?.tags);
|
||||
if (parsed.length) {
|
||||
this.setEntries(parsed, { allowEmpty: false });
|
||||
this.lastLoadSource = "event";
|
||||
return { ok: true, source: "event", event: newest };
|
||||
}
|
||||
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", event: newest };
|
||||
};
|
||||
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching relay list from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => nostrClient.pool.list([relayUrl], [filter]))
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalizedPubkey)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No relay list events returned from ${relayUrl}`
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
reject(emptyError);
|
||||
return;
|
||||
}
|
||||
resolve({ relayUrl, events });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
reject(wrapped);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fastRelays = targetRelays.slice(0, FAST_RELAY_FETCH_LIMIT);
|
||||
const backgroundRelays = targetRelays.slice(fastRelays.length);
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_RELAY_TIMEOUT_MS, true)
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_RELAY_TIMEOUT_MS, false)
|
||||
);
|
||||
|
||||
const background = Promise.allSettled([
|
||||
...fastPromises,
|
||||
...backgroundPromises,
|
||||
])
|
||||
.then((outcomes) => {
|
||||
const aggregated = [];
|
||||
outcomes.forEach((outcome) => {
|
||||
if (outcome.status === "fulfilled") {
|
||||
const events = Array.isArray(outcome.value?.events)
|
||||
? outcome.value.events
|
||||
: [];
|
||||
if (events.length) {
|
||||
aggregated.push(...events);
|
||||
}
|
||||
} else {
|
||||
const reason = outcome.reason;
|
||||
if (reason?.code === "timeout") {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${reason.relay} timed out while loading relay list (${reason.timeoutMs}ms)`
|
||||
);
|
||||
}
|
||||
} else if (isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${reason?.relay || "unknown"} failed while loading relay list:`,
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!aggregated.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyEvents(aggregated, { skipIfEmpty: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[relayManager] Background relay refresh failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
let fastResult = null;
|
||||
if (fastPromises.length) {
|
||||
try {
|
||||
fastResult = await Promise.any(fastPromises);
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors?.forEach((err) => {
|
||||
if (err?.code === "timeout" && isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${err.relay} timed out while loading relay list (${err.timeoutMs}ms)`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (isDevMode) {
|
||||
console.warn("[relayManager] Fast relay fetch failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fastResult?.events?.length) {
|
||||
const result = applyEvents(fastResult.events);
|
||||
background.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const fallback = applyEvents([], { skipIfEmpty: false });
|
||||
background.catch(() => {});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async publishRelayList(pubkey, options = {}) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
const error = new Error(
|
||||
"A valid pubkey is required to publish relay preferences."
|
||||
);
|
||||
error.code = "invalid-pubkey";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.signEvent) {
|
||||
const error = new Error(
|
||||
"A NIP-07 extension is required to publish relay preferences."
|
||||
);
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
const error = new Error(
|
||||
"Nostr is not connected yet. Please try again once relays are ready."
|
||||
);
|
||||
error.code = "nostr-uninitialized";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries = this.getEntries();
|
||||
if (!entries.length) {
|
||||
const error = new Error("Add at least one relay before publishing.");
|
||||
error.code = "empty";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const event = buildRelayListEvent({
|
||||
pubkey: normalizedPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
relays: entries,
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const targets = this.getPublishTargets(options?.relayUrls);
|
||||
|
||||
if (!targets.length) {
|
||||
const error = new Error("No relay targets are available for publishing.");
|
||||
error.code = "no-targets";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
targets,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "relay preferences update",
|
||||
message: "No relays accepted the update.",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const accepted = publishSummary.accepted.map(({ url }) => url);
|
||||
const failed = publishSummary.failed.map(({ url, error: relayError }) => ({
|
||||
url,
|
||||
reason: relayError || null,
|
||||
}));
|
||||
|
||||
this.lastEvent = signedEvent;
|
||||
|
||||
return { ok: true, event: signedEvent, accepted, failed, targets };
|
||||
}
|
||||
|
||||
getLastLoadSource() {
|
||||
return this.lastLoadSource;
|
||||
}
|
||||
}
|
||||
|
||||
export const relayManager = new RelayPreferencesManager();
|
||||
|
||||
export function normalizeRelayUrlForDisplay(value) {
|
||||
return normalizeRelayUrl(value);
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
// js/services/authService.js
|
||||
|
||||
import {
|
||||
setPubkey,
|
||||
setCurrentUserNpub,
|
||||
getPubkey,
|
||||
getCurrentUserNpub,
|
||||
} from "../state/appState.js";
|
||||
import {
|
||||
getSavedProfiles,
|
||||
getActiveProfilePubkey,
|
||||
setActiveProfilePubkey,
|
||||
mutateSavedProfiles,
|
||||
persistSavedProfiles,
|
||||
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
|
||||
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
|
||||
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
|
||||
persistProfileCacheToStorage as persistProfileCacheState,
|
||||
getProfileCacheEntry as getCachedProfileEntry,
|
||||
setProfileCacheEntry as setCachedProfileEntry,
|
||||
} from "../state/cache.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
this.logger(`AuthService listener for "${eventName}" threw`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const FALLBACK_PROFILE = {
|
||||
name: "Unknown",
|
||||
picture: "assets/svg/default-profile.svg",
|
||||
about: "",
|
||||
website: "",
|
||||
banner: "",
|
||||
lud16: "",
|
||||
lud06: "",
|
||||
};
|
||||
|
||||
const FAST_PROFILE_RELAY_LIMIT = 3;
|
||||
const FAST_PROFILE_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_PROFILE_TIMEOUT_MS = 6000;
|
||||
|
||||
export default class AuthService {
|
||||
constructor({ nostrClient, userBlocks, relayManager, logger } = {}) {
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.userBlocks = userBlocks || null;
|
||||
this.relayManager = relayManager || null;
|
||||
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
} else if (logger && typeof logger.log === "function") {
|
||||
this.logger = (...args) => logger.log(...args);
|
||||
} else {
|
||||
this.logger = () => {};
|
||||
}
|
||||
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.logger(message, error);
|
||||
} catch (logError) {
|
||||
console.warn("[AuthService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (error) {
|
||||
console.warn("[AuthService] logger threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
hydrateFromStorage() {
|
||||
hydrateProfileCacheFromStorage();
|
||||
const { profiles } = hydrateSavedProfilesFromStorage();
|
||||
if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) {
|
||||
const first = profiles[0]?.pubkey || null;
|
||||
if (first) {
|
||||
setActiveProfilePubkey(first, { persist: true });
|
||||
}
|
||||
}
|
||||
this.emitProfileList("hydrate");
|
||||
}
|
||||
|
||||
persistProfileCache() {
|
||||
persistProfileCacheState();
|
||||
}
|
||||
|
||||
getProfileCacheEntry(pubkey) {
|
||||
return getCachedProfileEntry(pubkey);
|
||||
}
|
||||
|
||||
setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) {
|
||||
const entry = setCachedProfileEntry(pubkey, profile, { persist });
|
||||
if (entry) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (normalized) {
|
||||
const updated = syncSavedProfileFromCacheState(normalized, {
|
||||
persist,
|
||||
});
|
||||
if (updated) {
|
||||
this.emitProfileList("cache-sync");
|
||||
}
|
||||
this.emit("profile:updated", {
|
||||
pubkey: normalized,
|
||||
profile: entry.profile || FALLBACK_PROFILE,
|
||||
reason,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
cloneSavedProfiles() {
|
||||
const saved = getSavedProfiles();
|
||||
return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : [];
|
||||
}
|
||||
|
||||
emitProfileList(reason = "update") {
|
||||
this.emit("profile:updated", {
|
||||
reason,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
}
|
||||
|
||||
safeEncodeNpub(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestLogin(options = {}) {
|
||||
if (!this.nostrClient || typeof this.nostrClient.login !== "function") {
|
||||
throw new Error("Nostr login is not available.");
|
||||
}
|
||||
|
||||
const result = await this.nostrClient.login(options);
|
||||
const pubkey =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result && typeof result === "object"
|
||||
? result.pubkey || result.publicKey || ""
|
||||
: "";
|
||||
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
if (!trimmed) {
|
||||
return { pubkey: null };
|
||||
}
|
||||
|
||||
if (options?.autoApply === false) {
|
||||
return { pubkey: trimmed };
|
||||
}
|
||||
|
||||
const detail = await this.login(trimmed, {
|
||||
persistActive: options?.persistActive !== false,
|
||||
});
|
||||
|
||||
return { pubkey: trimmed, detail };
|
||||
}
|
||||
|
||||
async handleUploadSubmit(payload, { publish } = {}) {
|
||||
if (!this.getActivePubkey()) {
|
||||
await this.requestLogin({ allowAccountSelection: true });
|
||||
if (!this.getActivePubkey()) {
|
||||
throw new Error("Login required to publish videos.");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof publish !== "function") {
|
||||
throw new Error("A publish callback must be provided to handleUploadSubmit.");
|
||||
}
|
||||
|
||||
return publish(payload);
|
||||
}
|
||||
|
||||
getActivePubkey() {
|
||||
return getPubkey();
|
||||
}
|
||||
|
||||
async login(pubkey, options = {}) {
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? options : { persistActive: true };
|
||||
const persistActive =
|
||||
normalizedOptions.persistActive === false ? false : true;
|
||||
|
||||
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
const nextPubkey = normalized || trimmed || null;
|
||||
if (!nextPubkey) {
|
||||
throw new Error("A valid pubkey is required for login.");
|
||||
}
|
||||
|
||||
const identityChanged = previousPubkey !== nextPubkey;
|
||||
|
||||
if (normalized) {
|
||||
setPubkey(normalized);
|
||||
if (this.nostrClient && typeof this.nostrClient === "object") {
|
||||
this.nostrClient.pubkey = normalized;
|
||||
}
|
||||
} else {
|
||||
setPubkey(nextPubkey);
|
||||
if (this.nostrClient && typeof this.nostrClient === "object") {
|
||||
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(nextPubkey);
|
||||
setCurrentUserNpub(npub);
|
||||
|
||||
let savedProfilesMutated = false;
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const draft = Array.isArray(profiles) ? profiles.slice() : [];
|
||||
const existingIndex = draft.findIndex((entry) => {
|
||||
const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey);
|
||||
return normalizedEntry && normalizedEntry === normalized;
|
||||
});
|
||||
|
||||
const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey);
|
||||
const cachedProfile = cacheEntry?.profile || {};
|
||||
|
||||
const nextEntry = {
|
||||
pubkey: normalized || nextPubkey,
|
||||
npub: npub || (cachedProfile.npub ?? null),
|
||||
name: cachedProfile.name || draft[existingIndex]?.name || "",
|
||||
picture: cachedProfile.picture || draft[existingIndex]?.picture || "",
|
||||
authType: "nip07",
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const currentEntry = draft[existingIndex] || {};
|
||||
const changed =
|
||||
currentEntry.npub !== nextEntry.npub ||
|
||||
currentEntry.name !== nextEntry.name ||
|
||||
currentEntry.picture !== nextEntry.picture ||
|
||||
currentEntry.authType !== nextEntry.authType;
|
||||
if (changed) {
|
||||
draft[existingIndex] = nextEntry;
|
||||
savedProfilesMutated = true;
|
||||
}
|
||||
} else {
|
||||
draft.push(nextEntry);
|
||||
savedProfilesMutated = true;
|
||||
}
|
||||
|
||||
return draft;
|
||||
}, { persist: false, persistActive: false });
|
||||
|
||||
if (persistActive) {
|
||||
setActiveProfilePubkey(normalized || nextPubkey, { persist: true });
|
||||
}
|
||||
|
||||
if (!persistActive && savedProfilesMutated) {
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
} else if (persistActive || savedProfilesMutated) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
|
||||
if (savedProfilesMutated) {
|
||||
this.emitProfileList("login");
|
||||
}
|
||||
|
||||
const activeLoginPubkey = getPubkey();
|
||||
const normalizedLoginPubkey =
|
||||
this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null;
|
||||
|
||||
const detail = {
|
||||
pubkey: activeLoginPubkey,
|
||||
npub: getCurrentUserNpub(),
|
||||
previousPubkey: previousPubkey || null,
|
||||
identityChanged,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
postLogin: {
|
||||
pubkey: normalizedLoginPubkey,
|
||||
blocksLoaded: false,
|
||||
relaysLoaded: false,
|
||||
profile: null,
|
||||
},
|
||||
postLoginPromise: null,
|
||||
postLoginError: null,
|
||||
};
|
||||
|
||||
const postLoginPromise = Promise.resolve()
|
||||
.then(() => this.applyPostLoginState())
|
||||
.then((postLogin) => {
|
||||
detail.postLogin = postLogin;
|
||||
detail.postLoginError = null;
|
||||
|
||||
try {
|
||||
this.emitProfileList("post-login");
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to emit profile list after login", error);
|
||||
}
|
||||
|
||||
this.emit("auth:post-login", {
|
||||
...detail,
|
||||
postLogin,
|
||||
postLoginError: null,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
|
||||
return postLogin;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.log("[AuthService] applyPostLoginState failed", error);
|
||||
detail.postLoginError = error;
|
||||
|
||||
this.emit("auth:post-login", {
|
||||
...detail,
|
||||
postLogin: detail.postLogin,
|
||||
postLoginError: error,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
detail.postLoginPromise = postLoginPromise;
|
||||
|
||||
try {
|
||||
Object.defineProperty(detail, "__handled", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore descriptor errors (e.g., frozen objects) and fall back to direct assignment.
|
||||
detail.__handled = false;
|
||||
}
|
||||
|
||||
this.emit("auth:login", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async applyPostLoginState() {
|
||||
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
const detail = {
|
||||
pubkey: activePubkey || null,
|
||||
blocksLoaded: false,
|
||||
relaysLoaded: false,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
if (!activePubkey) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const schedule = (callback) => Promise.resolve().then(() => callback());
|
||||
|
||||
const operations = [];
|
||||
|
||||
if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") {
|
||||
operations.push({
|
||||
name: "blocksLoaded",
|
||||
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey)),
|
||||
onFulfilled: () => true,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load block list", error);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
|
||||
operations.push({
|
||||
name: "relaysLoaded",
|
||||
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
|
||||
onFulfilled: () => true,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load relay list", error);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
operations.push({
|
||||
name: "profile",
|
||||
promise: schedule(() => this.loadOwnProfile(activePubkey)),
|
||||
onFulfilled: (value) => value,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load own profile", error);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
operations.map((operation) => operation.promise)
|
||||
);
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
const operation = operations[index];
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "fulfilled") {
|
||||
detail[operation.name] = operation.onFulfilled(result.value);
|
||||
} else {
|
||||
detail[operation.name] = operation.onRejected(result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.nostrClient && typeof this.nostrClient.logout === "function") {
|
||||
try {
|
||||
this.nostrClient.logout();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] nostrClient.logout threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
|
||||
setPubkey(null);
|
||||
setCurrentUserNpub(null);
|
||||
setActiveProfilePubkey(null, { persist: true });
|
||||
|
||||
if (this.userBlocks && typeof this.userBlocks.reset === "function") {
|
||||
try {
|
||||
this.userBlocks.reset();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] userBlocks.reset threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.relayManager && typeof this.relayManager.reset === "function") {
|
||||
try {
|
||||
this.relayManager.reset();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] relayManager.reset threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
|
||||
const detail = {
|
||||
previousPubkey: previousPubkey || null,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
};
|
||||
|
||||
this.emit("auth:logout", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
removeSavedProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return { removed: false };
|
||||
}
|
||||
|
||||
const { changed } = mutateSavedProfiles((profiles) =>
|
||||
profiles.filter((entry) => entry?.pubkey !== normalized),
|
||||
{
|
||||
persist: true,
|
||||
persistActive: true,
|
||||
});
|
||||
|
||||
if (changed && getActiveProfilePubkey() === normalized) {
|
||||
setActiveProfilePubkey(null, { persist: true });
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.emitProfileList("remove-saved-profile");
|
||||
}
|
||||
|
||||
return { removed: changed };
|
||||
}
|
||||
|
||||
async switchProfile(pubkey) {
|
||||
const normalizedTarget = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalizedTarget) {
|
||||
throw new Error("Unable to switch profiles: invalid account.");
|
||||
}
|
||||
|
||||
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
|
||||
if (normalizedActive && normalizedActive === normalizedTarget) {
|
||||
return { switched: false, reason: "already-active" };
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
allowAccountSelection: true,
|
||||
expectPubkey: normalizedTarget,
|
||||
persistActive: true,
|
||||
};
|
||||
const autoApply = requestOptions.autoApply !== false;
|
||||
|
||||
let requestResult;
|
||||
try {
|
||||
requestResult = await this.requestLogin(requestOptions);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const requestedPubkey =
|
||||
this.normalizeHexPubkey(requestResult?.pubkey) ||
|
||||
(typeof requestResult?.pubkey === "string"
|
||||
? requestResult.pubkey.trim()
|
||||
: "");
|
||||
|
||||
let detail = requestResult?.detail || null;
|
||||
|
||||
if (!detail && !autoApply) {
|
||||
const fallbackTarget =
|
||||
this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget;
|
||||
detail = await this.login(fallbackTarget, { persistActive: true });
|
||||
}
|
||||
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const draft = profiles.slice();
|
||||
const index = draft.findIndex(
|
||||
(entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget
|
||||
);
|
||||
if (index > 0) {
|
||||
const [moved] = draft.splice(index, 1);
|
||||
draft.unshift(moved);
|
||||
}
|
||||
return draft;
|
||||
}, { persist: true, persistActive: true });
|
||||
|
||||
const savedProfiles = this.cloneSavedProfiles();
|
||||
const activeProfilePubkey = getActiveProfilePubkey();
|
||||
|
||||
if (detail && typeof detail === "object") {
|
||||
detail.savedProfiles = savedProfiles;
|
||||
detail.activeProfilePubkey = activeProfilePubkey;
|
||||
}
|
||||
|
||||
this.emitProfileList("switch-profile");
|
||||
|
||||
return { switched: true, detail };
|
||||
}
|
||||
|
||||
async loadOwnProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
const relays = Array.isArray(this.nostrClient.relays)
|
||||
? this.nostrClient.relays.filter((url) => typeof url === "string" && url)
|
||||
: [];
|
||||
|
||||
if (!relays.length) {
|
||||
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fallback",
|
||||
});
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
const filter = [{ kinds: [0], authors: [normalized], limit: 1 }];
|
||||
|
||||
const selectNewestEvent = (eventList) => {
|
||||
if (!Array.isArray(eventList) || !eventList.length) {
|
||||
return null;
|
||||
}
|
||||
const sorted = eventList
|
||||
.filter((event) => event && event.pubkey === normalized)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
return sorted.length ? sorted[0] : null;
|
||||
};
|
||||
|
||||
const buildProfileFromEvent = (event) => {
|
||||
if (!event?.content) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(event.content);
|
||||
return {
|
||||
name: data.display_name || data.name || FALLBACK_PROFILE.name,
|
||||
picture: data.picture || FALLBACK_PROFILE.picture,
|
||||
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
||||
website:
|
||||
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
|
||||
banner:
|
||||
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
|
||||
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
|
||||
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to parse profile metadata", error);
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.nostrClient.pool.list([relayUrl], filter))
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalized)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No profile events returned from ${relayUrl}`
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
reject(emptyError);
|
||||
return;
|
||||
}
|
||||
resolve({ relayUrl, events });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
reject(wrapped);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
|
||||
const backgroundRelays = relays.slice(fastRelays.length);
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
|
||||
);
|
||||
|
||||
const background = Promise.allSettled([
|
||||
...fastPromises,
|
||||
...backgroundPromises,
|
||||
])
|
||||
.then((outcomes) => {
|
||||
const aggregated = [];
|
||||
outcomes.forEach((outcome) => {
|
||||
if (outcome.status === "fulfilled") {
|
||||
const events = Array.isArray(outcome.value?.events)
|
||||
? outcome.value.events
|
||||
: [];
|
||||
if (events.length) {
|
||||
aggregated.push(...events);
|
||||
}
|
||||
} else if (outcome.status === "rejected") {
|
||||
const reason = outcome.reason;
|
||||
if (reason?.code === "timeout") {
|
||||
this.log(
|
||||
`[AuthService] Relay ${reason.relay} timed out loading profile (background)`
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`,
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newest = selectNewestEvent(aggregated);
|
||||
if (!newest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = buildProfileFromEvent(newest);
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:background",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.log("[AuthService] Background profile refresh failed", error);
|
||||
});
|
||||
|
||||
let fastResult = null;
|
||||
if (fastPromises.length) {
|
||||
try {
|
||||
fastResult = await Promise.any(fastPromises);
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors?.forEach((err) => {
|
||||
if (err?.code === "timeout") {
|
||||
this.log(
|
||||
`[AuthService] Relay ${err.relay} timed out loading profile`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log("[AuthService] Failed to load profile from fast relays", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fastResult?.events?.length) {
|
||||
const newest = selectNewestEvent(fastResult.events);
|
||||
if (newest) {
|
||||
const profile = buildProfileFromEvent(newest);
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fast",
|
||||
});
|
||||
background.catch(() => {});
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fallback",
|
||||
});
|
||||
|
||||
background.catch(() => {});
|
||||
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
async fetchProfile(pubkey, { forceRefresh = false } = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheEntry = this.getProfileCacheEntry(normalized);
|
||||
if (cacheEntry && !forceRefresh) {
|
||||
this.emit("profile:updated", {
|
||||
pubkey: normalized,
|
||||
profile: cacheEntry.profile || FALLBACK_PROFILE,
|
||||
reason: "cache-hit",
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
return cacheEntry.profile;
|
||||
}
|
||||
|
||||
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return cacheEntry?.profile || FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
this.nostrClient.relays.map((relayUrl) =>
|
||||
this.nostrClient.pool.list([relayUrl], [
|
||||
{ kinds: [0], authors: [normalized], limit: 1 },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const events = results.flat();
|
||||
let newest = null;
|
||||
for (const event of events) {
|
||||
if (!event || event.pubkey !== normalized || !event.content) {
|
||||
continue;
|
||||
}
|
||||
if (!newest || event.created_at > newest.created_at) {
|
||||
newest = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (newest?.content) {
|
||||
const data = JSON.parse(newest.content);
|
||||
const profile = {
|
||||
name: data.display_name || data.name || FALLBACK_PROFILE.name,
|
||||
picture: data.picture || FALLBACK_PROFILE.picture,
|
||||
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
||||
website:
|
||||
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
|
||||
banner:
|
||||
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
|
||||
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
|
||||
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
|
||||
};
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: forceRefresh ? "force-refresh" : "fetch-profile",
|
||||
});
|
||||
return profile;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to fetch profile", error);
|
||||
}
|
||||
|
||||
return cacheEntry?.profile || FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
||||
return this.fetchProfile(pubkey, { forceRefresh });
|
||||
}
|
||||
|
||||
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated = syncSavedProfileFromCacheState(normalized, { persist });
|
||||
if (updated) {
|
||||
this.emitProfileList("cache-sync");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
loadProfileCacheFromStorage() {
|
||||
hydrateProfileCacheFromStorage();
|
||||
}
|
||||
|
||||
loadSavedProfilesFromStorage() {
|
||||
const result = hydrateSavedProfilesFromStorage();
|
||||
this.emitProfileList("hydrate");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
|
||||
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export default class DiscussionCountService {
|
||||
constructor({
|
||||
maxVideos = DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS,
|
||||
logger = typeof console !== "undefined" ? console : null,
|
||||
} = {}) {
|
||||
this.maxVideos = toPositiveInteger(maxVideos, DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS);
|
||||
this.logger = logger;
|
||||
this.videoDiscussionCountCache = new Map();
|
||||
this.inFlightDiscussionCounts = new Map();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.videoDiscussionCountCache.clear();
|
||||
this.inFlightDiscussionCounts.clear();
|
||||
}
|
||||
|
||||
getCachedCount(videoId) {
|
||||
if (!videoId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.videoDiscussionCountCache.get(videoId);
|
||||
}
|
||||
|
||||
refreshCounts(videos = [], options = {}) {
|
||||
const { videoListRoot = null, nostrClient = null } = options;
|
||||
|
||||
if (!Array.isArray(videos) || !videos.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root =
|
||||
videoListRoot && typeof videoListRoot.querySelector === "function"
|
||||
? videoListRoot
|
||||
: null;
|
||||
if (!root || !nostrClient?.pool || typeof nostrClient.countEventsAcrossRelays !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleVideos = videos
|
||||
.filter(
|
||||
(video) =>
|
||||
video &&
|
||||
typeof video.id === "string" &&
|
||||
video.id &&
|
||||
video.enableComments !== false
|
||||
)
|
||||
.slice(0, this.maxVideos);
|
||||
|
||||
eligibleVideos.forEach((video) => {
|
||||
const container = root.querySelector(`[data-discussion-count="${video.id}"]`);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedCount = this.getCachedCount(video.id);
|
||||
if (typeof cachedCount === "number") {
|
||||
this.updateDiscussionCountElement(container, cachedCount);
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = this.buildDiscussionCountFilters(video);
|
||||
if (!filters.length) {
|
||||
this.markDiscussionCountError(container, { unsupported: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inFlightDiscussionCounts.has(video.id)) {
|
||||
this.setDiscussionCountPending(container);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setDiscussionCountPending(container);
|
||||
|
||||
const request = Promise.resolve()
|
||||
.then(() => nostrClient.countEventsAcrossRelays(filters))
|
||||
.then((result) => {
|
||||
const perRelay = Array.isArray(result?.perRelay)
|
||||
? result.perRelay.filter((entry) => entry && entry.ok)
|
||||
: [];
|
||||
|
||||
if (!perRelay.length) {
|
||||
this.markDiscussionCountError(container, { unsupported: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
const total = Number(result?.total);
|
||||
const normalized =
|
||||
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
|
||||
this.videoDiscussionCountCache.set(video.id, normalized);
|
||||
this.updateDiscussionCountElement(container, normalized);
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode && this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
`[counts] Failed to fetch discussion count for ${video.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
this.markDiscussionCountError(container);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlightDiscussionCounts.delete(video.id);
|
||||
});
|
||||
|
||||
this.inFlightDiscussionCounts.set(video.id, request);
|
||||
});
|
||||
}
|
||||
|
||||
buildDiscussionCountFilters(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filters = [];
|
||||
const eventId = typeof video.id === "string" ? video.id.trim() : "";
|
||||
if (eventId) {
|
||||
filters.push({ kinds: [1], "#e": [eventId] });
|
||||
}
|
||||
|
||||
const address = this.getVideoAddressPointer(video);
|
||||
if (address) {
|
||||
filters.push({ kinds: [1], "#a": [address] });
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
getVideoAddressPointer(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||||
const dTag = tags.find(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag.length >= 2 &&
|
||||
tag[0] === "d" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].trim()
|
||||
);
|
||||
|
||||
if (!dTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
if (!pubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const identifier = dTag[1].trim();
|
||||
if (!identifier) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const kind =
|
||||
Number.isFinite(video.kind) && video.kind > 0
|
||||
? Math.floor(video.kind)
|
||||
: 30078;
|
||||
|
||||
return `${kind}:${pubkey}:${identifier}`;
|
||||
}
|
||||
|
||||
setDiscussionCountPending(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.countState = "pending";
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (valueEl) {
|
||||
valueEl.textContent = "…";
|
||||
}
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
updateDiscussionCountElement(element, count) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (!valueEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = Number(count);
|
||||
const safeValue =
|
||||
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||||
element.dataset.countState = "ready";
|
||||
valueEl.textContent = safeValue.toLocaleString();
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
markDiscussionCountError(element, { unsupported = false } = {}) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (valueEl) {
|
||||
valueEl.textContent = "—";
|
||||
}
|
||||
|
||||
element.dataset.countState = unsupported ? "unsupported" : "error";
|
||||
|
||||
if (unsupported) {
|
||||
element.title = COUNT_UNSUPPORTED_TITLE;
|
||||
} else {
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { COUNT_UNSUPPORTED_TITLE };
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo,
|
||||
} from "../nostr.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
getVideoSubscription as getStoredVideoSubscription,
|
||||
setVideoSubscription as setStoredVideoSubscription,
|
||||
} from "../state/appState.js";
|
||||
|
||||
const VIDEO_KIND = 30078;
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
try {
|
||||
this.logger(`nostrService listener for "${eventName}" threw`, error);
|
||||
} catch (logError) {
|
||||
console.warn("[nostrService] listener logger threw", logError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLogger(logger) {
|
||||
if (typeof logger === "function") {
|
||||
return logger;
|
||||
}
|
||||
if (logger && typeof logger.log === "function") {
|
||||
return (...args) => logger.log(...args);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function ensureSet(value) {
|
||||
if (value instanceof Set) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return new Set(value);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeUntil(timestamp) {
|
||||
const value = Number(timestamp);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const seconds = Math.floor(value);
|
||||
return seconds > 0 ? seconds : 0;
|
||||
}
|
||||
|
||||
export class NostrService {
|
||||
constructor({ logger } = {}) {
|
||||
this.nostrClient = nostrClient;
|
||||
this.accessControl = accessControl;
|
||||
this.logger = normalizeLogger(logger);
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.logger(message, error);
|
||||
} catch (logError) {
|
||||
console.warn("[nostrService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
this.videosMap = null;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] logger threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
ensureVideosMap() {
|
||||
if (this.videosMap instanceof Map) {
|
||||
return this.videosMap;
|
||||
}
|
||||
|
||||
let map = getStoredVideosMap();
|
||||
if (!(map instanceof Map)) {
|
||||
map = new Map();
|
||||
setStoredVideosMap(map);
|
||||
}
|
||||
|
||||
this.videosMap = map;
|
||||
return this.videosMap;
|
||||
}
|
||||
|
||||
getVideosMap() {
|
||||
return this.ensureVideosMap();
|
||||
}
|
||||
|
||||
getVideoSubscription() {
|
||||
return getStoredVideoSubscription();
|
||||
}
|
||||
|
||||
setVideoSubscription(subscription) {
|
||||
setStoredVideoSubscription(subscription || null);
|
||||
this.emit("subscription:changed", { subscription: subscription || null });
|
||||
}
|
||||
|
||||
clearVideoSubscription() {
|
||||
const current = getStoredVideoSubscription();
|
||||
if (current && typeof current.unsub === "function") {
|
||||
try {
|
||||
current.unsub();
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] Failed to unsubscribe from video feed:", error);
|
||||
}
|
||||
}
|
||||
this.setVideoSubscription(null);
|
||||
}
|
||||
|
||||
cacheVideos(videos = []) {
|
||||
if (!Array.isArray(videos) || !videos.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = this.ensureVideosMap();
|
||||
for (const video of videos) {
|
||||
if (video && typeof video.id === "string" && video.id) {
|
||||
map.set(video.id, video);
|
||||
}
|
||||
}
|
||||
setStoredVideosMap(map);
|
||||
this.emit("videos:cache", { size: map.size });
|
||||
}
|
||||
|
||||
shouldIncludeVideo(video, {
|
||||
blacklistedEventIds = new Set(),
|
||||
isAuthorBlocked = () => false,
|
||||
} = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blacklistedEventIds.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof video.pubkey === "string" && video.pubkey) {
|
||||
try {
|
||||
if (isAuthorBlocked(video.pubkey)) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] isAuthorBlocked handler threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (video.isPrivate === true) {
|
||||
const normalizedVideoPubkey = normalizeHexPubkey(video.pubkey);
|
||||
const normalizedViewerPubkey = normalizeHexPubkey(
|
||||
this.nostrClient?.pubkey
|
||||
);
|
||||
|
||||
if (
|
||||
!normalizedViewerPubkey ||
|
||||
!normalizedVideoPubkey ||
|
||||
normalizedVideoPubkey !== normalizedViewerPubkey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.accessControl && typeof this.accessControl.canAccess === "function") {
|
||||
try {
|
||||
if (!this.accessControl.canAccess(video)) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] access control check failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
filterVideos(videos = [], options = {}) {
|
||||
const blacklist = ensureSet(options.blacklistedEventIds);
|
||||
const isAuthorBlocked =
|
||||
typeof options.isAuthorBlocked === "function"
|
||||
? options.isAuthorBlocked
|
||||
: () => false;
|
||||
|
||||
return videos.filter((video) =>
|
||||
this.shouldIncludeVideo(video, {
|
||||
blacklistedEventIds: blacklist,
|
||||
isAuthorBlocked,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async ensureAccessControlReady() {
|
||||
if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[nostrService] Failed to ensure access control lists are ready:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredActiveVideos(options = {}) {
|
||||
const all = this.nostrClient.getActiveVideos();
|
||||
return this.filterVideos(all, options);
|
||||
}
|
||||
|
||||
async loadVideos({
|
||||
forceFetch = false,
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
onVideos,
|
||||
} = {}) {
|
||||
await this.ensureAccessControlReady();
|
||||
|
||||
if (forceFetch) {
|
||||
this.clearVideoSubscription();
|
||||
}
|
||||
|
||||
const applyAndNotify = (videos, reason) => {
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
this.cacheVideos(filtered);
|
||||
if (typeof onVideos === "function") {
|
||||
try {
|
||||
onVideos(filtered, { reason });
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] onVideos handler threw", error);
|
||||
}
|
||||
}
|
||||
this.emit("videos:updated", { videos: filtered, reason });
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const cached = this.nostrClient.getActiveVideos();
|
||||
const initial = applyAndNotify(cached, "cache");
|
||||
|
||||
if (!getStoredVideoSubscription()) {
|
||||
const subscription = this.nostrClient.subscribeVideos(() => {
|
||||
const updated = this.nostrClient.getActiveVideos();
|
||||
applyAndNotify(updated, "subscription");
|
||||
});
|
||||
this.setVideoSubscription(subscription);
|
||||
this.emit("subscription:started", { subscription });
|
||||
}
|
||||
|
||||
return initial;
|
||||
}
|
||||
|
||||
async fetchVideos(options = {}) {
|
||||
try {
|
||||
const videos = await this.nostrClient.fetchVideos();
|
||||
const filtered = this.filterVideos(videos, options);
|
||||
this.cacheVideos(filtered);
|
||||
this.emit("videos:fetched", { videos: filtered });
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
console.error("[nostrService] Failed to fetch videos:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async loadOlderVideos(lastTimestamp, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
limit = 150,
|
||||
} = {}) {
|
||||
const until = normalizeUntil(lastTimestamp) - 1;
|
||||
if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [VIDEO_KIND],
|
||||
"#t": ["video"],
|
||||
until,
|
||||
limit,
|
||||
};
|
||||
|
||||
const collected = new Map();
|
||||
|
||||
try {
|
||||
const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]);
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
try {
|
||||
const video = convertEventToVideo(event);
|
||||
if (video.invalid) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
this.nostrClient &&
|
||||
typeof this.nostrClient.applyRootCreatedAt === "function"
|
||||
) {
|
||||
this.nostrClient.applyRootCreatedAt(video);
|
||||
}
|
||||
if (collected.has(video.id)) {
|
||||
continue;
|
||||
}
|
||||
collected.set(video.id, video);
|
||||
this.nostrClient.allEvents.set(video.id, video);
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] Failed to convert older event", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[nostrService] Failed to load older videos:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const videos = Array.from(collected.values());
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
|
||||
this.cacheVideos(filtered);
|
||||
this.emit("videos:older", { videos: filtered, until });
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async publishVideoNote(publishPayload, pubkey) {
|
||||
const detail = { pubkey };
|
||||
if (publishPayload && typeof publishPayload === "object") {
|
||||
detail.payload = publishPayload;
|
||||
if (publishPayload.legacyFormData) {
|
||||
detail.legacyFormData = publishPayload.legacyFormData;
|
||||
}
|
||||
if (publishPayload.nip71) {
|
||||
detail.nip71 = publishPayload.nip71;
|
||||
}
|
||||
} else {
|
||||
detail.formData = publishPayload;
|
||||
}
|
||||
|
||||
let nip71Result = null;
|
||||
let legacyResult = null;
|
||||
|
||||
try {
|
||||
legacyResult = await this.nostrClient.publishVideo(
|
||||
publishPayload,
|
||||
pubkey
|
||||
);
|
||||
} catch (error) {
|
||||
detail.error = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let shouldAttemptNip71 = true;
|
||||
const pointerIdentifiers = {
|
||||
eventId: legacyResult?.id,
|
||||
};
|
||||
|
||||
if (Array.isArray(legacyResult?.tags)) {
|
||||
const dTag = legacyResult.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string"
|
||||
);
|
||||
if (dTag) {
|
||||
pointerIdentifiers.dTag = dTag[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyResult?.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(legacyResult.content);
|
||||
if (parsed && typeof parsed.videoRootId === "string") {
|
||||
pointerIdentifiers.videoRootId = parsed.videoRootId;
|
||||
}
|
||||
if (parsed && parsed.isPrivate) {
|
||||
shouldAttemptNip71 = false;
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.log("[nostrService] Failed to parse legacy publish payload", parseError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pointerIdentifiers.videoRootId) {
|
||||
shouldAttemptNip71 = false;
|
||||
}
|
||||
|
||||
if (shouldAttemptNip71) {
|
||||
try {
|
||||
nip71Result = await this.nostrClient.publishNip71Video(
|
||||
publishPayload,
|
||||
pubkey,
|
||||
pointerIdentifiers
|
||||
);
|
||||
} catch (nip71Error) {
|
||||
detail.nip71Error = nip71Error;
|
||||
this.log("[nostrService] NIP-71 publish failed", nip71Error);
|
||||
}
|
||||
}
|
||||
|
||||
const result = { legacy: legacyResult, nip71: nip71Result };
|
||||
detail.result = result;
|
||||
this.emit("videos:published", detail);
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) {
|
||||
const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey);
|
||||
this.emit("videos:edited", { originalEvent, updatedData, pubkey, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleFullDeleteVideo({ videoRootId, pubkey, confirm = true } = {}) {
|
||||
const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, {
|
||||
confirm,
|
||||
});
|
||||
if (result) {
|
||||
this.emit("videos:deleted", { videoRootId, pubkey });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getOldEventById(eventId) {
|
||||
const map = this.ensureVideosMap();
|
||||
if (map.has(eventId)) {
|
||||
return map.get(eventId);
|
||||
}
|
||||
|
||||
const cached = this.nostrClient.allEvents.get(eventId);
|
||||
if (cached && !cached.deleted) {
|
||||
map.set(eventId, cached);
|
||||
setStoredVideosMap(map);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fetched = await this.nostrClient.getEventById(eventId);
|
||||
if (fetched && !fetched.deleted) {
|
||||
map.set(eventId, fetched);
|
||||
setStoredVideosMap(map);
|
||||
return fetched;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrService = new NostrService();
|
||||
|
||||
export default nostrService;
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
loadNwcSettings,
|
||||
saveNwcSettings,
|
||||
clearNwcSettings,
|
||||
createDefaultNwcSettings,
|
||||
} from "../nwcSettings.js";
|
||||
import { MAX_WALLET_DEFAULT_ZAP } from "../config.js";
|
||||
|
||||
const NWC_URI_SCHEME = "nostr+walletconnect://";
|
||||
|
||||
function isFunction(value) {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
export default class NwcSettingsService {
|
||||
constructor({
|
||||
normalizeHexPubkey,
|
||||
getActivePubkey,
|
||||
payments = null,
|
||||
logger = console,
|
||||
notifyError,
|
||||
maxWalletDefaultZap = MAX_WALLET_DEFAULT_ZAP,
|
||||
loadSettings = loadNwcSettings,
|
||||
saveSettings = saveNwcSettings,
|
||||
clearSettings = clearNwcSettings,
|
||||
createDefaultSettings = createDefaultNwcSettings,
|
||||
} = {}) {
|
||||
this.normalizeHexPubkey = isFunction(normalizeHexPubkey)
|
||||
? normalizeHexPubkey
|
||||
: (value) => (typeof value === "string" ? value.trim() : null);
|
||||
this.getActivePubkey = isFunction(getActivePubkey)
|
||||
? getActivePubkey
|
||||
: () => null;
|
||||
this.payments = payments || null;
|
||||
this.logger = logger || console;
|
||||
this.notifyError = isFunction(notifyError) ? notifyError : null;
|
||||
this.maxWalletDefaultZap =
|
||||
Number.isFinite(maxWalletDefaultZap) && maxWalletDefaultZap > 0
|
||||
? maxWalletDefaultZap
|
||||
: MAX_WALLET_DEFAULT_ZAP;
|
||||
this.loadSettings = isFunction(loadSettings)
|
||||
? loadSettings
|
||||
: loadNwcSettings;
|
||||
this.saveSettings = isFunction(saveSettings)
|
||||
? saveSettings
|
||||
: saveNwcSettings;
|
||||
this.clearSettings = isFunction(clearSettings)
|
||||
? clearSettings
|
||||
: clearNwcSettings;
|
||||
this.createDefaultSettings = isFunction(createDefaultSettings)
|
||||
? createDefaultSettings
|
||||
: () => createDefaultNwcSettings();
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
cloneSettings(settings) {
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
setPayments(payments) {
|
||||
this.payments = payments || null;
|
||||
}
|
||||
|
||||
setActivePubkeyGetter(fn) {
|
||||
if (isFunction(fn)) {
|
||||
this.getActivePubkey = fn;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
getNormalizedPubkey(pubkey) {
|
||||
try {
|
||||
return this.normalizeHexPubkey(pubkey);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn("[nwcSettings] Failed to normalize pubkey:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateNwcSettingsForPubkey(pubkey) {
|
||||
const normalized = this.getNormalizedPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.loadSettings(normalized);
|
||||
const record =
|
||||
settings && typeof settings === "object"
|
||||
? { ...settings }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to load settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const fallback = this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, fallback);
|
||||
return this.cloneSettings(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveNwcSettings() {
|
||||
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
|
||||
if (!normalized) {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
createDefaultNwcSettings() {
|
||||
return this.createDefaultSettings();
|
||||
}
|
||||
|
||||
hasActiveWalletConnection() {
|
||||
const settings = this.getActiveNwcSettings();
|
||||
const candidate =
|
||||
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
||||
return candidate.length > 0;
|
||||
}
|
||||
|
||||
validateWalletUri(uri, { requireValue = false } = {}) {
|
||||
const value = typeof uri === "string" ? uri.trim() : "";
|
||||
|
||||
if (!value) {
|
||||
if (requireValue) {
|
||||
return {
|
||||
valid: false,
|
||||
sanitized: "",
|
||||
message: "Enter a wallet connect URI before continuing.",
|
||||
};
|
||||
}
|
||||
return { valid: true, sanitized: "" };
|
||||
}
|
||||
|
||||
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
|
||||
return {
|
||||
valid: false,
|
||||
sanitized: value,
|
||||
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, sanitized: value };
|
||||
}
|
||||
|
||||
async updateActiveNwcSettings(partial = {}) {
|
||||
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
|
||||
if (!normalized) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
"[nwcSettings] Cannot update settings without an active pubkey.",
|
||||
);
|
||||
}
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await this.saveSettings(normalized, partial);
|
||||
const record =
|
||||
updated && typeof updated === "object"
|
||||
? { ...updated }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to save settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async handleProfileWalletPersist({
|
||||
nwcUri,
|
||||
defaultZap,
|
||||
lastChecked,
|
||||
} = {}) {
|
||||
const partial = {};
|
||||
if (nwcUri !== undefined) {
|
||||
partial.nwcUri = nwcUri;
|
||||
}
|
||||
if (defaultZap !== undefined) {
|
||||
partial.defaultZap = defaultZap;
|
||||
}
|
||||
if (lastChecked !== undefined) {
|
||||
partial.lastChecked = lastChecked;
|
||||
}
|
||||
|
||||
if (!Object.keys(partial).length) {
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
|
||||
return this.updateActiveNwcSettings(partial);
|
||||
}
|
||||
|
||||
async ensureWallet({ nwcUri, defaultZap } = {}) {
|
||||
const activeSettings = this.getActiveNwcSettings();
|
||||
const candidateUri =
|
||||
typeof nwcUri === "string" && nwcUri.trim()
|
||||
? nwcUri.trim()
|
||||
: activeSettings.nwcUri || "";
|
||||
const { valid, sanitized, message } = this.validateWalletUri(candidateUri, {
|
||||
requireValue: true,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new Error(message || "Invalid wallet URI provided.");
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...activeSettings,
|
||||
nwcUri: sanitized,
|
||||
};
|
||||
|
||||
if (typeof defaultZap === "number" && Number.isFinite(defaultZap)) {
|
||||
const rounded = Math.max(0, Math.round(defaultZap));
|
||||
merged.defaultZap = Math.min(this.maxWalletDefaultZap, rounded);
|
||||
} else if (defaultZap === null) {
|
||||
merged.defaultZap = null;
|
||||
}
|
||||
|
||||
if (this.payments && isFunction(this.payments.ensureWallet)) {
|
||||
return this.payments.ensureWallet({ settings: merged });
|
||||
}
|
||||
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
"[wallet] Falling back to stub ensureWallet implementation. Returning settings without performing a connection test.",
|
||||
);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async clearStoredNwcSettings(pubkey, { silent = false } = {}) {
|
||||
const normalized = this.getNormalizedPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.clearSettings(normalized);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to clear settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!silent && this.notifyError) {
|
||||
this.notifyError("Failed to clear wallet settings for this account.");
|
||||
}
|
||||
try {
|
||||
await this.saveSettings(normalized, this.createDefaultNwcSettings());
|
||||
} catch (persistError) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to overwrite settings for ${normalized}:`,
|
||||
persistError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.cache.delete(normalized);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cache.delete(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
async onLogin({ pubkey, previousPubkey, identityChanged } = {}) {
|
||||
if (identityChanged) {
|
||||
this.onIdentityChanged();
|
||||
}
|
||||
|
||||
const normalizedActive = this.getNormalizedPubkey(
|
||||
pubkey !== undefined ? pubkey : this.getActivePubkey(),
|
||||
);
|
||||
const normalizedPrevious = this.getNormalizedPubkey(previousPubkey);
|
||||
|
||||
if (
|
||||
normalizedPrevious &&
|
||||
(!normalizedActive || normalizedPrevious !== normalizedActive)
|
||||
) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: true });
|
||||
}
|
||||
|
||||
if (normalizedActive) {
|
||||
await this.hydrateNwcSettingsForPubkey(normalizedActive);
|
||||
}
|
||||
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
|
||||
async onLogout({ pubkey, previousPubkey } = {}) {
|
||||
const normalizedPrevious = this.getNormalizedPubkey(
|
||||
previousPubkey !== undefined ? previousPubkey : pubkey,
|
||||
);
|
||||
if (normalizedPrevious) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: false });
|
||||
}
|
||||
this.clearCache();
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
onIdentityChanged() {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
// js/services/playbackService.js
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (err) {
|
||||
if (this.logger) {
|
||||
this.logger("[PlaybackService] Listener error", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
|
||||
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
|
||||
"This magnet link is missing a compatible BitTorrent v1 info hash.";
|
||||
|
||||
export class PlaybackService {
|
||||
constructor({
|
||||
logger,
|
||||
torrentClient,
|
||||
deriveTorrentPlaybackConfig,
|
||||
isValidMagnetUri,
|
||||
urlFirstEnabled = true,
|
||||
analyticsCallbacks = {},
|
||||
} = {}) {
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
} else if (logger && typeof logger.log === "function") {
|
||||
this.logger = (...args) => logger.log(...args);
|
||||
} else {
|
||||
this.logger = () => {};
|
||||
}
|
||||
|
||||
this.torrentClient = torrentClient;
|
||||
this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig;
|
||||
this.isValidMagnetUri = isValidMagnetUri;
|
||||
this.urlFirstEnabled = !!urlFirstEnabled;
|
||||
this.analyticsCallbacks = analyticsCallbacks || {};
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (err) {
|
||||
console.warn("[PlaybackService] logger threw", err);
|
||||
}
|
||||
}
|
||||
|
||||
handleAnalyticsEvent(eventName, detail) {
|
||||
const callbacks = this.analyticsCallbacks || {};
|
||||
if (typeof callbacks.onEvent === "function") {
|
||||
try {
|
||||
callbacks.onEvent(eventName, detail);
|
||||
} catch (err) {
|
||||
this.log("[PlaybackService] onEvent callback threw", err);
|
||||
}
|
||||
}
|
||||
const specific = callbacks[eventName];
|
||||
if (typeof specific === "function") {
|
||||
try {
|
||||
specific(detail);
|
||||
} catch (err) {
|
||||
this.log(`[PlaybackService] ${eventName} callback threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareVideoElement(videoElement) {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||||
const userWantsUnmuted = storedUnmuted === "true";
|
||||
videoElement.muted = !userWantsUnmuted;
|
||||
|
||||
if (!videoElement.dataset.autoplayBound) {
|
||||
videoElement.addEventListener("volumechange", () => {
|
||||
localStorage.setItem(
|
||||
"unmutedAutoplay",
|
||||
(!videoElement.muted).toString()
|
||||
);
|
||||
});
|
||||
videoElement.dataset.autoplayBound = "true";
|
||||
}
|
||||
}
|
||||
|
||||
registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
{ stallMs = 8000, onSuccess, onFallback } = {}
|
||||
) {
|
||||
if (!videoElement || typeof onFallback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
|
||||
let active = true;
|
||||
let stallTimerId = null;
|
||||
const listeners = [];
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
for (const [eventName, handler] of listeners) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFallback = (reason) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
cleanup();
|
||||
onFallback(reason);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
|
||||
);
|
||||
cleanup();
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
if (!active || !normalizedStallMs) {
|
||||
return;
|
||||
}
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
}
|
||||
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
|
||||
};
|
||||
|
||||
const addListener = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
listeners.push([eventName, handler]);
|
||||
};
|
||||
|
||||
addListener("error", () => triggerFallback("error"));
|
||||
addListener("abort", () => triggerFallback("abort"));
|
||||
addListener("stalled", () => triggerFallback("stalled"));
|
||||
addListener("playing", handleSuccess);
|
||||
addListener("ended", handleSuccess);
|
||||
|
||||
const timerEvents = [
|
||||
"timeupdate",
|
||||
"progress",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"suspend",
|
||||
"waiting",
|
||||
];
|
||||
for (const eventName of timerEvents) {
|
||||
addListener(eventName, resetTimer);
|
||||
}
|
||||
|
||||
if (normalizedStallMs) {
|
||||
resetTimer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.cleanupWatchdog();
|
||||
}
|
||||
}
|
||||
|
||||
createSession(options = {}) {
|
||||
const session = new PlaybackSession(this, options);
|
||||
this.currentSession = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
class PlaybackSession extends SimpleEventEmitter {
|
||||
constructor(service, options = {}) {
|
||||
super((message, ...args) => service.log(message, ...args));
|
||||
this.service = service;
|
||||
this.options = options;
|
||||
this.watchdogCleanup = null;
|
||||
this.result = { source: null };
|
||||
this.finished = false;
|
||||
this.startPromise = null;
|
||||
|
||||
this.sanitizedUrl =
|
||||
typeof options.url === "string" ? options.url.trim() : "";
|
||||
this.trimmedMagnet =
|
||||
typeof options.magnet === "string" ? options.magnet.trim() : "";
|
||||
this.requestSignature =
|
||||
typeof options.requestSignature === "string"
|
||||
? options.requestSignature
|
||||
: JSON.stringify({
|
||||
url: this.sanitizedUrl,
|
||||
magnet: this.trimmedMagnet,
|
||||
});
|
||||
|
||||
if (typeof service.deriveTorrentPlaybackConfig === "function") {
|
||||
this.playbackConfig = service.deriveTorrentPlaybackConfig({
|
||||
magnet: this.trimmedMagnet,
|
||||
url: this.sanitizedUrl,
|
||||
logger: (message) => service.log(message),
|
||||
appProtocol: options.appProtocol,
|
||||
});
|
||||
} else {
|
||||
this.playbackConfig = {
|
||||
magnet: this.trimmedMagnet,
|
||||
fallbackMagnet: "",
|
||||
provided: !!this.trimmedMagnet,
|
||||
usedInfoHash: false,
|
||||
originalInput: this.trimmedMagnet,
|
||||
didMutate: false,
|
||||
infoHash: "",
|
||||
};
|
||||
}
|
||||
|
||||
const magnetIsUsable =
|
||||
typeof service.isValidMagnetUri === "function"
|
||||
? service.isValidMagnetUri(this.playbackConfig.magnet)
|
||||
: false;
|
||||
|
||||
this.magnetForPlayback = magnetIsUsable
|
||||
? this.playbackConfig.magnet
|
||||
: "";
|
||||
this.fallbackMagnet = magnetIsUsable
|
||||
? this.playbackConfig.fallbackMagnet
|
||||
: "";
|
||||
this.magnetProvided = !!this.playbackConfig.provided;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return !this.finished;
|
||||
}
|
||||
|
||||
matchesRequestSignature(signature) {
|
||||
if (!this.isActive()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof signature !== "string" || !signature) {
|
||||
return false;
|
||||
}
|
||||
return signature === this.requestSignature;
|
||||
}
|
||||
|
||||
getResult() {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
getPlaybackConfig() {
|
||||
return this.playbackConfig;
|
||||
}
|
||||
|
||||
getMagnetForPlayback() {
|
||||
return this.magnetForPlayback;
|
||||
}
|
||||
|
||||
getFallbackMagnet() {
|
||||
return this.fallbackMagnet;
|
||||
}
|
||||
|
||||
getMagnetProvided() {
|
||||
return this.magnetProvided;
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (typeof this.watchdogCleanup === "function") {
|
||||
try {
|
||||
this.watchdogCleanup();
|
||||
} catch (err) {
|
||||
this.service.log("[PlaybackSession] Watchdog cleanup threw", err);
|
||||
} finally {
|
||||
this.watchdogCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerWatchdogs(videoElement, options) {
|
||||
this.cleanupWatchdog();
|
||||
this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
attachDebugListeners(videoElement) {
|
||||
if (!videoElement) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const debugEvents = [
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"play",
|
||||
"playing",
|
||||
"pause",
|
||||
"stalled",
|
||||
"suspend",
|
||||
"waiting",
|
||||
"ended",
|
||||
"error",
|
||||
];
|
||||
|
||||
const handlers = [];
|
||||
for (const eventName of debugEvents) {
|
||||
const handler = () => {
|
||||
const { readyState, networkState, currentTime, paused, error } =
|
||||
videoElement;
|
||||
let suffix =
|
||||
`readyState=${readyState} networkState=${networkState} currentTime=${
|
||||
Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime
|
||||
} paused=${paused}`;
|
||||
if (eventName === "error" && error) {
|
||||
const errorMessage = error.message || "";
|
||||
suffix += ` code=${error.code || ""} message=${errorMessage}`;
|
||||
}
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
|
||||
);
|
||||
};
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
handlers.push([eventName, handler]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!handlers.length) {
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler] of handlers) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
handlers.length = 0;
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
this.startPromise = this.execute();
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const {
|
||||
videoElement,
|
||||
waitForCleanup,
|
||||
cancelPendingViewLogging,
|
||||
clearActiveIntervals,
|
||||
showModalWithPoster,
|
||||
teardownVideoElement,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
autoplay,
|
||||
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
|
||||
} = this.options;
|
||||
|
||||
this.result = { source: null };
|
||||
this.finished = false;
|
||||
|
||||
const detail = {
|
||||
urlProvided: !!this.sanitizedUrl,
|
||||
magnetProvided: this.magnetProvided,
|
||||
magnetUsable: !!this.magnetForPlayback,
|
||||
};
|
||||
this.service.handleAnalyticsEvent("session-start", detail);
|
||||
this.emit("session-start", detail);
|
||||
|
||||
try {
|
||||
if (typeof waitForCleanup === "function") {
|
||||
await waitForCleanup();
|
||||
}
|
||||
if (typeof cancelPendingViewLogging === "function") {
|
||||
cancelPendingViewLogging();
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
if (typeof showModalWithPoster === "function") {
|
||||
await Promise.resolve(showModalWithPoster());
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Preparing video..." });
|
||||
|
||||
if (typeof clearActiveIntervals === "function") {
|
||||
clearActiveIntervals();
|
||||
}
|
||||
|
||||
this.service.prepareVideoElement(videoElement);
|
||||
|
||||
if (
|
||||
this.service.torrentClient &&
|
||||
typeof this.service.torrentClient.cleanup === "function"
|
||||
) {
|
||||
await this.service.torrentClient.cleanup();
|
||||
}
|
||||
|
||||
const activeVideoEl =
|
||||
typeof teardownVideoElement === "function"
|
||||
? teardownVideoElement(videoElement) || videoElement
|
||||
: videoElement;
|
||||
|
||||
if (!activeVideoEl) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
this.options.videoElement = activeVideoEl;
|
||||
this.emit("video-prepared", { videoElement: activeVideoEl });
|
||||
this.emit("view-logging-request", { videoElement: activeVideoEl });
|
||||
|
||||
const cleanupDebugListeners = this.attachDebugListeners(activeVideoEl);
|
||||
let cleanupHostedUrlStatusListeners = () => {};
|
||||
|
||||
const httpsUrl = this.sanitizedUrl;
|
||||
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
|
||||
|
||||
let fallbackStarted = false;
|
||||
const startTorrentFallback = async (reason) => {
|
||||
if (fallbackStarted) {
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fallbackStarted = true;
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
|
||||
if (activeVideoEl) {
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// ignore attribute removal errors
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.magnetForPlayback) {
|
||||
const message =
|
||||
"Hosted playback failed and no magnet fallback is available.";
|
||||
this.emit("status", { message });
|
||||
this.emit("sourcechange", { source: null });
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Switching to WebTorrent..." });
|
||||
this.service.handleAnalyticsEvent("fallback", { reason });
|
||||
this.emit("fallback", { reason });
|
||||
|
||||
if (typeof playViaWebTorrent !== "function") {
|
||||
const error = new Error("No torrent playback handler provided.");
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message: error.message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
|
||||
this.emit("sourcechange", { source: "torrent" });
|
||||
if (typeof autoplay === "function") {
|
||||
autoplay();
|
||||
}
|
||||
this.result = { source: "torrent", torrentInstance };
|
||||
return torrentInstance;
|
||||
};
|
||||
|
||||
if (this.service.urlFirstEnabled && httpsUrl) {
|
||||
this.emit("status", { message: "Checking hosted URL..." });
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
);
|
||||
|
||||
let hostedStatusResolved = false;
|
||||
const hostedStatusHandlers = [];
|
||||
const addHostedStatusListener = (eventName, handler, options) => {
|
||||
if (!activeVideoEl) {
|
||||
return;
|
||||
}
|
||||
activeVideoEl.addEventListener(eventName, handler, options);
|
||||
hostedStatusHandlers.push([eventName, handler, options]);
|
||||
};
|
||||
const markHostedUrlAsLive = () => {
|
||||
if (hostedStatusResolved) {
|
||||
return;
|
||||
}
|
||||
hostedStatusResolved = true;
|
||||
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
};
|
||||
const maybeMarkHostedUrl = () => {
|
||||
if (
|
||||
hostedStatusResolved ||
|
||||
!activeVideoEl ||
|
||||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
|
||||
markHostedUrlAsLive();
|
||||
}
|
||||
};
|
||||
cleanupHostedUrlStatusListeners = () => {
|
||||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler, options] of hostedStatusHandlers) {
|
||||
activeVideoEl.removeEventListener(eventName, handler, options);
|
||||
}
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
};
|
||||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||||
addHostedStatusListener(
|
||||
"error",
|
||||
() => {
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const probeResult =
|
||||
typeof probeUrl === "function" ? await probeUrl(httpsUrl) : null;
|
||||
const probeOutcome = probeResult?.outcome || "error";
|
||||
const shouldAttemptHosted =
|
||||
probeOutcome !== "bad" && probeOutcome !== "error";
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
|
||||
);
|
||||
|
||||
if (shouldAttemptHosted) {
|
||||
let outcomeResolved = false;
|
||||
let outcomeResolver = () => {};
|
||||
let autoplayBlocked = false;
|
||||
|
||||
const playbackOutcomePromise = new Promise((resolve) => {
|
||||
outcomeResolver = (value) => {
|
||||
if (outcomeResolved) {
|
||||
return;
|
||||
}
|
||||
outcomeResolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||||
onFallback: (reason) => {
|
||||
if (autoplayBlocked && reason === "stall") {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.emit("autoplay-blocked", { reason });
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
err
|
||||
);
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
try {
|
||||
activeVideoEl.src = httpsUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
autoplayBlocked = true;
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||||
err
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.cleanupWatchdog();
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
const restoreOnPlay = () => {
|
||||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||||
this.cleanupWatchdog();
|
||||
autoplayBlocked = false;
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
};
|
||||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFatalPlaybackError(err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
const playbackOutcome = await playbackOutcomePromise;
|
||||
if (playbackOutcome?.status === "success") {
|
||||
this.emit("poster-remove", { reason: "http-success" });
|
||||
this.service.handleAnalyticsEvent("sourcechange", {
|
||||
source: "url",
|
||||
});
|
||||
this.emit("sourcechange", { source: "url" });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
this.result = { source: "url" };
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const fallbackReason =
|
||||
playbackOutcome?.reason || "watchdog-triggered";
|
||||
const torrentInstance = await startTorrentFallback(fallbackReason);
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||||
);
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
}
|
||||
|
||||
if (this.magnetForPlayback) {
|
||||
const torrentInstance = await startTorrentFallback("magnet-primary");
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const message = this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
this.emit("status", { message });
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: null });
|
||||
this.emit("sourcechange", { source: null });
|
||||
cleanupDebugListeners();
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} catch (error) {
|
||||
this.service.log("Error in playVideoWithFallback:", error);
|
||||
const message = error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} finally {
|
||||
this.cleanupWatchdog();
|
||||
this.finished = true;
|
||||
if (this.result) {
|
||||
this.emit("finished", this.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaybackService;
|
||||
@@ -0,0 +1,821 @@
|
||||
import {
|
||||
loadR2Settings,
|
||||
saveR2Settings,
|
||||
clearR2Settings,
|
||||
buildR2Key,
|
||||
buildPublicUrl,
|
||||
mergeBucketEntry,
|
||||
sanitizeBaseDomain,
|
||||
} from "../r2.js";
|
||||
import {
|
||||
sanitizeBucketName,
|
||||
ensureBucket,
|
||||
putCors,
|
||||
attachCustomDomainAndWait,
|
||||
setManagedDomain,
|
||||
deriveShortSubdomain,
|
||||
} from "../storage/r2-mgmt.js";
|
||||
import {
|
||||
makeR2Client,
|
||||
multipartUpload,
|
||||
ensureBucketCors,
|
||||
} from "../storage/r2-s3.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
accountId: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiToken: "",
|
||||
zoneId: "",
|
||||
baseDomain: "",
|
||||
buckets: {},
|
||||
};
|
||||
}
|
||||
|
||||
class R2Service {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.cloudflareSettings = null;
|
||||
this.cloudflareAdvancedVisible = false;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(event);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(event, detail) {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (err) {
|
||||
console.error("[r2Service] Listener error for", event, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
return this.cloudflareSettings || createDefaultSettings();
|
||||
}
|
||||
|
||||
setSettings(settings) {
|
||||
this.cloudflareSettings = settings ? { ...settings } : null;
|
||||
this.emit("settingsChanged", { settings: this.getSettings() });
|
||||
}
|
||||
|
||||
getCloudflareAdvancedVisibility() {
|
||||
return Boolean(this.cloudflareAdvancedVisible);
|
||||
}
|
||||
|
||||
setCloudflareAdvancedVisibility(visible) {
|
||||
const isVisible = Boolean(visible);
|
||||
if (this.cloudflareAdvancedVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
this.cloudflareAdvancedVisible = isVisible;
|
||||
this.emit("advancedVisibilityChange", { visible: isVisible });
|
||||
}
|
||||
|
||||
normalizeStatusVariant(variant) {
|
||||
if (STATUS_VARIANTS.has(variant)) {
|
||||
return variant;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
setCloudflareSettingsStatus(message = "", variant = "info") {
|
||||
this.emit("settingsStatus", {
|
||||
message: message || "",
|
||||
variant: this.normalizeStatusVariant(variant),
|
||||
});
|
||||
}
|
||||
|
||||
setCloudflareUploadStatus(message = "", variant = "info") {
|
||||
this.emit("uploadStatus", {
|
||||
message: message || "",
|
||||
variant: this.normalizeStatusVariant(variant),
|
||||
});
|
||||
}
|
||||
|
||||
setCloudflareUploading(isUploading) {
|
||||
this.emit("uploadStateChange", { isUploading: Boolean(isUploading) });
|
||||
}
|
||||
|
||||
updateCloudflareProgress(fraction) {
|
||||
this.emit("uploadProgress", { fraction });
|
||||
}
|
||||
|
||||
buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) {
|
||||
const base =
|
||||
existingMetadata && typeof existingMetadata === "object"
|
||||
? { ...existingMetadata }
|
||||
: {};
|
||||
|
||||
const cloneImetaVariant = (variant) => {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return {
|
||||
m: "",
|
||||
dim: "",
|
||||
url: "",
|
||||
x: "",
|
||||
image: [],
|
||||
fallback: [],
|
||||
service: [],
|
||||
autoGenerated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...variant,
|
||||
image: Array.isArray(variant.image) ? [...variant.image] : [],
|
||||
fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [],
|
||||
service: Array.isArray(variant.service) ? [...variant.service] : [],
|
||||
};
|
||||
};
|
||||
|
||||
const imetaList = Array.isArray(base.imeta)
|
||||
? base.imeta.map((variant) => cloneImetaVariant(variant))
|
||||
: [];
|
||||
|
||||
if (!imetaList.length) {
|
||||
imetaList.push({
|
||||
m: "",
|
||||
dim: "",
|
||||
url: "",
|
||||
x: "",
|
||||
image: [],
|
||||
fallback: [],
|
||||
service: [],
|
||||
autoGenerated: true,
|
||||
});
|
||||
} else {
|
||||
const primary = imetaList[0];
|
||||
if (!Array.isArray(primary.image)) primary.image = [];
|
||||
if (!Array.isArray(primary.fallback)) primary.fallback = [];
|
||||
if (!Array.isArray(primary.service)) primary.service = [];
|
||||
}
|
||||
|
||||
const primaryVariant = imetaList[0];
|
||||
const normalizedUrl =
|
||||
typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim();
|
||||
if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) {
|
||||
primaryVariant.url = normalizedUrl;
|
||||
}
|
||||
|
||||
const mimeType =
|
||||
file && typeof file.type === "string" ? file.type.trim() : "";
|
||||
if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) {
|
||||
primaryVariant.m = mimeType;
|
||||
}
|
||||
|
||||
base.imeta = imetaList;
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
populateCloudflareSettingsInputs(settings) {
|
||||
const data = {
|
||||
accountId: settings?.accountId || "",
|
||||
accessKeyId: settings?.accessKeyId || "",
|
||||
secretAccessKey: settings?.secretAccessKey || "",
|
||||
apiToken: settings?.apiToken || "",
|
||||
zoneId: settings?.zoneId || "",
|
||||
baseDomain: settings?.baseDomain || "",
|
||||
};
|
||||
|
||||
const hasAdvancedValues = Boolean(
|
||||
(data.apiToken && data.apiToken.length > 0) ||
|
||||
(data.zoneId && data.zoneId.length > 0) ||
|
||||
(data.baseDomain && data.baseDomain.length > 0)
|
||||
);
|
||||
|
||||
if (hasAdvancedValues) {
|
||||
this.setCloudflareAdvancedVisibility(true);
|
||||
} else if (!this.cloudflareAdvancedVisible) {
|
||||
this.setCloudflareAdvancedVisibility(false);
|
||||
}
|
||||
|
||||
this.setCloudflareSettingsStatus("");
|
||||
this.emit("settingsPopulated", { settings: data });
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const settings = await loadR2Settings();
|
||||
this.setSettings(settings);
|
||||
this.populateCloudflareSettingsInputs(settings);
|
||||
return settings;
|
||||
} catch (err) {
|
||||
console.error("Failed to load Cloudflare settings:", err);
|
||||
this.setSettings(createDefaultSettings());
|
||||
this.populateCloudflareSettingsInputs(this.getSettings());
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to load saved settings.",
|
||||
"error"
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) {
|
||||
const accountId = String(formValues.accountId || "").trim();
|
||||
const accessKeyId = String(formValues.accessKeyId || "").trim();
|
||||
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
|
||||
const apiToken = String(formValues.apiToken || "").trim();
|
||||
const zoneId = String(formValues.zoneId || "").trim();
|
||||
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || "");
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Account ID, Access Key ID, and Secret are required.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let buckets = { ...(this.getSettings().buckets || {}) };
|
||||
const previousAccount = this.getSettings().accountId || "";
|
||||
const previousBaseDomain = this.getSettings().baseDomain || "";
|
||||
const previousZoneId = this.getSettings().zoneId || "";
|
||||
if (
|
||||
previousAccount !== accountId ||
|
||||
previousBaseDomain !== baseDomain ||
|
||||
previousZoneId !== zoneId
|
||||
) {
|
||||
buckets = {};
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
apiToken,
|
||||
zoneId,
|
||||
baseDomain,
|
||||
buckets,
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = await saveR2Settings(updatedSettings);
|
||||
this.setSettings(saved);
|
||||
this.populateCloudflareSettingsInputs(saved);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to save Cloudflare settings:", err);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to save settings. Check console for details.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(formValues = {}, options = {}) {
|
||||
return this.handleCloudflareSettingsSubmit(formValues, options);
|
||||
}
|
||||
|
||||
async handleCloudflareClearSettings() {
|
||||
try {
|
||||
await clearR2Settings();
|
||||
const refreshed = await loadR2Settings();
|
||||
this.setSettings(refreshed);
|
||||
this.populateCloudflareSettingsInputs(refreshed);
|
||||
this.setCloudflareAdvancedVisibility(false);
|
||||
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to clear Cloudflare settings:", err);
|
||||
this.setCloudflareSettingsStatus("Failed to clear settings.", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clearSettings() {
|
||||
return this.handleCloudflareClearSettings();
|
||||
}
|
||||
|
||||
getCorsOrigins() {
|
||||
const origins = new Set();
|
||||
if (typeof window !== "undefined" && window.location) {
|
||||
const origin = window.location.origin;
|
||||
if (origin && origin !== "null") {
|
||||
origins.add(origin);
|
||||
}
|
||||
if (origin && origin.startsWith("http://localhost")) {
|
||||
origins.add(origin.replace("http://", "https://"));
|
||||
}
|
||||
}
|
||||
return Array.from(origins);
|
||||
}
|
||||
|
||||
deriveSubdomainForNpub(npub) {
|
||||
try {
|
||||
return deriveShortSubdomain(npub);
|
||||
} catch (err) {
|
||||
console.warn("Failed to derive short subdomain, falling back:", err);
|
||||
}
|
||||
|
||||
const base = String(npub || "user")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/^-+|[-]+$/g, "");
|
||||
return base.slice(0, 32) || "user";
|
||||
}
|
||||
|
||||
async ensureBucketConfigForNpub(npub) {
|
||||
if (!npub || !this.cloudflareSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountId = (this.cloudflareSettings.accountId || "").trim();
|
||||
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
|
||||
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim();
|
||||
const corsOrigins = this.getCorsOrigins();
|
||||
const baseDomain = this.cloudflareSettings.baseDomain || "";
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error("Cloudflare account ID is missing.");
|
||||
}
|
||||
|
||||
let entry = this.cloudflareSettings.buckets?.[npub] || null;
|
||||
|
||||
if (entry && entry.publicBaseUrl) {
|
||||
if (apiToken) {
|
||||
try {
|
||||
await ensureBucket({
|
||||
accountId,
|
||||
bucket: entry.bucket,
|
||||
token: apiToken,
|
||||
});
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket: entry.bucket,
|
||||
token: apiToken,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to refresh bucket configuration:", err);
|
||||
}
|
||||
} else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
||||
try {
|
||||
const s3 = makeR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
await ensureBucketCors({
|
||||
s3,
|
||||
bucket: entry.bucket,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to refresh bucket CORS via access keys:", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entry,
|
||||
usedManagedFallback: entry.domainType !== "custom",
|
||||
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiToken) {
|
||||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||||
const manualCustomDomain = baseDomain
|
||||
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
|
||||
: "";
|
||||
|
||||
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
|
||||
if (!publicBaseUrl) {
|
||||
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
throw new Error(
|
||||
"No public bucket domain configured. Add an API token or configure the domain manually."
|
||||
);
|
||||
}
|
||||
|
||||
const manualEntry = {
|
||||
bucket: bucketName,
|
||||
publicBaseUrl,
|
||||
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
||||
try {
|
||||
const s3 = makeR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
await ensureBucketCors({
|
||||
s3,
|
||||
bucket: bucketName,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (corsErr) {
|
||||
console.warn(
|
||||
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
|
||||
corsErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let savedEntry = entry;
|
||||
if (
|
||||
!entry ||
|
||||
entry.bucket !== manualEntry.bucket ||
|
||||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
|
||||
entry.domainType !== manualEntry.domainType
|
||||
) {
|
||||
const updatedSettings = await saveR2Settings(
|
||||
mergeBucketEntry(this.getSettings(), npub, manualEntry)
|
||||
);
|
||||
this.setSettings(updatedSettings);
|
||||
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
|
||||
}
|
||||
|
||||
return {
|
||||
entry: savedEntry,
|
||||
usedManagedFallback: manualEntry.domainType !== "custom",
|
||||
customDomainStatus:
|
||||
manualEntry.domainType === "custom" ? "manual" : "managed",
|
||||
};
|
||||
}
|
||||
|
||||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||||
|
||||
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
|
||||
|
||||
try {
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to apply R2 CORS rules:", err);
|
||||
}
|
||||
|
||||
let publicBaseUrl = entry?.publicBaseUrl || "";
|
||||
let domainType = entry?.domainType || "managed";
|
||||
let usedManagedFallback = false;
|
||||
let customDomainStatus = "skipped";
|
||||
|
||||
if (baseDomain && zoneId) {
|
||||
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
|
||||
try {
|
||||
const custom = await attachCustomDomainAndWait({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
zoneId,
|
||||
domain,
|
||||
pollInterval: 2500,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
customDomainStatus = custom?.status || "unknown";
|
||||
if (custom?.active && custom?.url) {
|
||||
publicBaseUrl = custom.url;
|
||||
domainType = "custom";
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Failed to disable managed domain:", disableErr);
|
||||
}
|
||||
} else {
|
||||
usedManagedFallback = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (/already exists/i.test(err.message || "")) {
|
||||
publicBaseUrl = `https://${domain}`;
|
||||
domainType = "custom";
|
||||
customDomainStatus = "active";
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Failed to disable managed domain:", disableErr);
|
||||
}
|
||||
} else {
|
||||
console.warn("Failed to attach custom domain, falling back:", err);
|
||||
usedManagedFallback = true;
|
||||
customDomainStatus = "error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
const managed = await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: true,
|
||||
});
|
||||
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
|
||||
domainType = "managed";
|
||||
usedManagedFallback = true;
|
||||
customDomainStatus =
|
||||
customDomainStatus === "skipped" ? "managed" : customDomainStatus;
|
||||
}
|
||||
|
||||
const mergedEntry = {
|
||||
bucket: bucketName,
|
||||
publicBaseUrl,
|
||||
domainType,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
const updatedSettings = await saveR2Settings(
|
||||
mergeBucketEntry(this.getSettings(), npub, mergedEntry)
|
||||
);
|
||||
this.setSettings(updatedSettings);
|
||||
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
|
||||
}
|
||||
|
||||
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
|
||||
if (!this.cloudflareSettings) {
|
||||
const detail = {
|
||||
text: "Save your credentials to configure R2.",
|
||||
title: "",
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (!hasPubkey) {
|
||||
const detail = { text: "Login to preview your R2 bucket.", title: "" };
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (!npub) {
|
||||
const detail = { text: "Unable to encode npub.", title: "" };
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
const entry = this.cloudflareSettings.buckets?.[npub];
|
||||
if (!entry || !entry.publicBaseUrl) {
|
||||
const detail = {
|
||||
text: "Bucket will be auto-created on your next upload.",
|
||||
title: "",
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
|
||||
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
|
||||
const fullPreview = `${entry.bucket} • ${publicUrl}`;
|
||||
|
||||
let displayHostAndPath = truncateMiddle(publicUrl, 72);
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
const cleanPath = parsed.pathname.replace(/^\//, "");
|
||||
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
|
||||
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
|
||||
} catch (err) {
|
||||
// ignore URL parse issues and fall back to the raw string
|
||||
}
|
||||
|
||||
const truncatedBucket = truncateMiddle(entry.bucket, 28);
|
||||
const detail = {
|
||||
text: `${truncatedBucket} • ${displayHostAndPath}`,
|
||||
title: fullPreview,
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async handleCloudflareUploadSubmit({
|
||||
npub = "",
|
||||
file = null,
|
||||
metadata = {},
|
||||
settingsInput = null,
|
||||
publishVideoNote,
|
||||
onReset,
|
||||
} = {}) {
|
||||
if (settingsInput) {
|
||||
const saved = await this.handleCloudflareSettingsSubmit(settingsInput, {
|
||||
quiet: true,
|
||||
});
|
||||
if (!saved) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Fix your R2 settings before uploading.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!npub) {
|
||||
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = String(metadata.title || "").trim();
|
||||
if (!title) {
|
||||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Select a video or HLS file to upload.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = String(metadata.description || "").trim();
|
||||
const thumbnail = String(metadata.thumbnail || "").trim();
|
||||
const magnet = String(metadata.magnet || "").trim();
|
||||
const ws = String(metadata.ws || "").trim();
|
||||
const xs = String(metadata.xs || "").trim();
|
||||
const enableComments = metadata.enableComments !== false;
|
||||
|
||||
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Missing R2 credentials. Save them before uploading.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
|
||||
this.updateCloudflareProgress(0);
|
||||
this.setCloudflareUploading(true);
|
||||
|
||||
let bucketResult = null;
|
||||
try {
|
||||
bucketResult = await this.ensureBucketConfigForNpub(npub);
|
||||
} catch (err) {
|
||||
console.error("Failed to prepare R2 bucket:", err);
|
||||
this.setCloudflareUploadStatus(
|
||||
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
|
||||
"error"
|
||||
);
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bucketEntry =
|
||||
bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
|
||||
|
||||
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Bucket is missing a public domain. Check your Cloudflare settings.",
|
||||
"error"
|
||||
);
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
return false;
|
||||
}
|
||||
|
||||
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
|
||||
if (bucketResult?.usedManagedFallback) {
|
||||
const baseDomain = this.cloudflareSettings?.baseDomain || "";
|
||||
if (baseDomain) {
|
||||
const customStatus = bucketResult?.customDomainStatus
|
||||
? ` (custom domain status: ${bucketResult.customDomainStatus})`
|
||||
: "";
|
||||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
|
||||
} else {
|
||||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setCloudflareUploadStatus(
|
||||
statusMessage,
|
||||
bucketResult?.usedManagedFallback ? "warning" : "info"
|
||||
);
|
||||
|
||||
const key = buildR2Key(npub, file);
|
||||
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
|
||||
|
||||
try {
|
||||
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
bucket: bucketEntry.bucket,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type,
|
||||
onProgress: (fraction) => {
|
||||
this.updateCloudflareProgress(fraction);
|
||||
},
|
||||
});
|
||||
|
||||
let publishOutcome = true;
|
||||
|
||||
if (typeof publishVideoNote !== "function") {
|
||||
console.warn(
|
||||
"publishVideoNote handler missing; skipping publish step."
|
||||
);
|
||||
publishOutcome = false;
|
||||
} else {
|
||||
const payload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
ws,
|
||||
xs,
|
||||
enableComments,
|
||||
};
|
||||
|
||||
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
|
||||
publicUrl,
|
||||
file,
|
||||
});
|
||||
if (mergedNip71 && Object.keys(mergedNip71).length) {
|
||||
payload.nip71 = mergedNip71;
|
||||
}
|
||||
|
||||
const published = await publishVideoNote(payload, {
|
||||
onSuccess: () => {
|
||||
if (typeof onReset === "function") {
|
||||
onReset();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
publishOutcome = Boolean(published);
|
||||
if (publishOutcome) {
|
||||
this.setCloudflareUploadStatus(
|
||||
`Published ${publicUrl}`,
|
||||
"success"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return publishOutcome;
|
||||
} catch (err) {
|
||||
console.error("Cloudflare upload failed:", err);
|
||||
this.setCloudflareUploadStatus(
|
||||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
await this.updateCloudflareBucketPreview({ hasPubkey: true, npub });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadVideo(params = {}) {
|
||||
return this.handleCloudflareUploadSubmit(params);
|
||||
}
|
||||
}
|
||||
|
||||
const r2Service = new R2Service();
|
||||
|
||||
export default r2Service;
|
||||
@@ -0,0 +1,599 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import { pointerArrayToKey } from "../utils/pointer.js";
|
||||
import {
|
||||
ingestLocalViewEvent as defaultIngestLocalViewEvent,
|
||||
} from "../viewCounter.js";
|
||||
|
||||
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sanitizeVideoMetadata(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAtCandidates = [
|
||||
video.rootCreatedAt,
|
||||
video.created_at,
|
||||
video.createdAt,
|
||||
video.publishedAt,
|
||||
];
|
||||
|
||||
let createdAt = null;
|
||||
for (const candidate of createdAtCandidates) {
|
||||
if (Number.isFinite(candidate)) {
|
||||
createdAt = Math.floor(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof video.id === "string" ? video.id : "",
|
||||
title: typeof video.title === "string" ? video.title : "",
|
||||
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
||||
created_at: createdAt,
|
||||
url: typeof video.url === "string" ? video.url : "",
|
||||
magnet: typeof video.magnet === "string" ? video.magnet : "",
|
||||
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
|
||||
legacyInfoHash:
|
||||
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
|
||||
};
|
||||
}
|
||||
|
||||
export default class WatchHistoryTelemetry {
|
||||
constructor({
|
||||
watchHistoryService = null,
|
||||
watchHistoryController = null,
|
||||
nostrClient = null,
|
||||
log,
|
||||
normalizeHexPubkey,
|
||||
getActiveUserPubkey,
|
||||
ingestLocalViewEvent = defaultIngestLocalViewEvent,
|
||||
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
|
||||
} = {}) {
|
||||
this.watchHistoryService = watchHistoryService;
|
||||
this.watchHistoryController = watchHistoryController;
|
||||
this.nostrClient = nostrClient;
|
||||
this.log = typeof log === "function" ? log : () => {};
|
||||
this.normalizeHexPubkey =
|
||||
typeof normalizeHexPubkey === "function"
|
||||
? normalizeHexPubkey
|
||||
: () => null;
|
||||
this.getActiveUserPubkey =
|
||||
typeof getActiveUserPubkey === "function" ? getActiveUserPubkey : () => null;
|
||||
this.ingestLocalViewEvent =
|
||||
typeof ingestLocalViewEvent === "function"
|
||||
? ingestLocalViewEvent
|
||||
: defaultIngestLocalViewEvent;
|
||||
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
|
||||
? Math.max(0, viewThresholdSeconds)
|
||||
: DEFAULT_VIEW_THRESHOLD_SECONDS;
|
||||
|
||||
this.watchHistoryMetadataEnabled = null;
|
||||
this.watchHistoryPreferenceUnsubscribe = null;
|
||||
this.playbackTelemetryState = null;
|
||||
this.loggedViewPointerKeys = new Set();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resetPlaybackLoggingState();
|
||||
this._clearPreferenceSubscription();
|
||||
this.watchHistoryMetadataEnabled = null;
|
||||
}
|
||||
|
||||
async initPreferenceSync() {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.watchHistoryMetadataEnabled = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.refreshPreferenceSettings();
|
||||
|
||||
if (
|
||||
typeof this.watchHistoryService.subscribe === "function" &&
|
||||
!this.watchHistoryPreferenceUnsubscribe
|
||||
) {
|
||||
try {
|
||||
const unsubscribe = this.watchHistoryService.subscribe(
|
||||
"metadata-preference",
|
||||
(payload) => {
|
||||
const previous = this.watchHistoryMetadataEnabled;
|
||||
const enabled = payload?.enabled !== false;
|
||||
this.watchHistoryMetadataEnabled = enabled;
|
||||
if (previous === true && enabled === false) {
|
||||
try {
|
||||
this.watchHistoryService?.clearLocalMetadata?.();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to subscribe to metadata preference changes:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.watchHistoryPreferenceUnsubscribe || null;
|
||||
}
|
||||
|
||||
refreshPreferenceSettings() {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.watchHistoryMetadataEnabled = false;
|
||||
return this.watchHistoryMetadataEnabled;
|
||||
}
|
||||
|
||||
const previous = this.watchHistoryMetadataEnabled;
|
||||
let enabled = true;
|
||||
|
||||
try {
|
||||
if (typeof this.watchHistoryService.getSettings === "function") {
|
||||
const settings = this.watchHistoryService.getSettings();
|
||||
enabled = settings?.metadata?.storeLocally !== false;
|
||||
} else if (
|
||||
typeof this.watchHistoryService.shouldStoreMetadata === "function"
|
||||
) {
|
||||
enabled = this.watchHistoryService.shouldStoreMetadata() !== false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to read metadata settings:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
this.watchHistoryMetadataEnabled = enabled;
|
||||
|
||||
if (enabled === false && previous !== false) {
|
||||
try {
|
||||
this.watchHistoryService?.clearLocalMetadata?.();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to purge metadata cache while preference disabled:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.watchHistoryMetadataEnabled;
|
||||
}
|
||||
|
||||
isMetadataPreferenceEnabled() {
|
||||
return this.watchHistoryMetadataEnabled !== false;
|
||||
}
|
||||
|
||||
persistMetadataForVideo(video, pointerInfo) {
|
||||
if (
|
||||
!this.watchHistoryMetadataEnabled ||
|
||||
!pointerInfo ||
|
||||
!pointerInfo.key ||
|
||||
typeof this.watchHistoryService?.setLocalMetadata !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video || typeof video !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
video: sanitizeVideoMetadata(video),
|
||||
};
|
||||
|
||||
try {
|
||||
this.watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to persist local metadata for pointer:",
|
||||
pointerInfo.key,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropMetadata(pointerKey) {
|
||||
if (!pointerKey || typeof pointerKey !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.watchHistoryService?.removeLocalMetadata !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.watchHistoryService.removeLocalMetadata(pointerKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to remove cached metadata for pointer:",
|
||||
pointerKey,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoval(payload = {}) {
|
||||
if (!this.watchHistoryController) {
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
|
||||
}
|
||||
|
||||
flush(reason = "session-end", context = "watch-history") {
|
||||
if (!this.watchHistoryController) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
return this.watchHistoryController.flush(reason, context);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
cancelPlaybackLogging({
|
||||
reason = "session-end",
|
||||
context = "cancelPendingViewLogging",
|
||||
} = {}) {
|
||||
const state = this.playbackTelemetryState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewTimerId) {
|
||||
this._getTimerHost().clearTimeout(state.viewTimerId);
|
||||
}
|
||||
|
||||
if (Array.isArray(state.handlers) && state.videoEl) {
|
||||
for (const { eventName, handler } of state.handlers) {
|
||||
try {
|
||||
state.videoEl.removeEventListener(eventName, handler);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[watchHistoryTelemetry] Failed to detach ${eventName} listener:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.playbackTelemetryState = null;
|
||||
|
||||
this.flush(reason, context).catch((error) => {
|
||||
const message = normalizeString(error?.message) || String(error ?? "unknown");
|
||||
this.log(
|
||||
`[watchHistoryTelemetry] Watch history flush failed: ${message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resetPlaybackLoggingState() {
|
||||
this.cancelPlaybackLogging();
|
||||
if (this.loggedViewPointerKeys.size > 0) {
|
||||
this.loggedViewPointerKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
preparePlaybackLogging({
|
||||
videoElement,
|
||||
pointer,
|
||||
pointerKey: explicitPointerKey,
|
||||
video,
|
||||
} = {}) {
|
||||
this.cancelPlaybackLogging();
|
||||
|
||||
if (!videoElement || typeof videoElement.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerKey = explicitPointerKey || pointerArrayToKey(pointer);
|
||||
if (!pointer || !pointerKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewerIdentityKey = this.getActiveViewIdentityKey();
|
||||
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
|
||||
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
videoEl: videoElement,
|
||||
pointer,
|
||||
pointerKey,
|
||||
viewerIdentityKey,
|
||||
handlers: [],
|
||||
viewTimerId: null,
|
||||
viewFired: false,
|
||||
videoMetadata: sanitizeVideoMetadata(video),
|
||||
};
|
||||
|
||||
const clearTimer = (timerKey) => {
|
||||
const property = `${timerKey}TimerId`;
|
||||
if (state[property]) {
|
||||
this._getTimerHost().clearTimeout(state[property]);
|
||||
state[property] = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeView = () => {
|
||||
if (state.viewFired) {
|
||||
return;
|
||||
}
|
||||
state.viewFired = true;
|
||||
clearTimer("view");
|
||||
this.cancelPlaybackLogging();
|
||||
|
||||
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
|
||||
state;
|
||||
|
||||
(async () => {
|
||||
let viewResult;
|
||||
try {
|
||||
const canUseWatchHistoryService =
|
||||
typeof this.watchHistoryService?.publishView === "function";
|
||||
const resolveWatchActor = () => {
|
||||
const normalizedUser = this.normalizeHexPubkey(
|
||||
this.getActiveUserPubkey(),
|
||||
);
|
||||
if (normalizedUser) {
|
||||
return normalizedUser;
|
||||
}
|
||||
|
||||
const normalizedClient = this.normalizeHexPubkey(
|
||||
this.nostrClient?.pubkey,
|
||||
);
|
||||
if (normalizedClient) {
|
||||
return normalizedClient;
|
||||
}
|
||||
|
||||
const normalizedSession = this.normalizeHexPubkey(
|
||||
this.nostrClient?.sessionActor?.pubkey,
|
||||
);
|
||||
if (normalizedSession) {
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
if (normalizeString(this.nostrClient?.pubkey)) {
|
||||
return normalizeString(this.nostrClient.pubkey).toLowerCase();
|
||||
}
|
||||
|
||||
if (normalizeString(this.nostrClient?.sessionActor?.pubkey)) {
|
||||
return normalizeString(
|
||||
this.nostrClient.sessionActor.pubkey,
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const activeWatchActor = resolveWatchActor();
|
||||
const watchMetadata = {};
|
||||
if (activeWatchActor) {
|
||||
watchMetadata.actor = activeWatchActor;
|
||||
}
|
||||
if (state.videoMetadata) {
|
||||
watchMetadata.video = state.videoMetadata;
|
||||
}
|
||||
const metadataPayload = Object.keys(watchMetadata).length
|
||||
? watchMetadata
|
||||
: undefined;
|
||||
|
||||
if (canUseWatchHistoryService) {
|
||||
viewResult = await this.watchHistoryService.publishView(
|
||||
thresholdPointer,
|
||||
undefined,
|
||||
metadataPayload,
|
||||
);
|
||||
} else if (typeof this.nostrClient?.recordVideoView === "function") {
|
||||
viewResult = await this.nostrClient.recordVideoView(
|
||||
thresholdPointer,
|
||||
);
|
||||
} else {
|
||||
viewResult = { ok: false, error: "view-logging-unavailable" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Exception while recording video view:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const viewOk = !!viewResult?.ok;
|
||||
if (viewOk) {
|
||||
const eventIdentityKey =
|
||||
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
|
||||
state.viewerIdentityKey ||
|
||||
this.getActiveViewIdentityKey();
|
||||
const keyToPersist = this.buildViewCooldownKey(
|
||||
thresholdPointerKey,
|
||||
eventIdentityKey,
|
||||
);
|
||||
if (keyToPersist) {
|
||||
this.loggedViewPointerKeys.add(keyToPersist);
|
||||
}
|
||||
if (viewResult?.event && this.ingestLocalViewEvent) {
|
||||
try {
|
||||
this.ingestLocalViewEvent({
|
||||
event: viewResult.event,
|
||||
pointer: thresholdPointer,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to ingest local view event:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isDevMode && viewResult) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] View event rejected by relays:",
|
||||
viewResult,
|
||||
);
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Unexpected error while recording video view:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
|
||||
const firedKey = `${timerKey}Fired`;
|
||||
const idKey = `${timerKey}TimerId`;
|
||||
if (state[firedKey] || state[idKey]) {
|
||||
return;
|
||||
}
|
||||
const currentSeconds = Number.isFinite(videoElement.currentTime)
|
||||
? videoElement.currentTime
|
||||
: 0;
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
Math.ceil((thresholdSeconds - currentSeconds) * 1000),
|
||||
);
|
||||
if (remainingMs <= 0) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
state[idKey] = this._getTimerHost().setTimeout(callback, remainingMs);
|
||||
};
|
||||
|
||||
const registerHandler = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
state.handlers.push({ eventName, handler });
|
||||
};
|
||||
|
||||
registerHandler("timeupdate", () => {
|
||||
if (!state.viewFired && videoElement.currentTime >= this.viewThresholdSeconds) {
|
||||
finalizeView();
|
||||
}
|
||||
});
|
||||
|
||||
const cancelOnPause = () => {
|
||||
if (!state.viewFired) {
|
||||
clearTimer("view");
|
||||
}
|
||||
};
|
||||
|
||||
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
|
||||
registerHandler(event, cancelOnPause),
|
||||
);
|
||||
|
||||
const resumeIfNeeded = () => {
|
||||
if (state.viewFired) {
|
||||
return;
|
||||
}
|
||||
scheduleTimer("view", this.viewThresholdSeconds, finalizeView);
|
||||
};
|
||||
|
||||
["play", "playing"].forEach((event) =>
|
||||
registerHandler(event, resumeIfNeeded),
|
||||
);
|
||||
|
||||
this.playbackTelemetryState = state;
|
||||
|
||||
if (!videoElement.paused && videoElement.currentTime > 0) {
|
||||
resumeIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveViewIdentityKey() {
|
||||
const normalizedUser = this.normalizeHexPubkey(this.getActiveUserPubkey());
|
||||
if (normalizedUser) {
|
||||
return `actor:${normalizedUser}`;
|
||||
}
|
||||
|
||||
const sessionActorPubkey = this.normalizeHexPubkey(
|
||||
this.nostrClient?.sessionActor?.pubkey,
|
||||
);
|
||||
if (sessionActorPubkey) {
|
||||
return `actor:${sessionActorPubkey}`;
|
||||
}
|
||||
|
||||
return "actor:anonymous";
|
||||
}
|
||||
|
||||
deriveViewIdentityKeyFromEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `actor:${normalizedPubkey}`;
|
||||
}
|
||||
|
||||
buildViewCooldownKey(pointerKey, identityKey) {
|
||||
const normalizedPointerKey = normalizeString(pointerKey);
|
||||
if (!normalizedPointerKey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedIdentity = normalizeString(identityKey).toLowerCase();
|
||||
|
||||
return normalizedIdentity
|
||||
? `${normalizedPointerKey}::${normalizedIdentity}`
|
||||
: normalizedPointerKey;
|
||||
}
|
||||
|
||||
_clearPreferenceSubscription() {
|
||||
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
|
||||
try {
|
||||
this.watchHistoryPreferenceUnsubscribe();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.watchHistoryPreferenceUnsubscribe = null;
|
||||
}
|
||||
|
||||
_getTimerHost() {
|
||||
if (typeof window !== "undefined" && window) {
|
||||
return window;
|
||||
}
|
||||
return globalThis;
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -1,7 +1,7 @@
|
||||
// sidebar.js
|
||||
import { setHashView } from "./index.js";
|
||||
|
||||
export function setupSidebarNavigation() {
|
||||
export function setupSidebarNavigation({ closeSidebar } = {}) {
|
||||
const sidebarLinks = document.querySelectorAll('#sidebar a[href^="#view="]');
|
||||
sidebarLinks.forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
@@ -16,13 +16,21 @@ export function setupSidebarNavigation() {
|
||||
setHashView(viewName); // This changes the hash and loads the view.
|
||||
|
||||
// --- NEW: if on mobile, close the sidebar automatically. ---
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const app = document.getElementById("app");
|
||||
if (sidebar && app) {
|
||||
sidebar.classList.remove("sidebar-open");
|
||||
app.classList.remove("sidebar-open");
|
||||
const isMobile =
|
||||
typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(max-width: 767px)").matches
|
||||
: window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
if (typeof closeSidebar === "function") {
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove("sidebar-open");
|
||||
}
|
||||
document.body.classList.remove("sidebar-open");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// js/sidebarLoading.js
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function getSidebarLoadingMarkup(message = "", options = {}) {
|
||||
const trimmed = typeof message === "string" ? message.trim() : "";
|
||||
const copy = trimmed || "Fetching data…";
|
||||
const safeCopy = escapeHtml(copy);
|
||||
const showSpinner = options?.showSpinner !== false;
|
||||
const spinnerMarkup = showSpinner
|
||||
? '<span class="status-spinner status-spinner--inline" aria-hidden="true"></span>'
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
|
||||
<div class="sidebar-loading-indicator">
|
||||
${spinnerMarkup}
|
||||
<span class="sidebar-loading-text">${safeCopy}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
const state = {
|
||||
pubkey: null,
|
||||
currentUserNpub: null,
|
||||
currentVideo: null,
|
||||
modals: Object.create(null),
|
||||
videosMap: null,
|
||||
videoSubscription: null,
|
||||
};
|
||||
|
||||
const globalSubscribers = new Set();
|
||||
const keySubscribers = new Map();
|
||||
const modalSubscribers = new Map();
|
||||
|
||||
function createSnapshot() {
|
||||
return {
|
||||
pubkey: state.pubkey,
|
||||
currentUserNpub: state.currentUserNpub,
|
||||
currentVideo: state.currentVideo,
|
||||
modals: { ...state.modals },
|
||||
videosMap: state.videosMap,
|
||||
videoSubscription: state.videoSubscription,
|
||||
};
|
||||
}
|
||||
|
||||
function notifyKey(key, value, previousValue) {
|
||||
const subscribers = keySubscribers.get(key);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] subscriber for key "${key}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSubscribers.size) {
|
||||
const snapshot = createSnapshot();
|
||||
for (const callback of globalSubscribers) {
|
||||
try {
|
||||
callback(snapshot, { key, value, previousValue });
|
||||
} catch (error) {
|
||||
console.warn("[appState] global subscriber threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToKey(key, callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!keySubscribers.has(key)) {
|
||||
keySubscribers.set(key, new Set());
|
||||
}
|
||||
const subscribers = keySubscribers.get(key);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
keySubscribers.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppState(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
globalSubscribers.add(callback);
|
||||
return () => {
|
||||
globalSubscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppStateKey(key, callback) {
|
||||
return subscribeToKey(key, callback);
|
||||
}
|
||||
|
||||
function updateKey(key, value) {
|
||||
const previousValue = state[key];
|
||||
if (previousValue === value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
state[key] = value;
|
||||
notifyKey(key, value, previousValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getAppState() {
|
||||
return createSnapshot();
|
||||
}
|
||||
|
||||
export function getPubkey() {
|
||||
return state.pubkey;
|
||||
}
|
||||
|
||||
export function setPubkey(value) {
|
||||
return updateKey("pubkey", typeof value === "string" ? value : value ?? null);
|
||||
}
|
||||
|
||||
export function getCurrentUserNpub() {
|
||||
return state.currentUserNpub;
|
||||
}
|
||||
|
||||
export function setCurrentUserNpub(value) {
|
||||
return updateKey(
|
||||
"currentUserNpub",
|
||||
typeof value === "string" && value ? value : value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getCurrentVideo() {
|
||||
return state.currentVideo;
|
||||
}
|
||||
|
||||
export function setCurrentVideo(value) {
|
||||
return updateKey("currentVideo", value ?? null);
|
||||
}
|
||||
|
||||
export function getVideosMap() {
|
||||
return state.videosMap;
|
||||
}
|
||||
|
||||
export function setVideosMap(value) {
|
||||
if (value instanceof Map) {
|
||||
return updateKey("videosMap", value);
|
||||
}
|
||||
return updateKey("videosMap", value ?? null);
|
||||
}
|
||||
|
||||
export function getVideoSubscription() {
|
||||
return state.videoSubscription;
|
||||
}
|
||||
|
||||
export function setVideoSubscription(value) {
|
||||
return updateKey("videoSubscription", value ?? null);
|
||||
}
|
||||
|
||||
export function getModalState(name) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(state.modals[name]);
|
||||
}
|
||||
|
||||
function notifyModal(name, value, previousValue) {
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] modal subscriber for "${name}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setModalState(name, isOpen) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = Boolean(isOpen);
|
||||
const previous = Boolean(state.modals[name]);
|
||||
if (previous === normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const previousModals = { ...state.modals };
|
||||
state.modals[name] = normalized;
|
||||
notifyModal(name, normalized, previous);
|
||||
notifyKey("modals", { ...state.modals }, previousModals);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function subscribeToModalState(name, callback) {
|
||||
if (typeof name !== "string" || !name || typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!modalSubscribers.has(name)) {
|
||||
modalSubscribers.set(name, new Set());
|
||||
}
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
modalSubscribers.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAppState() {
|
||||
const previous = createSnapshot();
|
||||
state.pubkey = null;
|
||||
state.currentUserNpub = null;
|
||||
state.currentVideo = null;
|
||||
state.modals = Object.create(null);
|
||||
state.videosMap = null;
|
||||
state.videoSubscription = null;
|
||||
notifyKey("pubkey", state.pubkey, previous.pubkey);
|
||||
notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub);
|
||||
notifyKey("currentVideo", state.currentVideo, previous.currentVideo);
|
||||
notifyKey("modals", { ...state.modals }, previous.modals);
|
||||
notifyKey("videosMap", state.videosMap, previous.videosMap);
|
||||
notifyKey(
|
||||
"videoSubscription",
|
||||
state.videoSubscription,
|
||||
previous.videoSubscription
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import {
|
||||
readUrlHealthFromStorage,
|
||||
removeUrlHealthFromStorage,
|
||||
writeUrlHealthToStorage,
|
||||
} from "../utils/storage.js";
|
||||
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||||
|
||||
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
|
||||
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
|
||||
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function sanitizeProfileString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let savedProfiles = [];
|
||||
let activeProfilePubkey = null;
|
||||
const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(previousProfiles) || !Array.isArray(nextProfiles)) {
|
||||
return true;
|
||||
}
|
||||
if (previousProfiles.length !== nextProfiles.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousProfiles.length; index += 1) {
|
||||
const prev = previousProfiles[index];
|
||||
const next = nextProfiles[index];
|
||||
if (prev === next) {
|
||||
continue;
|
||||
}
|
||||
if (!prev || !next || typeof prev !== "object" || typeof next !== "object") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded && decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSavedProfiles() {
|
||||
return savedProfiles;
|
||||
}
|
||||
|
||||
export function getProfileCacheMap() {
|
||||
return profileCache;
|
||||
}
|
||||
|
||||
export function getActiveProfilePubkey() {
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function setActiveProfilePubkey(pubkey, { persist = true } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
const nextValue =
|
||||
normalized || (typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : null);
|
||||
if (activeProfilePubkey === nextValue) {
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
activeProfilePubkey = nextValue;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function mutateSavedProfiles(mutator, { persist = true, persistActive = true } = {}) {
|
||||
if (typeof mutator !== "function") {
|
||||
return { changed: false, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
const draft = savedProfiles.slice();
|
||||
const result = mutator(draft);
|
||||
const nextProfiles = Array.isArray(result) ? result : draft;
|
||||
const changed = hasSavedProfilesChanged(savedProfiles, nextProfiles);
|
||||
|
||||
if (changed) {
|
||||
savedProfiles = nextProfiles;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive });
|
||||
}
|
||||
} else if (persist && persistActive === false) {
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
|
||||
return { changed, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
export function setSavedProfiles(nextProfiles, options) {
|
||||
return mutateSavedProfiles(
|
||||
() => (Array.isArray(nextProfiles) ? nextProfiles.slice() : []),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function readSavedProfilesPayloadFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.readSavedProfilesPayloadFromStorage] Failed to parse payload:",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSavedProfilesPayloadToStorage(payload) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(SAVED_PROFILES_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
const isQuotaError =
|
||||
error &&
|
||||
(error.name === "QuotaExceededError" ||
|
||||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
error.code === 22 ||
|
||||
error.code === 1014);
|
||||
if (isQuotaError) {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Storage quota exceeded; keeping in-memory copy only.",
|
||||
error
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSavedProfiles({ persistActive = true } = {}) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedProfiles.length && !activeProfilePubkey) {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistSavedProfiles] Failed to remove empty payload:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let activePubkeyToPersist = activeProfilePubkey || null;
|
||||
if (!persistActive) {
|
||||
const storedPayload = readSavedProfilesPayloadFromStorage();
|
||||
if (storedPayload && typeof storedPayload === "object") {
|
||||
const candidate =
|
||||
typeof storedPayload.activePubkey === "string"
|
||||
? storedPayload.activePubkey
|
||||
: typeof storedPayload.activePubKey === "string"
|
||||
? storedPayload.activePubKey
|
||||
: null;
|
||||
const normalizedStored = normalizeHexPubkey(candidate);
|
||||
if (normalizedStored) {
|
||||
activePubkeyToPersist = normalizedStored;
|
||||
} else if (candidate === null) {
|
||||
activePubkeyToPersist = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||||
entries: savedProfiles.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
npub:
|
||||
typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture: typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
})),
|
||||
activePubkey: activePubkeyToPersist,
|
||||
};
|
||||
|
||||
writeSavedProfilesPayloadToStorage(payload);
|
||||
}
|
||||
|
||||
export function loadSavedProfilesFromStorage() {
|
||||
savedProfiles = [];
|
||||
activeProfilePubkey = null;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
let needsRewrite = false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) {
|
||||
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
const seenPubkeys = new Set();
|
||||
for (const candidate of entries) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizeHexPubkey(candidate.pubkey);
|
||||
if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPubkeys.add(normalizedPubkey);
|
||||
const storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
const npub =
|
||||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||||
? candidate.npub.trim()
|
||||
: null;
|
||||
|
||||
const entry = {
|
||||
pubkey: normalizedPubkey,
|
||||
npub,
|
||||
name: typeof candidate.name === "string" ? candidate.name : "",
|
||||
picture: typeof candidate.picture === "string" ? candidate.picture : "",
|
||||
authType: storedAuthType,
|
||||
};
|
||||
|
||||
if (
|
||||
typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub
|
||||
) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
savedProfiles.push(entry);
|
||||
}
|
||||
|
||||
const activeCandidate =
|
||||
typeof parsed.activePubkey === "string"
|
||||
? parsed.activePubkey
|
||||
: typeof parsed.activePubKey === "string"
|
||||
? parsed.activePubKey
|
||||
: null;
|
||||
const normalizedActive = normalizeHexPubkey(activeCandidate);
|
||||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||||
activeProfilePubkey = normalizedActive;
|
||||
} else if (activeCandidate) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} else if (raw) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error);
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (!activeProfilePubkey && savedProfiles.length) {
|
||||
activeProfilePubkey = savedProfiles[0].pubkey;
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (needsRewrite) {
|
||||
persistSavedProfiles();
|
||||
}
|
||||
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
export function syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheEntry = profileCache.get(normalized);
|
||||
if (!cacheEntry || typeof cacheEntry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const next = profiles.slice();
|
||||
const index = next.findIndex((entry) => entry && entry.pubkey === normalized);
|
||||
if (index < 0) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
const existing = next[index] || {};
|
||||
const profile = cacheEntry.profile || {};
|
||||
const nextEntry = {
|
||||
...existing,
|
||||
name: profile.name || existing.name || "",
|
||||
picture: profile.picture || existing.picture || "",
|
||||
};
|
||||
|
||||
if (
|
||||
existing.name === nextEntry.name &&
|
||||
existing.picture === nextEntry.picture
|
||||
) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
next[index] = nextEntry;
|
||||
updated = true;
|
||||
return next;
|
||||
}, { persist, persistActive: false });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parsed.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||||
if (!pubkey || !entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = entry.profile;
|
||||
if (!profile || typeof profile !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const about = sanitizeProfileString(profile.about || profile.aboutMe);
|
||||
if (about) {
|
||||
normalized.about = about;
|
||||
}
|
||||
|
||||
const website = sanitizeProfileString(profile.website || profile.url);
|
||||
if (website) {
|
||||
normalized.website = website;
|
||||
}
|
||||
|
||||
const banner = sanitizeProfileString(profile.banner || profile.header);
|
||||
if (banner) {
|
||||
normalized.banner = banner;
|
||||
}
|
||||
|
||||
const lud16 = sanitizeProfileString(profile.lud16);
|
||||
if (lud16) {
|
||||
normalized.lud16 = lud16;
|
||||
}
|
||||
|
||||
const lud06 = sanitizeProfileString(profile.lud06);
|
||||
if (lud06) {
|
||||
normalized.lud06 = lud06;
|
||||
}
|
||||
|
||||
const lightningCandidates = [
|
||||
sanitizeProfileString(profile.lightningAddress),
|
||||
lud16,
|
||||
lud06,
|
||||
].filter(Boolean);
|
||||
if (lightningCandidates.length) {
|
||||
normalized.lightningAddress = lightningCandidates[0];
|
||||
}
|
||||
|
||||
profileCache.set(pubkey, {
|
||||
profile: normalized,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadProfileCacheFromStorage] Failed to parse payload:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistProfileCacheToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
|
||||
for (const [pubkey, entry] of profileCache.entries()) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[pubkey] = {
|
||||
profile: entry.profile,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: PROFILE_CACHE_VERSION,
|
||||
savedAt: now,
|
||||
entries,
|
||||
};
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistProfileCacheToStorage] Failed to clear storage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn("[cache.persistProfileCacheToStorage] Failed to persist cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileCacheEntry(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = profileCache.get(normalized);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(normalized);
|
||||
persistProfileCacheToStorage();
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey || !profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const about = sanitizeProfileString(profile.about || profile.aboutMe);
|
||||
if (about) {
|
||||
normalized.about = about;
|
||||
}
|
||||
|
||||
const website = sanitizeProfileString(profile.website || profile.url);
|
||||
if (website) {
|
||||
normalized.website = website;
|
||||
}
|
||||
|
||||
const banner = sanitizeProfileString(profile.banner || profile.header);
|
||||
if (banner) {
|
||||
normalized.banner = banner;
|
||||
}
|
||||
|
||||
const lud16 = sanitizeProfileString(profile.lud16);
|
||||
if (lud16) {
|
||||
normalized.lud16 = lud16;
|
||||
}
|
||||
|
||||
const lud06 = sanitizeProfileString(profile.lud06);
|
||||
if (lud06) {
|
||||
normalized.lud06 = lud06;
|
||||
}
|
||||
|
||||
const lightningCandidates = [
|
||||
sanitizeProfileString(profile.lightningAddress),
|
||||
lud16,
|
||||
lud06,
|
||||
].filter(Boolean);
|
||||
if (lightningCandidates.length) {
|
||||
normalized.lightningAddress = lightningCandidates[0];
|
||||
}
|
||||
|
||||
const entry = {
|
||||
profile: normalized,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
profileCache.set(normalizedPubkey, entry);
|
||||
if (persist) {
|
||||
persistProfileCacheToStorage();
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
return `${trimmed}::${mode}`;
|
||||
}
|
||||
|
||||
function isUrlHealthEntryFresh(entry, url) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url && entry.url && entry.url !== url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedUrlHealth(eventId, url) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthCache.get(eventId);
|
||||
if (isUrlHealthEntryFresh(entry, url)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
urlHealthCache.delete(eventId);
|
||||
}
|
||||
|
||||
const stored = readUrlHealthFromStorage(eventId);
|
||||
if (!isUrlHealthEntryFresh(stored, url)) {
|
||||
if (stored) {
|
||||
removeUrlHealthFromStorage(eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
urlHealthCache.set(eventId, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function storeUrlHealth(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
status: result?.status || "checking",
|
||||
message: result?.message || "Checking hosted URL…",
|
||||
url: url || result?.url || "",
|
||||
expiresAt: now + ttl,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
urlHealthCache.set(eventId, entry);
|
||||
writeUrlHealthToStorage(eventId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setInFlightUrlProbe(eventId, url, promise, options = {}) {
|
||||
if (!eventId || !promise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
urlHealthInFlight.set(eventId, { promise, key });
|
||||
promise.finally(() => {
|
||||
const current = urlHealthInFlight.get(eventId);
|
||||
if (current && current.promise === promise) {
|
||||
urlHealthInFlight.delete(eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getInFlightUrlProbe(eventId, url, options = {}) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthInFlight.get(eventId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
if (entry.key && entry.key !== key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export const urlHealthConstants = {
|
||||
URL_HEALTH_TTL_MS,
|
||||
URL_HEALTH_TIMEOUT_RETRY_MS,
|
||||
URL_PROBE_TIMEOUT_RETRY_MS,
|
||||
};
|
||||
+106
-10
@@ -1,14 +1,97 @@
|
||||
// js/storage/r2-s3.js
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketCorsCommand,
|
||||
} from "https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
|
||||
|
||||
const AWS_SDK_SPECIFIER =
|
||||
"https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
|
||||
|
||||
const disableNetworkImports = Boolean(
|
||||
globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__
|
||||
);
|
||||
|
||||
let awsSdk = null;
|
||||
let awsSdkLoadError = null;
|
||||
let awsSdkLoadPromise = null;
|
||||
|
||||
function assignAwsSdk(module) {
|
||||
if (!module) {
|
||||
return;
|
||||
}
|
||||
|
||||
awsSdk = {
|
||||
S3Client: module.S3Client,
|
||||
CreateBucketCommand: module.CreateBucketCommand,
|
||||
CreateMultipartUploadCommand: module.CreateMultipartUploadCommand,
|
||||
UploadPartCommand: module.UploadPartCommand,
|
||||
CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand: module.AbortMultipartUploadCommand,
|
||||
HeadBucketCommand: module.HeadBucketCommand,
|
||||
PutBucketCorsCommand: module.PutBucketCorsCommand,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAwsSdkModule() {
|
||||
if (awsSdk || awsSdkLoadError) {
|
||||
return awsSdk;
|
||||
}
|
||||
|
||||
if (!awsSdkLoadPromise) {
|
||||
if (disableNetworkImports) {
|
||||
awsSdkLoadError = new Error(
|
||||
"Cloudflare R2 SDK network import has been disabled."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
awsSdkLoadPromise = import(AWS_SDK_SPECIFIER)
|
||||
.then((module) => {
|
||||
assignAwsSdk(module);
|
||||
return awsSdk;
|
||||
})
|
||||
.catch((error) => {
|
||||
awsSdkLoadError = error || new Error("Failed to load R2 SDK module");
|
||||
throw awsSdkLoadError;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await awsSdkLoadPromise;
|
||||
} catch (error) {
|
||||
awsSdkLoadError = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requireAwsSdk() {
|
||||
if (awsSdk) {
|
||||
return awsSdk;
|
||||
}
|
||||
|
||||
if (awsSdkLoadError) {
|
||||
throw awsSdkLoadError;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Cloudflare R2 SDK has not finished loading. Call ensureR2SdkLoaded() first."
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureR2SdkLoaded() {
|
||||
return loadAwsSdkModule();
|
||||
}
|
||||
|
||||
export function getR2SdkLoadError() {
|
||||
return awsSdkLoadError;
|
||||
}
|
||||
|
||||
export function isR2SdkAvailable() {
|
||||
return Boolean(awsSdk);
|
||||
}
|
||||
|
||||
if (!disableNetworkImports) {
|
||||
// Begin loading immediately in environments that support network imports.
|
||||
loadAwsSdkModule().catch(() => {
|
||||
// Errors are captured for later inspection by getR2SdkLoadError().
|
||||
});
|
||||
}
|
||||
|
||||
function computeCacheControl(key) {
|
||||
if (!key) {
|
||||
@@ -36,6 +119,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
|
||||
throw new Error("Missing Cloudflare R2 credentials");
|
||||
}
|
||||
|
||||
const { S3Client } = requireAwsSdk();
|
||||
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
@@ -45,6 +130,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
|
||||
}
|
||||
|
||||
async function ensureBucketExists({ s3, bucket }) {
|
||||
const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk();
|
||||
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
return;
|
||||
@@ -80,6 +167,8 @@ export async function ensureBucketCors({ s3, bucket, origins }) {
|
||||
throw new Error("Bucket name is required to configure CORS");
|
||||
}
|
||||
|
||||
const { PutBucketCorsCommand } = requireAwsSdk();
|
||||
|
||||
const allowedOrigins = (origins || []).filter(Boolean);
|
||||
if (allowedOrigins.length === 0) {
|
||||
return;
|
||||
@@ -131,6 +220,13 @@ export async function multipartUpload({
|
||||
throw new Error("File is required");
|
||||
}
|
||||
|
||||
const {
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
} = requireAwsSdk();
|
||||
|
||||
const createCommand = new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
|
||||
+512
-536
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,885 @@
|
||||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../../magnet.js";
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
|
||||
export class EditModal {
|
||||
constructor({
|
||||
removeTrackingScripts,
|
||||
setGlobalModalState,
|
||||
sanitizers = {},
|
||||
escapeHtml,
|
||||
showError,
|
||||
getMode,
|
||||
eventTarget,
|
||||
container,
|
||||
} = {}) {
|
||||
this.removeTrackingScripts =
|
||||
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
|
||||
this.setGlobalModalState =
|
||||
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
||||
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
||||
this.showError = typeof showError === "function" ? showError : () => {};
|
||||
this.getMode = typeof getMode === "function" ? getMode : () => "live";
|
||||
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
this.container = container || null;
|
||||
|
||||
this.sanitizers = {
|
||||
text:
|
||||
typeof sanitizers.text === "function"
|
||||
? sanitizers.text
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
url:
|
||||
typeof sanitizers.url === "function"
|
||||
? sanitizers.url
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
magnet:
|
||||
typeof sanitizers.magnet === "function"
|
||||
? sanitizers.magnet
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
checkbox:
|
||||
typeof sanitizers.checkbox === "function"
|
||||
? sanitizers.checkbox
|
||||
: (value) => !!value,
|
||||
};
|
||||
|
||||
this.root = null;
|
||||
this.overlay = null;
|
||||
this.form = null;
|
||||
this.closeButton = null;
|
||||
this.cancelButton = null;
|
||||
this.submitButton = null;
|
||||
this.fieldButtons = [];
|
||||
this.fields = {};
|
||||
this.visibility = {
|
||||
container: null,
|
||||
buttons: [],
|
||||
};
|
||||
|
||||
this.activeVideo = null;
|
||||
this.isVisible = false;
|
||||
this.eventsBound = false;
|
||||
this.loadPromise = null;
|
||||
|
||||
this.pendingSubmit = false;
|
||||
this.pendingSubmitVideo = null;
|
||||
|
||||
this.nip71FormManager = new Nip71FormManager();
|
||||
this.nip71SectionKey = "edit";
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
this.eventTarget.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener, options) {
|
||||
this.eventTarget.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
emit(type, detail) {
|
||||
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
async load({ container } = {}) {
|
||||
if (this.root) {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
if (this.loadPromise) {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
this.loadPromise = (async () => {
|
||||
const targetContainer =
|
||||
container || this.container || document.getElementById("modalContainer");
|
||||
if (!targetContainer) {
|
||||
throw new Error("Modal container element not found!");
|
||||
}
|
||||
|
||||
let modal = targetContainer.querySelector("#editVideoModal");
|
||||
if (!modal) {
|
||||
const response = await fetch("components/edit-video-modal.html");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
this.removeTrackingScripts(wrapper);
|
||||
targetContainer.appendChild(wrapper);
|
||||
modal = wrapper.querySelector("#editVideoModal");
|
||||
}
|
||||
|
||||
if (!modal) {
|
||||
throw new Error("Edit video modal markup missing after load.");
|
||||
}
|
||||
|
||||
this.container = targetContainer;
|
||||
this.root = modal;
|
||||
|
||||
this.cacheElements(modal);
|
||||
if (!this.eventsBound) {
|
||||
this.bindEvents();
|
||||
this.eventsBound = true;
|
||||
}
|
||||
this.reset();
|
||||
|
||||
return this.root;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this.loadPromise;
|
||||
} finally {
|
||||
this.loadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
cacheElements(context) {
|
||||
this.overlay = context.querySelector("#editVideoModalOverlay") || null;
|
||||
this.form = context.querySelector("#editVideoForm") || null;
|
||||
this.closeButton = context.querySelector("#closeEditVideoModal") || null;
|
||||
this.cancelButton = context.querySelector("#cancelEditVideo") || null;
|
||||
this.submitButton = context.querySelector("#submitEditVideo") || null;
|
||||
|
||||
this.updateSubmitButtonState();
|
||||
|
||||
this.fieldButtons = Array.from(context.querySelectorAll("[data-edit-target]"));
|
||||
|
||||
this.fields = {
|
||||
title: context.querySelector("#editVideoTitle") || null,
|
||||
url: context.querySelector("#editVideoUrl") || null,
|
||||
magnet: context.querySelector("#editVideoMagnet") || null,
|
||||
ws: context.querySelector("#editVideoWs") || null,
|
||||
xs: context.querySelector("#editVideoXs") || null,
|
||||
thumbnail: context.querySelector("#editVideoThumbnail") || null,
|
||||
description: context.querySelector("#editVideoDescription") || null,
|
||||
isPrivate: context.querySelector("#editVideoIsPrivate") || null,
|
||||
enableComments: context.querySelector("#editEnableComments") || null,
|
||||
};
|
||||
|
||||
this.visibility = {
|
||||
container: context.querySelector("[data-visibility-toggle]") || null,
|
||||
buttons: Array.from(
|
||||
context.querySelectorAll("[data-visibility-option]") || []
|
||||
),
|
||||
helper: context.querySelector("[data-visibility-helper]") || null,
|
||||
helperDefault: "",
|
||||
};
|
||||
|
||||
if (this.visibility.helper) {
|
||||
this.visibility.helperDefault = this.visibility.helper.textContent || "";
|
||||
}
|
||||
|
||||
const nip71Context = this.form || context;
|
||||
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.form) {
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
});
|
||||
}
|
||||
|
||||
const cancelHandler = (event) => {
|
||||
event?.preventDefault?.();
|
||||
this.close({ emitCancel: true });
|
||||
};
|
||||
|
||||
if (this.overlay) {
|
||||
this.overlay.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (this.closeButton) {
|
||||
this.closeButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (this.cancelButton) {
|
||||
this.cancelButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (Array.isArray(this.fieldButtons)) {
|
||||
this.fieldButtons.forEach((button) => {
|
||||
if (!button || button.dataset.editListenerAttached === "true") {
|
||||
return;
|
||||
}
|
||||
button.addEventListener("click", (event) => {
|
||||
this.handleEditFieldToggle(event);
|
||||
});
|
||||
button.dataset.editListenerAttached = "true";
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(this.visibility?.buttons)) {
|
||||
this.visibility.buttons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
const option = event?.currentTarget?.dataset?.visibilityOption;
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
this.setVisibility(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fields.isPrivate) {
|
||||
this.fields.isPrivate.addEventListener("change", () => {
|
||||
this.handleIsPrivateChange({ emit: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fields.enableComments) {
|
||||
this.fields.enableComments.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
const value = this.sanitizers.checkbox(target?.checked);
|
||||
const detail = {
|
||||
field: "enableComments",
|
||||
value,
|
||||
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
||||
};
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
});
|
||||
}
|
||||
|
||||
this.nip71FormManager.bindSection(this.nip71SectionKey);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.root) {
|
||||
this.activeVideo = null;
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
this.setSubmitState({ pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = Object.values(this.fields);
|
||||
inputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (input.type === "checkbox") {
|
||||
const defaultAttr = input.dataset?.defaultChecked;
|
||||
const defaultChecked =
|
||||
defaultAttr === "true"
|
||||
? true
|
||||
: defaultAttr === "false"
|
||||
? false
|
||||
: input.defaultChecked;
|
||||
input.checked = defaultChecked;
|
||||
input.disabled = false;
|
||||
input.removeAttribute("disabled");
|
||||
delete input.dataset.isEditing;
|
||||
} else {
|
||||
input.value = "";
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
delete input.dataset.isEditing;
|
||||
}
|
||||
delete input.dataset.originalValue;
|
||||
});
|
||||
|
||||
if (Array.isArray(this.fieldButtons)) {
|
||||
this.fieldButtons.forEach((button) => {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
});
|
||||
}
|
||||
|
||||
this.handleIsPrivateChange({ emit: false });
|
||||
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
this.activeVideo = null;
|
||||
this.setSubmitState({ pending: false });
|
||||
}
|
||||
|
||||
async open(video) {
|
||||
await this.load();
|
||||
|
||||
if (!video) {
|
||||
throw new Error("No video provided for editing.");
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
const magnetSource = video.magnet || video.rawMagnet || "";
|
||||
const magnetHints = extractMagnetHints(magnetSource);
|
||||
const effectiveWs = video.ws || magnetHints.ws || "";
|
||||
const effectiveXs = video.xs || magnetHints.xs || "";
|
||||
const enableCommentsValue =
|
||||
typeof video.enableComments === "boolean" ? video.enableComments : true;
|
||||
const isPrivateValue = video.isPrivate === true;
|
||||
|
||||
const editContext = {
|
||||
...video,
|
||||
ws: effectiveWs,
|
||||
xs: effectiveXs,
|
||||
enableComments: enableCommentsValue,
|
||||
isPrivate: isPrivateValue,
|
||||
};
|
||||
|
||||
this.applyVideoToForm(editContext);
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
if (video.nip71 && typeof video.nip71 === "object") {
|
||||
this.nip71FormManager.hydrateSection(this.nip71SectionKey, video.nip71);
|
||||
}
|
||||
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
|
||||
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
|
||||
this.activeVideo = editContext;
|
||||
|
||||
if (this.root) {
|
||||
this.root.classList.remove("hidden");
|
||||
this.isVisible = true;
|
||||
this.setGlobalModalState("editVideo", true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
applyVideoToForm(editContext) {
|
||||
if (!editContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldMap = {
|
||||
title: editContext.title || "",
|
||||
url: editContext.url || "",
|
||||
magnet: editContext.magnet || "",
|
||||
ws: editContext.ws || "",
|
||||
xs: editContext.xs || "",
|
||||
thumbnail: editContext.thumbnail || "",
|
||||
description: editContext.description || "",
|
||||
isPrivate: editContext.isPrivate,
|
||||
enableComments: editContext.enableComments,
|
||||
};
|
||||
|
||||
Object.entries(fieldMap).forEach(([key, rawValue]) => {
|
||||
const input = this.fields[key];
|
||||
const targetId = this.fieldIdForKey(key);
|
||||
const button = this.fieldButtons.find(
|
||||
(item) => item?.dataset?.editTarget === targetId
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
if (button) {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
input.id = targetId;
|
||||
}
|
||||
|
||||
const isCheckbox = input.type === "checkbox";
|
||||
if (isCheckbox) {
|
||||
const hasValue = rawValue !== undefined;
|
||||
const boolValue = rawValue === true;
|
||||
input.checked = boolValue;
|
||||
input.disabled = hasValue;
|
||||
if (hasValue) {
|
||||
input.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
input.removeAttribute("disabled");
|
||||
}
|
||||
input.dataset.originalValue = boolValue ? "true" : "false";
|
||||
input.dataset.isEditing = hasValue ? "false" : "true";
|
||||
if (button) {
|
||||
if (hasValue) {
|
||||
button.classList.remove("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const value = typeof rawValue === "string" ? rawValue : "";
|
||||
const hasValue = value.trim().length > 0;
|
||||
|
||||
input.value = value;
|
||||
input.dataset.originalValue = value;
|
||||
input.dataset.isEditing = hasValue ? "false" : "true";
|
||||
if (hasValue) {
|
||||
input.readOnly = true;
|
||||
input.setAttribute("readonly", "readonly");
|
||||
input.classList.add("locked-input");
|
||||
} else {
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
}
|
||||
|
||||
if (button) {
|
||||
if (hasValue) {
|
||||
button.classList.remove("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.handleIsPrivateChange({ emit: false });
|
||||
}
|
||||
|
||||
fieldIdForKey(key) {
|
||||
switch (key) {
|
||||
case "title":
|
||||
return "editVideoTitle";
|
||||
case "url":
|
||||
return "editVideoUrl";
|
||||
case "magnet":
|
||||
return "editVideoMagnet";
|
||||
case "ws":
|
||||
return "editVideoWs";
|
||||
case "xs":
|
||||
return "editVideoXs";
|
||||
case "thumbnail":
|
||||
return "editVideoThumbnail";
|
||||
case "description":
|
||||
return "editVideoDescription";
|
||||
case "isPrivate":
|
||||
return "editVideoIsPrivate";
|
||||
case "enableComments":
|
||||
return "editEnableComments";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close({ emitCancel = false } = {}) {
|
||||
if (this.root) {
|
||||
this.root.classList.add("hidden");
|
||||
}
|
||||
this.isVisible = false;
|
||||
this.setGlobalModalState("editVideo", false);
|
||||
const cancelledVideo = this.activeVideo;
|
||||
this.reset();
|
||||
|
||||
if (emitCancel) {
|
||||
this.emit("video:edit-cancel", {
|
||||
videoId: this.escapeHtml(cancelledVideo?.id || ""),
|
||||
pubkey: this.escapeHtml(cancelledVideo?.pubkey || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEditFieldToggle(event) {
|
||||
const button = event?.currentTarget;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = button.dataset?.editTarget;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = this.root?.querySelector(`#${targetId}`);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCheckbox = input.type === "checkbox";
|
||||
const isLocked = isCheckbox
|
||||
? input.disabled === true
|
||||
: input.readOnly === true;
|
||||
|
||||
if (isLocked) {
|
||||
if (isCheckbox) {
|
||||
input.disabled = false;
|
||||
input.removeAttribute("disabled");
|
||||
input.dataset.isEditing = "true";
|
||||
} else {
|
||||
input.disabled = false;
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
input.dataset.isEditing = "true";
|
||||
}
|
||||
button.dataset.mode = "editing";
|
||||
button.textContent = "Restore original";
|
||||
if (!isCheckbox && typeof input.focus === "function") {
|
||||
input.focus();
|
||||
if (typeof input.setSelectionRange === "function") {
|
||||
const length = input.value.length;
|
||||
try {
|
||||
input.setSelectionRange(length, length);
|
||||
} catch (error) {
|
||||
// Ignore selection errors (e.g. unsupported input types)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input === this.fields.isPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const originalValue = input.dataset?.originalValue || "";
|
||||
|
||||
if (isCheckbox) {
|
||||
input.checked = originalValue === "true";
|
||||
input.disabled = true;
|
||||
input.setAttribute("disabled", "disabled");
|
||||
input.dataset.isEditing = "false";
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
if (input === this.fields.isPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = originalValue;
|
||||
|
||||
if (originalValue) {
|
||||
input.readOnly = true;
|
||||
input.setAttribute("readonly", "readonly");
|
||||
input.classList.add("locked-input");
|
||||
input.dataset.isEditing = "false";
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
input.dataset.isEditing = "true";
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
|
||||
setVisibility(option, { emit = true } = {}) {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = typeof option === "string" ? option.toLowerCase() : "";
|
||||
if (normalized !== "public" && normalized !== "private") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkbox.disabled) {
|
||||
this.updateVisibilityToggleUI();
|
||||
return;
|
||||
}
|
||||
|
||||
const wantPrivate = normalized === "private";
|
||||
if (checkbox.checked === wantPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
return;
|
||||
}
|
||||
|
||||
checkbox.checked = wantPrivate;
|
||||
this.handleIsPrivateChange({ emit });
|
||||
}
|
||||
|
||||
updateVisibilityToggleUI() {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
const buttons = Array.isArray(this.visibility?.buttons)
|
||||
? this.visibility.buttons
|
||||
: [];
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPrivate = checkbox.checked === true;
|
||||
const disabled = checkbox.disabled === true;
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const option = button.dataset?.visibilityOption || "";
|
||||
const normalized = option.toLowerCase();
|
||||
const isActive =
|
||||
(normalized === "private" && isPrivate) ||
|
||||
(normalized === "public" && !isPrivate);
|
||||
button.dataset.active = isActive ? "true" : "false";
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
if (disabled) {
|
||||
button.classList.add("is-disabled");
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.tabIndex = -1;
|
||||
} else {
|
||||
button.classList.remove("is-disabled");
|
||||
button.removeAttribute("aria-disabled");
|
||||
button.tabIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.visibility?.container) {
|
||||
this.visibility.container.dataset.state = isPrivate ? "private" : "public";
|
||||
if (disabled) {
|
||||
this.visibility.container.dataset.disabled = "true";
|
||||
} else {
|
||||
delete this.visibility.container.dataset.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
const helper = this.visibility?.helper;
|
||||
if (helper) {
|
||||
const privateCopy = this.visibility.helperDefault || helper.textContent;
|
||||
const publicCopy =
|
||||
"Public notes appear in feeds for everyone who can view your channel.";
|
||||
helper.textContent = isPrivate ? privateCopy : publicCopy;
|
||||
}
|
||||
}
|
||||
|
||||
handleIsPrivateChange({ emit = true } = {}) {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateVisibilityToggleUI();
|
||||
|
||||
if (!emit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this.sanitizers.checkbox(checkbox.checked);
|
||||
const detail = {
|
||||
field: "isPrivate",
|
||||
value,
|
||||
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
||||
};
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
}
|
||||
|
||||
cloneNip71Metadata(metadata) {
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(metadata));
|
||||
} catch (error) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.pendingSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeVideo || !this.root) {
|
||||
this.showError("No video selected for editing.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSubmitState({ pending: true, video: this.activeVideo });
|
||||
|
||||
const fieldValue = (key) => {
|
||||
const input = this.fields[key];
|
||||
if (!input || typeof input.value !== "string") {
|
||||
return "";
|
||||
}
|
||||
if (key === "url" || key === "ws" || key === "xs") {
|
||||
return this.sanitizers.url(input.value);
|
||||
}
|
||||
if (key === "magnet") {
|
||||
return this.sanitizers.magnet(input.value);
|
||||
}
|
||||
return this.sanitizers.text(input.value);
|
||||
};
|
||||
|
||||
const original = this.pendingSubmitVideo || this.activeVideo;
|
||||
|
||||
const titleInput = this.fields.title;
|
||||
const urlInput = this.fields.url;
|
||||
const magnetInput = this.fields.magnet;
|
||||
const wsInput = this.fields.ws;
|
||||
const xsInput = this.fields.xs;
|
||||
const thumbnailInput = this.fields.thumbnail;
|
||||
const descriptionInput = this.fields.description;
|
||||
const commentsInput = this.fields.enableComments;
|
||||
const privateInput = this.fields.isPrivate;
|
||||
|
||||
const newTitle = fieldValue("title");
|
||||
const newUrl = fieldValue("url");
|
||||
const newMagnet = fieldValue("magnet");
|
||||
const newWs = fieldValue("ws");
|
||||
const newXs = fieldValue("xs");
|
||||
const newThumbnail = fieldValue("thumbnail");
|
||||
const newDescription = fieldValue("description");
|
||||
|
||||
const isEditing = (input) => {
|
||||
if (!input) {
|
||||
return true;
|
||||
}
|
||||
if (input.dataset?.isEditing === "true") {
|
||||
return true;
|
||||
}
|
||||
if (input.type === "checkbox") {
|
||||
return input.disabled === false;
|
||||
}
|
||||
return input.readOnly === false;
|
||||
};
|
||||
|
||||
const titleWasEdited = isEditing(titleInput);
|
||||
const urlWasEdited = isEditing(urlInput);
|
||||
const magnetWasEdited = isEditing(magnetInput);
|
||||
|
||||
const finalTitle = titleWasEdited ? newTitle : original.title || "";
|
||||
const finalUrl = urlWasEdited ? newUrl : original.url || "";
|
||||
const shouldUseOriginalWs = wsInput ? wsInput.readOnly !== false : true;
|
||||
const shouldUseOriginalXs = xsInput ? xsInput.readOnly !== false : true;
|
||||
let finalWs = shouldUseOriginalWs ? original.ws || "" : newWs;
|
||||
let finalXs = shouldUseOriginalXs ? original.xs || "" : newXs;
|
||||
let finalMagnet = magnetWasEdited ? newMagnet : original.magnet || "";
|
||||
const finalThumbnail = isEditing(thumbnailInput)
|
||||
? newThumbnail
|
||||
: original.thumbnail || "";
|
||||
const finalDescription = isEditing(descriptionInput)
|
||||
? newDescription
|
||||
: original.description || "";
|
||||
const originalEnableComments =
|
||||
typeof original.enableComments === "boolean" ? original.enableComments : true;
|
||||
const originalIsPrivate = original.isPrivate === true;
|
||||
|
||||
const currentNip71 = this.cloneNip71Metadata(
|
||||
this.nip71FormManager.collectSection(this.nip71SectionKey)
|
||||
);
|
||||
const hasImetaSource = Array.isArray(currentNip71?.imeta)
|
||||
? currentNip71.imeta.some((variant) => {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return false;
|
||||
}
|
||||
const hasUrl = typeof variant.url === "string" && variant.url.trim();
|
||||
const hasMagnet = typeof variant.x === "string" && variant.x.trim();
|
||||
return Boolean(hasUrl || hasMagnet);
|
||||
})
|
||||
: false;
|
||||
|
||||
let finalEnableComments = originalEnableComments;
|
||||
if (commentsInput) {
|
||||
if (commentsInput.disabled) {
|
||||
finalEnableComments = commentsInput.dataset.originalValue === "true";
|
||||
} else {
|
||||
finalEnableComments = this.sanitizers.checkbox(commentsInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
let finalIsPrivate = originalIsPrivate;
|
||||
if (privateInput) {
|
||||
if (privateInput.disabled) {
|
||||
finalIsPrivate = privateInput.dataset.originalValue === "true";
|
||||
} else {
|
||||
finalIsPrivate = this.sanitizers.checkbox(privateInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalTitle || (!finalUrl && !finalMagnet && !hasImetaSource)) {
|
||||
this.showError(
|
||||
"Title and at least one of URL, Magnet, or a NIP-71 media variant is required."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
|
||||
this.showError("Hosted video URLs must use HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalMagnet) {
|
||||
const normalizedMagnet = normalizeAndAugmentMagnet(finalMagnet, {
|
||||
ws: finalWs,
|
||||
xs: finalXs,
|
||||
});
|
||||
finalMagnet = normalizedMagnet;
|
||||
const hints = extractMagnetHints(normalizedMagnet);
|
||||
finalWs = hints.ws;
|
||||
finalXs = hints.xs;
|
||||
} else {
|
||||
finalWs = "";
|
||||
finalXs = "";
|
||||
}
|
||||
|
||||
const updatedData = {
|
||||
version: original.version || 2,
|
||||
title: finalTitle,
|
||||
magnet: finalMagnet,
|
||||
url: finalUrl,
|
||||
thumbnail: finalThumbnail,
|
||||
description: finalDescription,
|
||||
mode: this.getMode(),
|
||||
ws: finalWs,
|
||||
xs: finalXs,
|
||||
wsEdited: !shouldUseOriginalWs,
|
||||
xsEdited: !shouldUseOriginalXs,
|
||||
urlEdited: urlWasEdited,
|
||||
magnetEdited: magnetWasEdited,
|
||||
enableComments: finalEnableComments,
|
||||
isPrivate: finalIsPrivate,
|
||||
};
|
||||
|
||||
const currentNip71Json = JSON.stringify(currentNip71);
|
||||
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
|
||||
updatedData.nip71 = currentNip71;
|
||||
updatedData.nip71Edited = nip71Edited;
|
||||
|
||||
const originalEvent = {
|
||||
id: this.sanitizers.text(original.id || ""),
|
||||
pubkey: this.sanitizers.text(original.pubkey || ""),
|
||||
videoRootId: this.sanitizers.text(original.videoRootId || ""),
|
||||
};
|
||||
|
||||
this.emit("video:edit-submit", {
|
||||
originalEvent,
|
||||
updatedData,
|
||||
video: { ...original },
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitState({ pending = false, video } = {}) {
|
||||
const nextPending = Boolean(pending);
|
||||
if (nextPending) {
|
||||
this.pendingSubmitVideo = video || this.activeVideo || this.pendingSubmitVideo;
|
||||
} else {
|
||||
this.pendingSubmitVideo = null;
|
||||
}
|
||||
|
||||
this.pendingSubmit = nextPending;
|
||||
this.updateSubmitButtonState();
|
||||
}
|
||||
|
||||
updateSubmitButtonState() {
|
||||
if (!this.submitButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingSubmit) {
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
this.submitButton.disabled = false;
|
||||
this.submitButton.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,765 @@
|
||||
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
|
||||
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
|
||||
export class UploadModal {
|
||||
constructor({
|
||||
authService,
|
||||
r2Service,
|
||||
publishVideoNote,
|
||||
removeTrackingScripts,
|
||||
setGlobalModalState,
|
||||
showError,
|
||||
showSuccess,
|
||||
getCurrentPubkey,
|
||||
safeEncodeNpub,
|
||||
eventTarget,
|
||||
container,
|
||||
} = {}) {
|
||||
this.authService = authService || null;
|
||||
this.r2Service = r2Service || null;
|
||||
this.publishVideoNote = typeof publishVideoNote === "function" ? publishVideoNote : null;
|
||||
this.removeTrackingScripts =
|
||||
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
|
||||
this.setGlobalModalState =
|
||||
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
||||
this.showError = typeof showError === "function" ? showError : () => {};
|
||||
this.showSuccess = typeof showSuccess === "function" ? showSuccess : () => {};
|
||||
this.getCurrentPubkey = typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
|
||||
this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
|
||||
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
this.container = container || null;
|
||||
|
||||
this.root = null;
|
||||
this.activeMode = "custom";
|
||||
this.isVisible = false;
|
||||
this.cloudflareSettings = this.r2Service?.getSettings?.() || null;
|
||||
this.cloudflareAdvancedVisible =
|
||||
this.r2Service?.getCloudflareAdvancedVisibility?.() || false;
|
||||
this.r2Unsubscribes = [];
|
||||
|
||||
this.uploadModeButtons = [];
|
||||
this.customSection = null;
|
||||
this.cloudflareSection = null;
|
||||
|
||||
this.customForm = null;
|
||||
this.customFormInputs = {};
|
||||
|
||||
this.cloudflareSettingsForm = null;
|
||||
this.cloudflareClearSettingsButton = null;
|
||||
this.cloudflareSettingsStatus = null;
|
||||
this.cloudflareBucketPreview = null;
|
||||
this.cloudflareUploadForm = null;
|
||||
this.cloudflareFileInput = null;
|
||||
this.cloudflareUploadButton = null;
|
||||
this.cloudflareUploadStatus = null;
|
||||
this.cloudflareProgressBar = null;
|
||||
this.cloudflareProgressFill = null;
|
||||
this.cloudflareTitleInput = null;
|
||||
this.cloudflareDescriptionInput = null;
|
||||
this.cloudflareThumbnailInput = null;
|
||||
this.cloudflareMagnetInput = null;
|
||||
this.cloudflareWsInput = null;
|
||||
this.cloudflareXsInput = null;
|
||||
this.cloudflareEnableCommentsInput = null;
|
||||
this.cloudflareAdvancedToggle = null;
|
||||
this.cloudflareAdvancedToggleLabel = null;
|
||||
this.cloudflareAdvancedToggleIcon = null;
|
||||
this.cloudflareAdvancedFields = null;
|
||||
this.r2AccountIdInput = null;
|
||||
this.r2AccessKeyIdInput = null;
|
||||
this.r2SecretAccessKeyInput = null;
|
||||
this.r2ApiTokenInput = null;
|
||||
this.r2ZoneIdInput = null;
|
||||
this.r2BaseDomainInput = null;
|
||||
|
||||
this.nip71FormManager = new Nip71FormManager();
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
if (!this.eventTarget) {
|
||||
return;
|
||||
}
|
||||
this.eventTarget.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener, options) {
|
||||
if (!this.eventTarget) {
|
||||
return;
|
||||
}
|
||||
this.eventTarget.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
emit(type, detail) {
|
||||
if (!this.eventTarget) {
|
||||
return;
|
||||
}
|
||||
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
async load({ container } = {}) {
|
||||
if (this.root) {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
const targetContainer =
|
||||
container || this.container || document.getElementById("modalContainer");
|
||||
if (!targetContainer) {
|
||||
throw new Error("Modal container element not found!");
|
||||
}
|
||||
|
||||
const response = await fetch("components/upload-modal.html");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
this.removeTrackingScripts(wrapper);
|
||||
targetContainer.appendChild(wrapper);
|
||||
|
||||
this.container = targetContainer;
|
||||
this.root = wrapper.querySelector("#uploadModal");
|
||||
if (!this.root) {
|
||||
throw new Error("Upload modal markup missing after load.");
|
||||
}
|
||||
|
||||
this.cacheElements(wrapper);
|
||||
this.bindEvents();
|
||||
this.registerR2Subscriptions();
|
||||
|
||||
this.renderCloudflareAdvancedVisibility(
|
||||
this.r2Service?.getCloudflareAdvancedVisibility?.()
|
||||
);
|
||||
|
||||
try {
|
||||
await this.loadR2Settings();
|
||||
} catch (error) {
|
||||
// Errors already surfaced via the service listeners.
|
||||
}
|
||||
|
||||
await this.refreshCloudflareBucketPreview();
|
||||
this.setMode(this.activeMode);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
|
||||
return this.root;
|
||||
}
|
||||
|
||||
cacheElements(context) {
|
||||
this.uploadModeButtons = Array.from(
|
||||
context.querySelectorAll(".upload-mode-toggle[data-upload-mode]")
|
||||
);
|
||||
this.customSection = context.querySelector("#customUploadSection") || null;
|
||||
this.cloudflareSection = context.querySelector("#cloudflareUploadSection") || null;
|
||||
|
||||
this.customForm = context.querySelector("#uploadForm") || null;
|
||||
this.customFormInputs = {
|
||||
title: context.querySelector("#uploadTitle") || null,
|
||||
url: context.querySelector("#uploadUrl") || null,
|
||||
magnet: context.querySelector("#uploadMagnet") || null,
|
||||
ws: context.querySelector("#uploadWs") || null,
|
||||
xs: context.querySelector("#uploadXs") || null,
|
||||
thumbnail: context.querySelector("#uploadThumbnail") || null,
|
||||
description: context.querySelector("#uploadDescription") || null,
|
||||
enableComments: context.querySelector("#uploadEnableComments") || null,
|
||||
isPrivate: context.querySelector("#uploadIsPrivate") || null,
|
||||
};
|
||||
|
||||
this.closeButton = context.querySelector("#closeUploadModal") || null;
|
||||
|
||||
this.cloudflareSettingsForm = context.querySelector("#cloudflareSettingsForm") || null;
|
||||
this.cloudflareClearSettingsButton =
|
||||
context.querySelector("#cloudflareClearSettings") || null;
|
||||
this.cloudflareSettingsStatus =
|
||||
context.querySelector("#cloudflareSettingsStatus") || null;
|
||||
this.cloudflareBucketPreview =
|
||||
context.querySelector("#cloudflareBucketPreview") || null;
|
||||
this.cloudflareUploadForm = context.querySelector("#cloudflareUploadForm") || null;
|
||||
this.cloudflareFileInput = context.querySelector("#cloudflareFile") || null;
|
||||
this.cloudflareUploadButton = context.querySelector("#cloudflareUploadButton") || null;
|
||||
this.cloudflareUploadStatus =
|
||||
context.querySelector("#cloudflareUploadStatus") || null;
|
||||
this.cloudflareProgressBar = context.querySelector("#cloudflareProgressBar") || null;
|
||||
this.cloudflareProgressFill = context.querySelector("#cloudflareProgressFill") || null;
|
||||
this.cloudflareTitleInput = context.querySelector("#cloudflareTitle") || null;
|
||||
this.cloudflareDescriptionInput =
|
||||
context.querySelector("#cloudflareDescription") || null;
|
||||
this.cloudflareThumbnailInput =
|
||||
context.querySelector("#cloudflareThumbnail") || null;
|
||||
this.cloudflareMagnetInput = context.querySelector("#cloudflareMagnet") || null;
|
||||
this.cloudflareWsInput = context.querySelector("#cloudflareWs") || null;
|
||||
this.cloudflareXsInput = context.querySelector("#cloudflareXs") || null;
|
||||
this.cloudflareEnableCommentsInput =
|
||||
context.querySelector("#cloudflareEnableComments") || null;
|
||||
this.cloudflareAdvancedToggle =
|
||||
context.querySelector("#cloudflareAdvancedToggle") || null;
|
||||
this.cloudflareAdvancedToggleLabel =
|
||||
context.querySelector("#cloudflareAdvancedToggleLabel") || null;
|
||||
this.cloudflareAdvancedToggleIcon =
|
||||
context.querySelector("#cloudflareAdvancedToggleIcon") || null;
|
||||
this.cloudflareAdvancedFields =
|
||||
context.querySelector("#cloudflareAdvancedFields") || null;
|
||||
this.r2AccountIdInput = context.querySelector("#r2AccountId") || null;
|
||||
this.r2AccessKeyIdInput = context.querySelector("#r2AccessKeyId") || null;
|
||||
this.r2SecretAccessKeyInput = context.querySelector("#r2SecretAccessKey") || null;
|
||||
this.r2ApiTokenInput = context.querySelector("#r2ApiToken") || null;
|
||||
this.r2ZoneIdInput = context.querySelector("#r2ZoneId") || null;
|
||||
this.r2BaseDomainInput = context.querySelector("#r2BaseDomain") || null;
|
||||
|
||||
this.nip71FormManager.registerSection("custom", this.customSection);
|
||||
this.nip71FormManager.registerSection("cloudflare", this.cloudflareSection);
|
||||
}
|
||||
|
||||
buildAutoGeneratedImetaVariant(file) {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = typeof file.type === "string" ? file.type.trim() : "";
|
||||
if (!mimeType) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
m: mimeType,
|
||||
dim: "",
|
||||
url: "",
|
||||
x: "",
|
||||
image: [],
|
||||
fallback: [],
|
||||
service: [],
|
||||
autoGenerated: true,
|
||||
};
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.closeButton) {
|
||||
this.closeButton.addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.customForm) {
|
||||
this.customForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.handleCustomSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(this.uploadModeButtons)) {
|
||||
this.uploadModeButtons.forEach((button) => {
|
||||
if (!button) return;
|
||||
button.addEventListener("click", () => {
|
||||
const mode = button.dataset.uploadMode || "custom";
|
||||
this.setMode(mode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareSettingsForm) {
|
||||
this.cloudflareSettingsForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await this.handleCloudflareSettingsSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareClearSettingsButton) {
|
||||
this.cloudflareClearSettingsButton.addEventListener("click", async () => {
|
||||
await this.handleCloudflareClearSettings();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareUploadForm) {
|
||||
this.cloudflareUploadForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await this.handleCloudflareUploadSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cloudflareAdvancedToggle) {
|
||||
this.cloudflareAdvancedToggle.addEventListener("click", () => {
|
||||
if (this.r2Service?.setCloudflareAdvancedVisibility &&
|
||||
this.r2Service?.getCloudflareAdvancedVisibility) {
|
||||
const nextState = !this.r2Service.getCloudflareAdvancedVisibility();
|
||||
this.r2Service.setCloudflareAdvancedVisibility(nextState);
|
||||
} else {
|
||||
this.renderCloudflareAdvancedVisibility(!this.cloudflareAdvancedVisible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.nip71FormManager.bindSection("custom");
|
||||
this.nip71FormManager.bindSection("cloudflare");
|
||||
}
|
||||
|
||||
registerR2Subscriptions() {
|
||||
if (!this.r2Service?.on) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(this.r2Unsubscribes)) {
|
||||
this.r2Unsubscribes.forEach((unsubscribe) => {
|
||||
try {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[UploadModal] Failed to remove R2 listener", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.r2Unsubscribes = [];
|
||||
|
||||
const register = (unsubscribe) => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
this.r2Unsubscribes.push(unsubscribe);
|
||||
}
|
||||
};
|
||||
|
||||
register(
|
||||
this.r2Service.on("advancedVisibilityChange", ({ visible } = {}) => {
|
||||
this.renderCloudflareAdvancedVisibility(visible);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("settingsStatus", ({ message, variant } = {}) => {
|
||||
this.applyCloudflareStatus(this.cloudflareSettingsStatus, message, variant);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("uploadStatus", ({ message, variant } = {}) => {
|
||||
this.applyCloudflareStatus(this.cloudflareUploadStatus, message, variant);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("uploadStateChange", ({ isUploading } = {}) => {
|
||||
this.renderCloudflareUploadingState(isUploading);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("uploadProgress", ({ fraction } = {}) => {
|
||||
this.updateCloudflareProgress(fraction);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("settingsPopulated", ({ settings } = {}) => {
|
||||
this.fillCloudflareSettingsInputs(settings);
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("settingsChanged", ({ settings } = {}) => {
|
||||
this.cloudflareSettings = settings || this.cloudflareSettings;
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
this.r2Service.on("bucketPreview", (detail = {}) => {
|
||||
this.renderCloudflareBucketPreview(detail);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!this.root) {
|
||||
return;
|
||||
}
|
||||
if (!this.root.classList.contains("hidden")) {
|
||||
this.isVisible = true;
|
||||
return;
|
||||
}
|
||||
this.root.classList.remove("hidden");
|
||||
this.isVisible = true;
|
||||
this.setGlobalModalState("upload", true);
|
||||
this.emit("upload:open", { mode: this.activeMode });
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.root) {
|
||||
return;
|
||||
}
|
||||
if (this.root.classList.contains("hidden")) {
|
||||
this.isVisible = false;
|
||||
return;
|
||||
}
|
||||
this.root.classList.add("hidden");
|
||||
this.isVisible = false;
|
||||
this.setGlobalModalState("upload", false);
|
||||
this.emit("upload:close", { mode: this.activeMode });
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
const normalized = mode === "cloudflare" ? "cloudflare" : "custom";
|
||||
this.activeMode = normalized;
|
||||
|
||||
if (this.customSection) {
|
||||
if (normalized === "custom") {
|
||||
this.customSection.classList.remove("hidden");
|
||||
} else {
|
||||
this.customSection.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cloudflareSection) {
|
||||
if (normalized === "cloudflare") {
|
||||
this.cloudflareSection.classList.remove("hidden");
|
||||
} else {
|
||||
this.cloudflareSection.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(this.uploadModeButtons)) {
|
||||
this.uploadModeButtons.forEach((button) => {
|
||||
if (!button?.dataset) {
|
||||
return;
|
||||
}
|
||||
const isActive = button.dataset.uploadMode === normalized;
|
||||
button.classList.toggle("bg-blue-500", isActive);
|
||||
button.classList.toggle("text-white", isActive);
|
||||
button.classList.toggle("shadow", isActive);
|
||||
button.classList.toggle("text-gray-300", !isActive);
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized === "cloudflare") {
|
||||
this.refreshCloudflareBucketPreview();
|
||||
}
|
||||
}
|
||||
|
||||
handleCustomSubmit() {
|
||||
const payload = {
|
||||
title: this.customFormInputs.title?.value?.trim() || "",
|
||||
url: this.customFormInputs.url?.value?.trim() || "",
|
||||
magnet: this.customFormInputs.magnet?.value?.trim() || "",
|
||||
ws: this.customFormInputs.ws?.value?.trim() || "",
|
||||
xs: this.customFormInputs.xs?.value?.trim() || "",
|
||||
thumbnail: this.customFormInputs.thumbnail?.value?.trim() || "",
|
||||
description: this.customFormInputs.description?.value?.trim() || "",
|
||||
enableComments: this.customFormInputs.enableComments
|
||||
? !!this.customFormInputs.enableComments.checked
|
||||
: true,
|
||||
};
|
||||
|
||||
if (this.customFormInputs.isPrivate) {
|
||||
payload.isPrivate = !!this.customFormInputs.isPrivate.checked;
|
||||
}
|
||||
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("custom");
|
||||
if (nip71Metadata) {
|
||||
payload.nip71 = nip71Metadata;
|
||||
}
|
||||
|
||||
this.emit("upload:submit", { payload });
|
||||
}
|
||||
|
||||
collectCloudflareSettingsFormValues() {
|
||||
return {
|
||||
accountId: this.r2AccountIdInput?.value?.trim() || "",
|
||||
accessKeyId: this.r2AccessKeyIdInput?.value?.trim() || "",
|
||||
secretAccessKey: this.r2SecretAccessKeyInput?.value?.trim() || "",
|
||||
apiToken: this.r2ApiTokenInput?.value?.trim() || "",
|
||||
zoneId: this.r2ZoneIdInput?.value?.trim() || "",
|
||||
baseDomain: this.r2BaseDomainInput?.value || "",
|
||||
};
|
||||
}
|
||||
|
||||
applyCloudflareStatus(element, message = "", variant = "info") {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = message || "";
|
||||
element.classList.remove(
|
||||
"text-green-400",
|
||||
"text-red-400",
|
||||
"text-yellow-400",
|
||||
"text-gray-400"
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
element.classList.add("text-gray-400");
|
||||
return;
|
||||
}
|
||||
|
||||
let className = "text-gray-400";
|
||||
if (variant === "success") {
|
||||
className = "text-green-400";
|
||||
} else if (variant === "error") {
|
||||
className = "text-red-400";
|
||||
} else if (variant === "warning") {
|
||||
className = "text-yellow-400";
|
||||
}
|
||||
element.classList.add(className);
|
||||
}
|
||||
|
||||
renderCloudflareAdvancedVisibility(visible) {
|
||||
const isVisible = Boolean(visible);
|
||||
this.cloudflareAdvancedVisible = isVisible;
|
||||
|
||||
if (this.cloudflareAdvancedFields) {
|
||||
if (isVisible) {
|
||||
this.cloudflareAdvancedFields.classList.remove("hidden");
|
||||
} else {
|
||||
this.cloudflareAdvancedFields.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cloudflareAdvancedToggle) {
|
||||
this.cloudflareAdvancedToggle.setAttribute(
|
||||
"aria-expanded",
|
||||
isVisible ? "true" : "false"
|
||||
);
|
||||
}
|
||||
|
||||
if (this.cloudflareAdvancedToggleLabel) {
|
||||
this.cloudflareAdvancedToggleLabel.textContent = isVisible
|
||||
? "Hide advanced options"
|
||||
: "Show advanced options";
|
||||
}
|
||||
|
||||
if (this.cloudflareAdvancedToggleIcon) {
|
||||
this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
renderCloudflareUploadingState(isUploading) {
|
||||
if (this.cloudflareUploadButton) {
|
||||
this.cloudflareUploadButton.disabled = Boolean(isUploading);
|
||||
this.cloudflareUploadButton.textContent = isUploading
|
||||
? "Uploading…"
|
||||
: "Upload to R2 & publish";
|
||||
}
|
||||
|
||||
if (this.cloudflareFileInput) {
|
||||
this.cloudflareFileInput.disabled = Boolean(isUploading);
|
||||
}
|
||||
|
||||
if (this.cloudflareEnableCommentsInput) {
|
||||
this.cloudflareEnableCommentsInput.disabled = Boolean(isUploading);
|
||||
}
|
||||
}
|
||||
|
||||
fillCloudflareSettingsInputs(settings) {
|
||||
const data = settings || {};
|
||||
|
||||
if (this.r2AccountIdInput) {
|
||||
this.r2AccountIdInput.value = data.accountId || "";
|
||||
}
|
||||
if (this.r2AccessKeyIdInput) {
|
||||
this.r2AccessKeyIdInput.value = data.accessKeyId || "";
|
||||
}
|
||||
if (this.r2SecretAccessKeyInput) {
|
||||
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
|
||||
}
|
||||
if (this.r2ApiTokenInput) {
|
||||
this.r2ApiTokenInput.value = data.apiToken || "";
|
||||
}
|
||||
if (this.r2ZoneIdInput) {
|
||||
this.r2ZoneIdInput.value = data.zoneId || "";
|
||||
}
|
||||
if (this.r2BaseDomainInput) {
|
||||
this.r2BaseDomainInput.value = data.baseDomain || "";
|
||||
}
|
||||
}
|
||||
|
||||
renderCloudflareBucketPreview({ text = "", title = "" } = {}) {
|
||||
if (!this.cloudflareBucketPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cloudflareBucketPreview.textContent = text || "";
|
||||
if (title) {
|
||||
this.cloudflareBucketPreview.setAttribute("title", title);
|
||||
} else {
|
||||
this.cloudflareBucketPreview.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
|
||||
async refreshCloudflareBucketPreview() {
|
||||
if (!this.r2Service?.updateCloudflareBucketPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
(this.getCurrentPubkey && this.getCurrentPubkey()) ||
|
||||
(this.authService?.getActivePubkey?.() ?? null);
|
||||
const hasPubkey = Boolean(pubkey);
|
||||
const npub = hasPubkey ? this.safeEncodeNpub(pubkey) : "";
|
||||
|
||||
await this.r2Service.updateCloudflareBucketPreview({ hasPubkey, npub });
|
||||
}
|
||||
|
||||
updateCloudflareProgress(fraction) {
|
||||
if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) {
|
||||
this.emit("upload:r2-progress", { fraction: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(fraction) || fraction < 0) {
|
||||
this.cloudflareProgressBar.classList.add("hidden");
|
||||
this.cloudflareProgressBar.setAttribute("aria-hidden", "true");
|
||||
this.cloudflareProgressFill.style.width = "0%";
|
||||
this.cloudflareProgressFill.setAttribute("aria-valuenow", "0");
|
||||
this.emit("upload:r2-progress", { fraction: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, fraction));
|
||||
const percent = Math.round(clamped * 100);
|
||||
|
||||
this.cloudflareProgressBar.classList.remove("hidden");
|
||||
this.cloudflareProgressBar.setAttribute("aria-hidden", "false");
|
||||
this.cloudflareProgressFill.style.width = `${percent}%`;
|
||||
this.cloudflareProgressFill.setAttribute("aria-valuenow", `${percent}`);
|
||||
this.emit("upload:r2-progress", { fraction: clamped });
|
||||
}
|
||||
|
||||
resetCloudflareUploadForm() {
|
||||
if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = "";
|
||||
if (this.cloudflareDescriptionInput) this.cloudflareDescriptionInput.value = "";
|
||||
if (this.cloudflareThumbnailInput) this.cloudflareThumbnailInput.value = "";
|
||||
if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = "";
|
||||
if (this.cloudflareWsInput) this.cloudflareWsInput.value = "";
|
||||
if (this.cloudflareXsInput) this.cloudflareXsInput.value = "";
|
||||
if (this.cloudflareEnableCommentsInput)
|
||||
this.cloudflareEnableCommentsInput.checked = true;
|
||||
if (this.cloudflareFileInput) this.cloudflareFileInput.value = "";
|
||||
this.nip71FormManager.resetSection("cloudflare");
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
}
|
||||
|
||||
resetCustomForm() {
|
||||
if (this.customFormInputs.title) this.customFormInputs.title.value = "";
|
||||
if (this.customFormInputs.url) this.customFormInputs.url.value = "";
|
||||
if (this.customFormInputs.magnet) this.customFormInputs.magnet.value = "";
|
||||
if (this.customFormInputs.ws) this.customFormInputs.ws.value = "";
|
||||
if (this.customFormInputs.xs) this.customFormInputs.xs.value = "";
|
||||
if (this.customFormInputs.thumbnail) this.customFormInputs.thumbnail.value = "";
|
||||
if (this.customFormInputs.description)
|
||||
this.customFormInputs.description.value = "";
|
||||
if (this.customFormInputs.enableComments)
|
||||
this.customFormInputs.enableComments.checked = true;
|
||||
if (this.customFormInputs.isPrivate)
|
||||
this.customFormInputs.isPrivate.checked = false;
|
||||
this.nip71FormManager.resetSection("custom");
|
||||
}
|
||||
|
||||
async loadR2Settings() {
|
||||
if (!this.r2Service?.loadSettings) {
|
||||
return null;
|
||||
}
|
||||
const settings = await this.r2Service.loadSettings();
|
||||
this.cloudflareSettings = settings;
|
||||
return settings;
|
||||
}
|
||||
|
||||
async handleCloudflareSettingsSubmit() {
|
||||
if (!this.r2Service?.saveSettings) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const formValues = this.collectCloudflareSettingsFormValues();
|
||||
const saved = await this.r2Service.saveSettings(formValues);
|
||||
if (saved) {
|
||||
await this.refreshCloudflareBucketPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[UploadModal] Failed to save Cloudflare settings", error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloudflareClearSettings() {
|
||||
if (!this.r2Service?.clearSettings) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cleared = await this.r2Service.clearSettings();
|
||||
if (cleared) {
|
||||
await this.refreshCloudflareBucketPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[UploadModal] Failed to clear Cloudflare settings", error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloudflareUploadSubmit() {
|
||||
if (!this.r2Service?.uploadVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
(this.getCurrentPubkey && this.getCurrentPubkey()) ||
|
||||
(this.authService?.getActivePubkey?.() ?? null);
|
||||
if (!pubkey) {
|
||||
this.showError("Please login to post a video.");
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(pubkey) || "";
|
||||
const file = this.cloudflareFileInput?.files?.[0] || null;
|
||||
const metadata = {
|
||||
title: this.cloudflareTitleInput?.value?.trim() || "",
|
||||
description: this.cloudflareDescriptionInput?.value?.trim() || "",
|
||||
thumbnail: this.cloudflareThumbnailInput?.value?.trim() || "",
|
||||
magnet: this.cloudflareMagnetInput?.value?.trim() || "",
|
||||
ws: this.cloudflareWsInput?.value?.trim() || "",
|
||||
xs: this.cloudflareXsInput?.value?.trim() || "",
|
||||
enableComments: this.cloudflareEnableCommentsInput
|
||||
? !!this.cloudflareEnableCommentsInput.checked
|
||||
: true,
|
||||
};
|
||||
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("cloudflare");
|
||||
if (nip71Metadata) {
|
||||
const imetaList = Array.isArray(nip71Metadata.imeta)
|
||||
? [...nip71Metadata.imeta]
|
||||
: [];
|
||||
const autoImeta = this.buildAutoGeneratedImetaVariant(file);
|
||||
if (autoImeta && !imetaList.some((variant) => variant?.autoGenerated)) {
|
||||
imetaList.push(autoImeta);
|
||||
}
|
||||
metadata.nip71 = {
|
||||
...nip71Metadata,
|
||||
imeta: imetaList,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.r2Service.uploadVideo({
|
||||
npub,
|
||||
file,
|
||||
metadata,
|
||||
settingsInput: this.collectCloudflareSettingsFormValues(),
|
||||
publishVideoNote: (payload, options) =>
|
||||
this.publishVideoNote ? this.publishVideoNote(payload, options) : null,
|
||||
onReset: () => this.resetCloudflareUploadForm(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UploadModal] Cloudflare upload failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (Array.isArray(this.r2Unsubscribes)) {
|
||||
this.r2Unsubscribes.forEach((unsubscribe) => {
|
||||
try {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[UploadModal] Failed to cleanup R2 listener", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.r2Unsubscribes = [];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,782 @@
|
||||
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
|
||||
|
||||
const REPEATER_KEYS = ["imeta", "text-track", "segment", "t", "p", "r"];
|
||||
|
||||
export class Nip71FormManager {
|
||||
constructor({ defaultFocusSelector } = {}) {
|
||||
this.defaultFocusSelector =
|
||||
typeof defaultFocusSelector === "string"
|
||||
? defaultFocusSelector
|
||||
: "[data-nip71-field], [data-nip71-nested-field]";
|
||||
this.sections = new Map();
|
||||
}
|
||||
|
||||
registerSection(key, root) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
this.sections.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = this.cacheSection(root);
|
||||
this.sections.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
bindSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store?.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.handlers?.click) {
|
||||
try {
|
||||
store.root.removeEventListener("click", store.handlers.click);
|
||||
} catch (error) {
|
||||
console.warn("[Nip71FormManager] Failed to detach previous handler", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (event) => {
|
||||
const addTrigger = event.target?.closest?.("[data-nip71-add]");
|
||||
if (addTrigger && store.root.contains(addTrigger)) {
|
||||
event.preventDefault();
|
||||
const targetKey = addTrigger.dataset?.nip71Add || "";
|
||||
const entry = this.addRepeaterEntry(key, targetKey);
|
||||
if (entry) {
|
||||
this.focusFirstField(entry, store.focusSelector);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nestedAddTrigger = event.target?.closest?.("[data-nip71-nested-add]");
|
||||
if (nestedAddTrigger && store.root.contains(nestedAddTrigger)) {
|
||||
event.preventDefault();
|
||||
const nestedKey = nestedAddTrigger.dataset?.nip71NestedAdd || "";
|
||||
const container = nestedAddTrigger.closest(
|
||||
`[data-nip71-nested="${nestedKey}"]`
|
||||
);
|
||||
const entry = this.addNestedEntry(container, nestedKey);
|
||||
if (entry) {
|
||||
this.focusFirstField(
|
||||
entry,
|
||||
`[data-nip71-nested-field="${nestedKey}"]`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const removeTrigger = event.target?.closest?.("[data-nip71-remove]");
|
||||
if (removeTrigger && store.root.contains(removeTrigger)) {
|
||||
event.preventDefault();
|
||||
const targetKey = removeTrigger.dataset?.nip71Remove || "";
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetKey === "nested") {
|
||||
const nestedEntry = removeTrigger.closest("[data-nip71-nested-entry]");
|
||||
this.removeNestedEntry(nestedEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeRepeaterEntry(key, targetKey, removeTrigger);
|
||||
}
|
||||
};
|
||||
|
||||
store.root.addEventListener("click", handleClick);
|
||||
store.handlers.click = handleClick;
|
||||
}
|
||||
|
||||
resetSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(store.kindInputs)) {
|
||||
store.kindInputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = Boolean(input.defaultChecked);
|
||||
});
|
||||
}
|
||||
|
||||
if (store.summaryInput) {
|
||||
store.summaryInput.value = "";
|
||||
}
|
||||
if (store.publishedAtInput) {
|
||||
store.publishedAtInput.value = "";
|
||||
}
|
||||
if (store.altInput) {
|
||||
store.altInput.value = "";
|
||||
}
|
||||
if (store.durationInput) {
|
||||
store.durationInput.value = "";
|
||||
}
|
||||
if (store.contentWarningInput) {
|
||||
store.contentWarningInput.value = "";
|
||||
}
|
||||
|
||||
if (store.repeaters) {
|
||||
Object.entries(store.repeaters).forEach(([repeaterKey, repeater]) => {
|
||||
if (!repeater?.list) {
|
||||
return;
|
||||
}
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(
|
||||
`[data-nip71-entry="${repeaterKey}"]`
|
||||
)
|
||||
);
|
||||
entries.forEach((entry) => {
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetEntry(entry);
|
||||
} else {
|
||||
entry.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hydrateSection(key, metadata) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetSection(key);
|
||||
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(store.kindInputs)) {
|
||||
const targetValue = metadata.kind != null ? `${metadata.kind}`.trim() : "";
|
||||
if (targetValue) {
|
||||
store.kindInputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = `${input.value}` === targetValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (store.summaryInput) {
|
||||
store.summaryInput.value = this.toInputValue(metadata.summary);
|
||||
}
|
||||
if (store.publishedAtInput) {
|
||||
const value =
|
||||
metadata.publishedAt ?? metadata.published_at ?? metadata["published-at"];
|
||||
store.publishedAtInput.value = this.toInputValue(value);
|
||||
}
|
||||
if (store.altInput) {
|
||||
store.altInput.value = this.toInputValue(metadata.alt);
|
||||
}
|
||||
if (store.durationInput) {
|
||||
const duration = this.normalizeNumber(metadata.duration);
|
||||
store.durationInput.value = duration != null ? `${duration}` : "";
|
||||
}
|
||||
if (store.contentWarningInput) {
|
||||
const value =
|
||||
metadata.contentWarning ??
|
||||
metadata["content-warning"] ??
|
||||
metadata.content_warning;
|
||||
store.contentWarningInput.value = this.toInputValue(value);
|
||||
}
|
||||
|
||||
this.hydrateRepeater(key, "imeta", metadata.imeta, (entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "m", value.m);
|
||||
this.setFieldValue(entry, "dim", value.dim);
|
||||
this.setFieldValue(entry, "url", value.url);
|
||||
this.setFieldValue(entry, "x", value.x);
|
||||
this.hydrateNested(entry, "image", value.image);
|
||||
this.hydrateNested(entry, "fallback", value.fallback);
|
||||
this.hydrateNested(entry, "service", value.service);
|
||||
});
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"text-track",
|
||||
metadata.textTracks ?? metadata["text-track"] ?? metadata.text_track,
|
||||
(entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "url", value.url);
|
||||
this.setFieldValue(entry, "type", value.type);
|
||||
this.setFieldValue(entry, "language", value.language);
|
||||
}
|
||||
);
|
||||
|
||||
this.hydrateRepeater(key, "segment", metadata.segments, (entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "start", value.start);
|
||||
this.setFieldValue(entry, "end", value.end);
|
||||
this.setFieldValue(entry, "title", value.title);
|
||||
this.setFieldValue(entry, "thumbnail", value.thumbnail);
|
||||
});
|
||||
|
||||
const sanitizedHashtags = this.sanitizeHashtags(
|
||||
metadata.hashtags ?? metadata.t,
|
||||
{ dedupe: true }
|
||||
);
|
||||
this.hydrateRepeater(key, "t", sanitizedHashtags, (entry, value) => {
|
||||
this.setFieldValue(entry, "value", this.renderHashtagValue(value));
|
||||
});
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"p",
|
||||
metadata.participants ?? metadata.p,
|
||||
(entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "pubkey", value.pubkey ?? value["pubkey"]);
|
||||
this.setFieldValue(entry, "relay", value.relay ?? value["relay"]);
|
||||
}
|
||||
);
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"r",
|
||||
metadata.references ?? metadata.r,
|
||||
(entry, value) => {
|
||||
this.setFieldValue(entry, "url", value);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
collectSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kindInput = Array.isArray(store.kindInputs)
|
||||
? store.kindInputs.find((input) => input?.checked)
|
||||
: null;
|
||||
let kind = null;
|
||||
if (kindInput?.value != null) {
|
||||
const parsed = Number(kindInput.value);
|
||||
kind = Number.isFinite(parsed) ? parsed : String(kindInput.value).trim();
|
||||
}
|
||||
|
||||
const summary = this.getTrimmedValue(store.summaryInput);
|
||||
const publishedAt = this.getTrimmedValue(store.publishedAtInput);
|
||||
const alt = this.getTrimmedValue(store.altInput);
|
||||
|
||||
let duration = null;
|
||||
if (store.durationInput) {
|
||||
const rawValue = store.durationInput.value;
|
||||
if (rawValue !== "" && rawValue != null) {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
duration = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentWarning = this.getTrimmedValue(store.contentWarningInput);
|
||||
|
||||
const imeta = this.collectRepeaterValues(key, "imeta", (entry) => {
|
||||
const variant = {
|
||||
m: this.getFieldValue(entry, "m"),
|
||||
dim: this.getFieldValue(entry, "dim"),
|
||||
url: this.getFieldValue(entry, "url"),
|
||||
x: this.getFieldValue(entry, "x"),
|
||||
image: this.collectNestedValues(entry, "image"),
|
||||
fallback: this.collectNestedValues(entry, "fallback"),
|
||||
service: this.collectNestedValues(entry, "service"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
variant.m ||
|
||||
variant.dim ||
|
||||
variant.url ||
|
||||
variant.x ||
|
||||
variant.image.length > 0 ||
|
||||
variant.fallback.length > 0 ||
|
||||
variant.service.length > 0;
|
||||
|
||||
return hasContent ? variant : null;
|
||||
});
|
||||
|
||||
const textTracks = this.collectRepeaterValues(
|
||||
key,
|
||||
"text-track",
|
||||
(entry) => {
|
||||
const track = {
|
||||
url: this.getFieldValue(entry, "url"),
|
||||
type: this.getFieldValue(entry, "type"),
|
||||
language: this.getFieldValue(entry, "language"),
|
||||
};
|
||||
|
||||
const hasContent = track.url || track.type || track.language;
|
||||
return hasContent ? track : null;
|
||||
}
|
||||
);
|
||||
|
||||
const segments = this.collectRepeaterValues(key, "segment", (entry) => {
|
||||
const segment = {
|
||||
start: this.getFieldValue(entry, "start"),
|
||||
end: this.getFieldValue(entry, "end"),
|
||||
title: this.getFieldValue(entry, "title"),
|
||||
thumbnail: this.getFieldValue(entry, "thumbnail"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
segment.start || segment.end || segment.title || segment.thumbnail;
|
||||
return hasContent ? segment : null;
|
||||
});
|
||||
|
||||
const hashtags = this.sanitizeHashtags(
|
||||
this.collectRepeaterValues(key, "t", (entry) => {
|
||||
const value = this.getFieldValue(entry, "value");
|
||||
return value || null;
|
||||
}),
|
||||
{ dedupe: true }
|
||||
);
|
||||
|
||||
const participants = this.collectRepeaterValues(key, "p", (entry) => {
|
||||
const participant = {
|
||||
pubkey: this.getFieldValue(entry, "pubkey"),
|
||||
relay: this.getFieldValue(entry, "relay"),
|
||||
};
|
||||
|
||||
const hasContent = participant.pubkey || participant.relay;
|
||||
return hasContent ? participant : null;
|
||||
});
|
||||
|
||||
const references = this.collectRepeaterValues(key, "r", (entry) => {
|
||||
const url = this.getFieldValue(entry, "url");
|
||||
return url || null;
|
||||
});
|
||||
|
||||
return {
|
||||
kind,
|
||||
summary,
|
||||
publishedAt,
|
||||
alt,
|
||||
duration,
|
||||
contentWarning,
|
||||
imeta,
|
||||
textTracks,
|
||||
segments,
|
||||
hashtags,
|
||||
participants,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
collectRepeaterValues(sectionKey, repeaterKey, mapFn) {
|
||||
const store = this.getSection(sectionKey);
|
||||
const repeater = store?.repeaters?.[repeaterKey];
|
||||
if (!repeater?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
|
||||
);
|
||||
|
||||
const results = [];
|
||||
entries.forEach((entry) => {
|
||||
const value = typeof mapFn === "function" ? mapFn(entry) : null;
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.trim()) {
|
||||
results.push(value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
results.push(value);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
sanitizeHashtags(values, { dedupe = false } = {}) {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
|
||||
values.forEach((value) => {
|
||||
const sanitized = this.sanitizeHashtagValue(value);
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = sanitized.toLowerCase();
|
||||
if (dedupe) {
|
||||
if (seen.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
}
|
||||
results.push(sanitized);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
sanitizeHashtagValue(value) {
|
||||
if (typeof value === "number") {
|
||||
value = Number.isFinite(value) ? `${value}` : "";
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
value = `${value ?? ""}`;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutHash = trimmed.replace(/^#+/, "").trim();
|
||||
if (!withoutHash) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return withoutHash.toLowerCase();
|
||||
}
|
||||
|
||||
renderHashtagValue(value) {
|
||||
const sanitized = this.sanitizeHashtagValue(value);
|
||||
return sanitized ? `#${sanitized}` : "";
|
||||
}
|
||||
|
||||
collectNestedValues(entry, nestedKey) {
|
||||
if (!entry || !nestedKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container = entry.querySelector(
|
||||
`[data-nip71-nested="${nestedKey}"]`
|
||||
);
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = Array.from(
|
||||
container.querySelectorAll(`[data-nip71-nested-field="${nestedKey}"]`)
|
||||
);
|
||||
|
||||
return fields
|
||||
.map((field) => this.getTrimmedValue(field))
|
||||
.filter((value) => Boolean(value));
|
||||
}
|
||||
|
||||
addRepeaterEntry(sectionKey, repeaterKey) {
|
||||
if (!repeaterKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
if (!store?.repeaters || !store.repeaters[repeaterKey]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { list, template } = store.repeaters[repeaterKey];
|
||||
if (!list || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = template.content
|
||||
? template.content.cloneNode(true)
|
||||
: template.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-entry="${repeaterKey}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeRepeaterEntry(sectionKey, repeaterKey, trigger) {
|
||||
if (!repeaterKey || !trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
if (!store?.repeaters?.[repeaterKey]?.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = trigger.closest(`[data-nip71-entry="${repeaterKey}"]`);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetEntry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
addNestedEntry(container, nestedKey) {
|
||||
if (!container || !nestedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${nestedKey}"]`
|
||||
);
|
||||
const template = container.querySelector(
|
||||
`[data-nip71-nested-template="${nestedKey}"]`
|
||||
);
|
||||
|
||||
if (!list || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = template.content
|
||||
? template.content.cloneNode(true)
|
||||
: template.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-nested-entry="${nestedKey}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeNestedEntry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
resetEntry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = entry.querySelectorAll("[data-nip71-field]");
|
||||
fields.forEach((field) => {
|
||||
if (field) {
|
||||
field.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const nestedContainers = entry.querySelectorAll("[data-nip71-nested]");
|
||||
nestedContainers.forEach((container) => {
|
||||
if (!container?.dataset?.nip71Nested) {
|
||||
return;
|
||||
}
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${container.dataset.nip71Nested}"]`
|
||||
);
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hydrateRepeater(sectionKey, repeaterKey, rawValues, applyValue) {
|
||||
const values = Array.isArray(rawValues) ? rawValues : [];
|
||||
if (!values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
const repeater = store?.repeaters?.[repeaterKey];
|
||||
if (!repeater?.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
|
||||
);
|
||||
|
||||
values.forEach((value, index) => {
|
||||
let entry = entries[index];
|
||||
if (!entry) {
|
||||
entry = this.addRepeaterEntry(sectionKey, repeaterKey);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (typeof applyValue === "function") {
|
||||
applyValue(entry, value, index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hydrateNested(entry, nestedKey, rawValues) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues.filter((value) => this.toInputValue(value))
|
||||
: [];
|
||||
if (!values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = entry.querySelector(`[data-nip71-nested="${nestedKey}"]`);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.forEach((value) => {
|
||||
const nestedEntry = this.addNestedEntry(container, nestedKey);
|
||||
if (!nestedEntry) {
|
||||
return;
|
||||
}
|
||||
const field = nestedEntry.querySelector(
|
||||
`[data-nip71-nested-field="${nestedKey}"]`
|
||||
);
|
||||
if (field) {
|
||||
field.value = this.toInputValue(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFieldValue(entry, field, value) {
|
||||
if (!entry || !field) {
|
||||
return;
|
||||
}
|
||||
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.value = this.toInputValue(value);
|
||||
}
|
||||
|
||||
focusFirstField(container, selector) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const targetSelector = selector || this.defaultFocusSelector;
|
||||
const target = container.querySelector(targetSelector);
|
||||
if (target?.focus) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getFieldValue(entry, field) {
|
||||
if (!entry || !field) {
|
||||
return "";
|
||||
}
|
||||
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
|
||||
return this.getTrimmedValue(element);
|
||||
}
|
||||
|
||||
getTrimmedValue(element) {
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
const { value } = element;
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value).trim() : "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
toInputValue(value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? `${value}` : "";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
return `${value ?? ""}`;
|
||||
}
|
||||
|
||||
normalizeNumber(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheSection(section) {
|
||||
const repeaters = {};
|
||||
REPEATER_KEYS.forEach((key) => {
|
||||
repeaters[key] = this.cacheRepeater(section, key);
|
||||
});
|
||||
|
||||
return {
|
||||
root: section,
|
||||
focusSelector: this.defaultFocusSelector,
|
||||
kindInputs: Array.from(section.querySelectorAll('[data-nip71-input="kind"]')),
|
||||
publishedAtInput:
|
||||
section.querySelector('[data-nip71-input="published_at"]') || null,
|
||||
altInput: section.querySelector('[data-nip71-input="alt"]') || null,
|
||||
durationInput:
|
||||
section.querySelector('[data-nip71-input="duration"]') || null,
|
||||
contentWarningInput:
|
||||
section.querySelector('[data-nip71-input="content-warning"]') || null,
|
||||
summaryInput: section.querySelector('[data-nip71-input="summary"]') || null,
|
||||
repeaters,
|
||||
handlers: {},
|
||||
};
|
||||
}
|
||||
|
||||
cacheRepeater(section, key) {
|
||||
if (!section || !key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeaterRoot = section.querySelector(
|
||||
`[data-nip71-repeater="${key}"]`
|
||||
);
|
||||
if (!repeaterRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root: repeaterRoot,
|
||||
list: repeaterRoot.querySelector(`[data-nip71-list="${key}"]`) || null,
|
||||
template:
|
||||
repeaterRoot.querySelector(`[data-nip71-template="${key}"]`) || null,
|
||||
};
|
||||
}
|
||||
|
||||
getSection(key) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return this.sections.get(key) || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
export default class MoreMenuController {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
document: doc = typeof document !== "undefined" ? document : null,
|
||||
accessControl = null,
|
||||
userBlocks = null,
|
||||
subscriptions = null,
|
||||
clipboard = typeof navigator !== "undefined" ? navigator.clipboard : null,
|
||||
isDevMode = false,
|
||||
callbacks = {},
|
||||
} = options;
|
||||
|
||||
this.document = doc;
|
||||
this.accessControl = accessControl;
|
||||
this.userBlocks = userBlocks;
|
||||
this.subscriptions = subscriptions;
|
||||
this.clipboard = clipboard;
|
||||
this.isDevMode = Boolean(isDevMode);
|
||||
|
||||
this.callbacks = {
|
||||
getCurrentVideo: callbacks.getCurrentVideo || (() => null),
|
||||
getCurrentUserNpub: callbacks.getCurrentUserNpub || (() => null),
|
||||
getCurrentUserPubkey: callbacks.getCurrentUserPubkey || (() => null),
|
||||
canCurrentUserManageBlacklist:
|
||||
callbacks.canCurrentUserManageBlacklist || (() => false),
|
||||
openCreatorChannel: callbacks.openCreatorChannel || (() => {}),
|
||||
goToProfile: callbacks.goToProfile || (() => {}),
|
||||
showError: callbacks.showError || (() => {}),
|
||||
showSuccess: callbacks.showSuccess || (() => {}),
|
||||
safeEncodeNpub: callbacks.safeEncodeNpub || (() => ""),
|
||||
safeDecodeNpub: callbacks.safeDecodeNpub || (() => ""),
|
||||
buildShareUrlFromEventId:
|
||||
callbacks.buildShareUrlFromEventId || (() => ""),
|
||||
handleRemoveHistoryAction:
|
||||
callbacks.handleRemoveHistoryAction || (() => Promise.resolve()),
|
||||
handleRepostAction:
|
||||
callbacks.handleRepostAction || (() => Promise.resolve()),
|
||||
handleMirrorAction:
|
||||
callbacks.handleMirrorAction || (() => Promise.resolve()),
|
||||
handleEnsurePresenceAction:
|
||||
callbacks.handleEnsurePresenceAction || (() => Promise.resolve()),
|
||||
loadVideos: callbacks.loadVideos || (() => Promise.resolve()),
|
||||
onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}),
|
||||
};
|
||||
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
this.boundDocumentClick = null;
|
||||
this.boundDocumentKeydown = null;
|
||||
|
||||
this.videoListView = null;
|
||||
this.videoModal = null;
|
||||
|
||||
this.boundVideoListShareListener = null;
|
||||
this.boundVideoListContextListener = null;
|
||||
this.boundVideoListBlacklistHandler = null;
|
||||
}
|
||||
|
||||
setVideoModal(videoModal) {
|
||||
this.videoModal = videoModal || null;
|
||||
}
|
||||
|
||||
attachVideoListView(view) {
|
||||
if (view === this.videoListView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachVideoListView();
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoListView = view;
|
||||
|
||||
this.boundVideoListBlacklistHandler = ({ video, dataset }) => {
|
||||
const detail = {
|
||||
...(dataset || {}),
|
||||
author: dataset?.author || video?.pubkey || "",
|
||||
};
|
||||
this.handleMoreMenuAction("blacklist-author", detail);
|
||||
};
|
||||
|
||||
if (typeof view.setBlacklistHandler === "function") {
|
||||
view.setBlacklistHandler(this.boundVideoListBlacklistHandler);
|
||||
}
|
||||
|
||||
this.boundVideoListShareListener = (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const dataset = {
|
||||
...(detail.dataset || {}),
|
||||
eventId:
|
||||
detail.eventId ||
|
||||
detail.dataset?.eventId ||
|
||||
detail.video?.id ||
|
||||
"",
|
||||
context: detail.dataset?.context || "card",
|
||||
};
|
||||
const action = detail.action || "copy-link";
|
||||
this.handleMoreMenuAction(action, dataset);
|
||||
};
|
||||
|
||||
if (typeof view.addEventListener === "function") {
|
||||
view.addEventListener("video:share", this.boundVideoListShareListener);
|
||||
}
|
||||
|
||||
this.boundVideoListContextListener = (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const dataset = {
|
||||
...(detail.dataset || {}),
|
||||
eventId: detail.dataset?.eventId || detail.video?.id || "",
|
||||
};
|
||||
this.handleMoreMenuAction(detail.action, dataset);
|
||||
};
|
||||
|
||||
if (typeof view.addEventListener === "function") {
|
||||
view.addEventListener(
|
||||
"video:context-action",
|
||||
this.boundVideoListContextListener,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
detachVideoListView() {
|
||||
if (!this.videoListView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.boundVideoListShareListener &&
|
||||
typeof this.videoListView.removeEventListener === "function"
|
||||
) {
|
||||
this.videoListView.removeEventListener(
|
||||
"video:share",
|
||||
this.boundVideoListShareListener,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.boundVideoListContextListener &&
|
||||
typeof this.videoListView.removeEventListener === "function"
|
||||
) {
|
||||
this.videoListView.removeEventListener(
|
||||
"video:context-action",
|
||||
this.boundVideoListContextListener,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof this.videoListView.setBlacklistHandler === "function") {
|
||||
this.videoListView.setBlacklistHandler(null);
|
||||
}
|
||||
|
||||
this.boundVideoListShareListener = null;
|
||||
this.boundVideoListContextListener = null;
|
||||
this.boundVideoListBlacklistHandler = null;
|
||||
this.videoListView = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.detachVideoListView();
|
||||
this.setVideoModal(null);
|
||||
|
||||
if (this.document && this.boundDocumentClick) {
|
||||
this.document.removeEventListener("click", this.boundDocumentClick);
|
||||
}
|
||||
if (this.document && this.boundDocumentKeydown) {
|
||||
this.document.removeEventListener("keydown", this.boundDocumentKeydown);
|
||||
}
|
||||
|
||||
this.boundDocumentClick = null;
|
||||
this.boundDocumentKeydown = null;
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
}
|
||||
|
||||
ensureGlobalMoreMenuHandlers() {
|
||||
if (this.moreMenuGlobalHandlerBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.moreMenuGlobalHandlerBound = true;
|
||||
|
||||
this.boundDocumentClick = (event) => {
|
||||
const target = event?.target;
|
||||
if (!target || typeof target.closest !== "function") {
|
||||
this.closeAllMoreMenus();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest("[data-more-menu-wrapper]") ||
|
||||
target.closest("[data-more-menu]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closeAllMoreMenus();
|
||||
};
|
||||
|
||||
this.boundDocumentKeydown = (event) => {
|
||||
if (event?.key === "Escape") {
|
||||
this.closeAllMoreMenus();
|
||||
}
|
||||
};
|
||||
|
||||
this.document.addEventListener("click", this.boundDocumentClick);
|
||||
this.document.addEventListener("keydown", this.boundDocumentKeydown);
|
||||
}
|
||||
|
||||
closeAllMoreMenus() {
|
||||
if (
|
||||
this.videoListView &&
|
||||
typeof this.videoListView.closeAllMenus === "function"
|
||||
) {
|
||||
this.videoListView.closeAllMenus();
|
||||
}
|
||||
|
||||
if (!this.document || typeof this.document.querySelectorAll !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const HTMLElementCtor =
|
||||
this.document?.defaultView?.HTMLElement ||
|
||||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||||
|
||||
const menus = this.document.querySelectorAll("[data-more-menu]");
|
||||
menus.forEach((menu) => {
|
||||
if (!HTMLElementCtor || menu instanceof HTMLElementCtor) {
|
||||
menu.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = this.document.querySelectorAll("[data-more-dropdown]");
|
||||
buttons.forEach((button) => {
|
||||
if (!HTMLElementCtor || button instanceof HTMLElementCtor) {
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
attachMoreMenuHandlers(container) {
|
||||
if (!container || typeof container.querySelectorAll !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = container.querySelectorAll("[data-more-dropdown]");
|
||||
if (!buttons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureGlobalMoreMenuHandlers();
|
||||
|
||||
const HTMLElementCtor =
|
||||
this.document?.defaultView?.HTMLElement ||
|
||||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.moreMenuToggleBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.moreMenuToggleBound = "true";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = button.getAttribute("data-more-dropdown") || "";
|
||||
const dropdown = this.document.getElementById(`moreDropdown-${key}`);
|
||||
if (HTMLElementCtor && !(dropdown instanceof HTMLElementCtor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const willOpen = dropdown.classList.contains("hidden");
|
||||
this.closeAllMoreMenus();
|
||||
|
||||
if (willOpen) {
|
||||
dropdown.classList.remove("hidden");
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const actionButtons = container.querySelectorAll(
|
||||
"[data-more-menu] button[data-action]",
|
||||
);
|
||||
actionButtons.forEach((button) => {
|
||||
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.moreMenuActionBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.moreMenuActionBound = "true";
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { action } = button.dataset;
|
||||
this.handleMoreMenuAction(action, button.dataset);
|
||||
this.closeAllMoreMenus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
syncModalMoreMenuData() {
|
||||
if (!this.videoModal || typeof this.videoModal.syncMoreMenuData !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVideo = this.callbacks.getCurrentVideo();
|
||||
const canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist();
|
||||
|
||||
this.videoModal.syncMoreMenuData({
|
||||
currentVideo,
|
||||
canManageBlacklist,
|
||||
});
|
||||
}
|
||||
|
||||
async handleMoreMenuAction(action, dataset = {}) {
|
||||
const normalized = typeof action === "string" ? action.trim() : "";
|
||||
const context = dataset.context || "";
|
||||
const currentVideo = this.callbacks.getCurrentVideo();
|
||||
|
||||
switch (normalized) {
|
||||
case "open-channel": {
|
||||
if (context === "modal") {
|
||||
this.callbacks.openCreatorChannel();
|
||||
break;
|
||||
}
|
||||
|
||||
const author =
|
||||
dataset.author || (currentVideo ? currentVideo.pubkey : "");
|
||||
if (author) {
|
||||
this.callbacks.goToProfile(author);
|
||||
} else {
|
||||
this.callbacks.showError("No creator info available.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "copy-link": {
|
||||
const eventId =
|
||||
dataset.eventId || (context === "modal" ? currentVideo?.id : "");
|
||||
if (!eventId) {
|
||||
this.callbacks.showError("Could not generate link.");
|
||||
break;
|
||||
}
|
||||
const shareUrl = this.callbacks.buildShareUrlFromEventId(eventId);
|
||||
if (!shareUrl) {
|
||||
this.callbacks.showError("Could not generate link.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.clipboard || typeof this.clipboard.writeText !== "function") {
|
||||
this.callbacks.showError("Failed to copy the link.");
|
||||
break;
|
||||
}
|
||||
|
||||
this.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() =>
|
||||
this.callbacks.showSuccess("Video link copied to clipboard!"),
|
||||
)
|
||||
.catch(() => this.callbacks.showError("Failed to copy the link."));
|
||||
break;
|
||||
}
|
||||
case "remove-history": {
|
||||
await this.callbacks.handleRemoveHistoryAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "repost-event": {
|
||||
await this.callbacks.handleRepostAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "mirror-video": {
|
||||
await this.callbacks.handleMirrorAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "ensure-presence": {
|
||||
await this.callbacks.handleEnsurePresenceAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "copy-npub": {
|
||||
const explicitNpub =
|
||||
typeof dataset.npub === "string" && dataset.npub.trim()
|
||||
? dataset.npub.trim()
|
||||
: "";
|
||||
const authorCandidate = dataset.author || "";
|
||||
const fallbackNpub = this.callbacks.safeEncodeNpub(authorCandidate);
|
||||
const valueToCopy = explicitNpub || fallbackNpub;
|
||||
|
||||
if (!valueToCopy) {
|
||||
this.callbacks.showError("No npub available to copy.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.clipboard || typeof this.clipboard.writeText !== "function") {
|
||||
this.callbacks.showError("Failed to copy the npub.");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.clipboard.writeText(valueToCopy);
|
||||
this.callbacks.showSuccess("Channel npub copied to clipboard!");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy npub:", error);
|
||||
this.callbacks.showError("Failed to copy the npub.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "blacklist-author": {
|
||||
const actorNpub = this.callbacks.getCurrentUserNpub();
|
||||
if (!actorNpub) {
|
||||
this.callbacks.showError(
|
||||
"Please login as a moderator to manage the blacklist.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.accessControl?.ensureReady?.();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to refresh moderation state before blacklisting:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.accessControl?.canEditAdminLists?.(actorNpub)) {
|
||||
this.callbacks.showError("Only moderators can manage the blacklist.");
|
||||
break;
|
||||
}
|
||||
|
||||
let author = dataset.author || "";
|
||||
if (!author && context === "modal" && currentVideo?.pubkey) {
|
||||
author = currentVideo.pubkey;
|
||||
}
|
||||
if (!author && currentVideo?.pubkey) {
|
||||
author = currentVideo.pubkey;
|
||||
}
|
||||
|
||||
const explicitNpub =
|
||||
typeof dataset.npub === "string" && dataset.npub.trim()
|
||||
? dataset.npub.trim()
|
||||
: "";
|
||||
const targetNpub = explicitNpub || this.callbacks.safeEncodeNpub(author);
|
||||
|
||||
if (!targetNpub) {
|
||||
this.callbacks.showError("Unable to determine the creator npub.");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.accessControl.addToBlacklist(
|
||||
actorNpub,
|
||||
targetNpub,
|
||||
);
|
||||
|
||||
if (result?.ok) {
|
||||
this.callbacks.showSuccess("Creator added to the blacklist.");
|
||||
try {
|
||||
await this.subscriptions?.refreshActiveFeed?.({
|
||||
reason: "admin-blacklist-update",
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isDevMode) {
|
||||
console.warn(
|
||||
"[Subscriptions] Failed to refresh after blacklist update:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const code = result?.error || "unknown";
|
||||
switch (code) {
|
||||
case "self":
|
||||
this.callbacks.showError("You cannot blacklist yourself.");
|
||||
break;
|
||||
case "immutable":
|
||||
this.callbacks.showError(
|
||||
"Moderators cannot blacklist the super admin or fellow moderators.",
|
||||
);
|
||||
break;
|
||||
case "invalid npub":
|
||||
this.callbacks.showError("Unable to blacklist this creator.");
|
||||
break;
|
||||
case "forbidden":
|
||||
this.callbacks.showError("Only moderators can manage the blacklist.");
|
||||
break;
|
||||
default:
|
||||
this.callbacks.showError(
|
||||
"Failed to update the blacklist. Please try again.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add creator to blacklist:", error);
|
||||
this.callbacks.showError(
|
||||
"Failed to update the blacklist. Please try again.",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "block-author": {
|
||||
const currentUserPubkey = this.callbacks.getCurrentUserPubkey();
|
||||
if (!currentUserPubkey) {
|
||||
this.callbacks.showError("Please login to manage your block list.");
|
||||
break;
|
||||
}
|
||||
|
||||
const authorCandidate =
|
||||
dataset.author || (currentVideo && currentVideo.pubkey) || "";
|
||||
|
||||
const trimmed =
|
||||
typeof authorCandidate === "string" ? authorCandidate.trim() : "";
|
||||
if (!trimmed) {
|
||||
this.callbacks.showError("Unable to determine the creator to block.");
|
||||
break;
|
||||
}
|
||||
|
||||
let normalizedHex = "";
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
normalizedHex = this.callbacks.safeDecodeNpub(trimmed) || "";
|
||||
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
normalizedHex = trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (!normalizedHex) {
|
||||
this.callbacks.showError("Unable to determine the creator to block.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalizedHex === currentUserPubkey) {
|
||||
this.callbacks.showError("You cannot block yourself.");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.userBlocks?.ensureLoaded?.(currentUserPubkey);
|
||||
|
||||
if (this.userBlocks?.isBlocked?.(normalizedHex)) {
|
||||
this.callbacks.showSuccess("You already blocked this creator.");
|
||||
} else {
|
||||
await this.userBlocks?.addBlock?.(normalizedHex, currentUserPubkey);
|
||||
this.callbacks.showSuccess(
|
||||
"Creator blocked. You won't see their videos anymore.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
this.callbacks.onUserBlocksUpdated();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[MoreMenu] Failed to refresh blocked list after update:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
await this.callbacks.loadVideos();
|
||||
try {
|
||||
await this.subscriptions?.refreshActiveFeed?.({
|
||||
reason: "user-block-update",
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isDevMode) {
|
||||
console.warn(
|
||||
"[Subscriptions] Failed to refresh after user block update:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update personal block list:", error);
|
||||
const message =
|
||||
error?.code === "nip04-missing"
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: "Failed to update your block list. Please try again.";
|
||||
this.callbacks.showError(message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "report": {
|
||||
this.callbacks.showSuccess("Reporting coming soon.");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,996 @@
|
||||
import { VideoCard } from "../components/VideoCard.js";
|
||||
import {
|
||||
subscribeToVideoViewCount,
|
||||
unsubscribeFromVideoViewCount,
|
||||
} from "../../viewCounter.js";
|
||||
|
||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
|
||||
export class VideoListView {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
document: doc = typeof document !== "undefined" ? document : null,
|
||||
container = null,
|
||||
mediaLoader = null,
|
||||
badgeHelpers = {},
|
||||
formatters = {},
|
||||
helpers = {},
|
||||
assets = {},
|
||||
state = {},
|
||||
utils = {},
|
||||
renderers = {},
|
||||
} = options;
|
||||
|
||||
this.document = doc;
|
||||
this.window = this.document?.defaultView ||
|
||||
(typeof window !== "undefined" ? window : null);
|
||||
this.container = null;
|
||||
this.mediaLoader = mediaLoader;
|
||||
|
||||
this.badgeHelpers = {
|
||||
attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}),
|
||||
attachUrlHealthBadges:
|
||||
badgeHelpers.attachUrlHealthBadges || (() => {}),
|
||||
};
|
||||
|
||||
this.formatters = {
|
||||
formatTimeAgo: formatters.formatTimeAgo || ((timestamp) => timestamp),
|
||||
formatViewCountLabel:
|
||||
formatters.formatViewCountLabel || ((total) => `${total}`),
|
||||
};
|
||||
|
||||
this.helpers = {
|
||||
escapeHtml: helpers.escapeHtml || ((value) => value),
|
||||
isMagnetSupported: helpers.isMagnetSupported || (() => false),
|
||||
toLocaleString: helpers.toLocaleString || ((value) => value),
|
||||
};
|
||||
|
||||
this.assets = {
|
||||
fallbackThumbnailSrc: assets.fallbackThumbnailSrc || "",
|
||||
unsupportedBtihMessage: assets.unsupportedBtihMessage || "",
|
||||
};
|
||||
|
||||
this.state = {
|
||||
loadedThumbnails:
|
||||
state.loadedThumbnails instanceof Map ? state.loadedThumbnails : new Map(),
|
||||
videosMap: state.videosMap instanceof Map ? state.videosMap : new Map(),
|
||||
urlHealthByVideoId:
|
||||
state.urlHealthByVideoId instanceof Map
|
||||
? state.urlHealthByVideoId
|
||||
: new Map(),
|
||||
streamHealthByVideoId:
|
||||
state.streamHealthByVideoId instanceof Map
|
||||
? state.streamHealthByVideoId
|
||||
: new Map(),
|
||||
feedMetadata:
|
||||
state.feedMetadata && typeof state.feedMetadata === "object"
|
||||
? { ...state.feedMetadata }
|
||||
: null,
|
||||
};
|
||||
|
||||
this.renderers = {
|
||||
getLoadingMarkup:
|
||||
typeof renderers.getLoadingMarkup === "function"
|
||||
? renderers.getLoadingMarkup
|
||||
: () => "",
|
||||
};
|
||||
|
||||
this.utils = {
|
||||
dedupeVideos:
|
||||
typeof utils.dedupeVideos === "function"
|
||||
? utils.dedupeVideos
|
||||
: (videos) => (Array.isArray(videos) ? [...videos] : []),
|
||||
getAllEvents:
|
||||
typeof utils.getAllEvents === "function" ? utils.getAllEvents : () => [],
|
||||
hasOlderVersion:
|
||||
typeof utils.hasOlderVersion === "function"
|
||||
? utils.hasOlderVersion
|
||||
: () => false,
|
||||
derivePointerInfo:
|
||||
typeof utils.derivePointerInfo === "function"
|
||||
? utils.derivePointerInfo
|
||||
: () => null,
|
||||
persistWatchHistoryMetadata:
|
||||
typeof utils.persistWatchHistoryMetadata === "function"
|
||||
? utils.persistWatchHistoryMetadata
|
||||
: () => {},
|
||||
getShareUrlBase:
|
||||
typeof utils.getShareUrlBase === "function"
|
||||
? utils.getShareUrlBase
|
||||
: () => "",
|
||||
buildShareUrlFromNevent:
|
||||
typeof utils.buildShareUrlFromNevent === "function"
|
||||
? utils.buildShareUrlFromNevent
|
||||
: () => "",
|
||||
buildShareUrlFromEventId:
|
||||
typeof utils.buildShareUrlFromEventId === "function"
|
||||
? utils.buildShareUrlFromEventId
|
||||
: () => "",
|
||||
getKnownVideoPostedAt:
|
||||
typeof utils.getKnownVideoPostedAt === "function"
|
||||
? utils.getKnownVideoPostedAt
|
||||
: () => null,
|
||||
resolveVideoPostedAt:
|
||||
typeof utils.resolveVideoPostedAt === "function"
|
||||
? utils.resolveVideoPostedAt
|
||||
: null,
|
||||
canManageBlacklist:
|
||||
typeof utils.canManageBlacklist === "function"
|
||||
? utils.canManageBlacklist
|
||||
: () => false,
|
||||
canEditVideo:
|
||||
typeof utils.canEditVideo === "function" ? utils.canEditVideo : () => false,
|
||||
canDeleteVideo:
|
||||
typeof utils.canDeleteVideo === "function"
|
||||
? utils.canDeleteVideo
|
||||
: (video) => this.utils.canEditVideo(video),
|
||||
batchFetchProfiles:
|
||||
typeof utils.batchFetchProfiles === "function"
|
||||
? utils.batchFetchProfiles
|
||||
: () => {},
|
||||
bindThumbnailFallbacks:
|
||||
typeof utils.bindThumbnailFallbacks === "function"
|
||||
? utils.bindThumbnailFallbacks
|
||||
: () => {},
|
||||
handleUrlHealthBadge:
|
||||
typeof utils.handleUrlHealthBadge === "function"
|
||||
? utils.handleUrlHealthBadge
|
||||
: () => {},
|
||||
refreshDiscussionCounts:
|
||||
typeof utils.refreshDiscussionCounts === "function"
|
||||
? utils.refreshDiscussionCounts
|
||||
: () => {},
|
||||
ensureGlobalMoreMenuHandlers:
|
||||
typeof utils.ensureGlobalMoreMenuHandlers === "function"
|
||||
? utils.ensureGlobalMoreMenuHandlers
|
||||
: () => {},
|
||||
closeAllMenus:
|
||||
typeof utils.closeAllMenus === "function" ? utils.closeAllMenus : () => {},
|
||||
};
|
||||
|
||||
this.renderedVideoIds = new Set();
|
||||
this.videoCardInstances = [];
|
||||
this.viewCountSubscriptions = new Map();
|
||||
this.currentVideos = [];
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
|
||||
this.handlers = {
|
||||
playback: null,
|
||||
edit: null,
|
||||
revert: null,
|
||||
delete: null,
|
||||
blacklist: null,
|
||||
};
|
||||
|
||||
this.emitter = typeof EventTarget !== "undefined" ? new EventTarget() : null;
|
||||
this._boundClickHandler = this.handleContainerClick.bind(this);
|
||||
|
||||
if (container) {
|
||||
this.setContainer(container);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
if (!this.emitter) return;
|
||||
this.emitter.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener, options) {
|
||||
if (!this.emitter) return;
|
||||
this.emitter.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
if (!this.emitter) {
|
||||
return false;
|
||||
}
|
||||
return this.emitter.dispatchEvent(event);
|
||||
}
|
||||
|
||||
setContainer(container) {
|
||||
if (container === this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.removeEventListener("click", this._boundClickHandler);
|
||||
}
|
||||
|
||||
this.container = container || null;
|
||||
|
||||
if (this.container) {
|
||||
this.container.addEventListener("click", this._boundClickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
mount(container) {
|
||||
this.setContainer(container);
|
||||
return this.container;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.setContainer(null);
|
||||
}
|
||||
|
||||
showLoading(message = "") {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markup = this.renderers.getLoadingMarkup(message);
|
||||
if (typeof markup === "string") {
|
||||
this.container.innerHTML = markup;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unmount();
|
||||
this.teardownAllViewCountSubscriptions();
|
||||
this.renderedVideoIds.clear();
|
||||
this.videoCardInstances = [];
|
||||
this.currentVideos = [];
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
}
|
||||
|
||||
setPlaybackHandler(handler) {
|
||||
this.handlers.playback = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setEditHandler(handler) {
|
||||
this.handlers.edit = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setRevertHandler(handler) {
|
||||
this.handlers.revert = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setDeleteHandler(handler) {
|
||||
this.handlers.delete = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setBlacklistHandler(handler) {
|
||||
this.handlers.blacklist = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
render(videos, metadata = null) {
|
||||
if (!this.container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this._lastRenderedVideoListElement !== this.container) {
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = this.container;
|
||||
}
|
||||
|
||||
const normalizedMetadata =
|
||||
metadata && typeof metadata === "object" ? { ...metadata } : null;
|
||||
this.state.feedMetadata = normalizedMetadata;
|
||||
|
||||
const source = Array.isArray(videos) ? videos : [];
|
||||
const dedupedVideos = this.utils.dedupeVideos(source);
|
||||
const displayVideos = dedupedVideos.filter(
|
||||
(video) => this.utils.canEditVideo(video) || !video?.isPrivate
|
||||
);
|
||||
|
||||
this.syncViewCountSubscriptions(displayVideos);
|
||||
this.cleanupThumbnailCache(displayVideos);
|
||||
this.cleanupHealthCache(displayVideos);
|
||||
|
||||
if (!displayVideos.length) {
|
||||
this.renderedVideoIds.clear();
|
||||
this.videoCardInstances = [];
|
||||
if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) {
|
||||
return displayVideos;
|
||||
}
|
||||
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
|
||||
this.container.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||
No public videos available yet. Be the first to upload one!
|
||||
</p>`;
|
||||
return displayVideos;
|
||||
}
|
||||
|
||||
displayVideos.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
this.currentVideos = displayVideos.slice();
|
||||
|
||||
const signaturePayload = displayVideos.map((video) => ({
|
||||
id: typeof video?.id === "string" ? video.id : "",
|
||||
createdAt: Number.isFinite(video?.created_at)
|
||||
? video.created_at
|
||||
: Number(video?.created_at) || 0,
|
||||
deleted: Boolean(video?.deleted),
|
||||
isPrivate: Boolean(video?.isPrivate),
|
||||
thumbnail: typeof video?.thumbnail === "string" ? video.thumbnail : "",
|
||||
url: typeof video?.url === "string" ? video.url : "",
|
||||
magnet: typeof video?.magnet === "string" ? video.magnet : "",
|
||||
enableComments: video?.enableComments === false ? false : true,
|
||||
}));
|
||||
const signature = JSON.stringify(signaturePayload);
|
||||
|
||||
if (signature === this.lastRenderedVideoSignature) {
|
||||
return displayVideos;
|
||||
}
|
||||
this.lastRenderedVideoSignature = signature;
|
||||
|
||||
const previouslyRenderedIds = new Set(this.renderedVideoIds);
|
||||
this.renderedVideoIds.clear();
|
||||
|
||||
const allEvents = this.utils.getAllEvents();
|
||||
const fragment = this.document?.createDocumentFragment?.();
|
||||
if (!fragment) {
|
||||
return displayVideos;
|
||||
}
|
||||
|
||||
const authorSet = new Set();
|
||||
const shareBase = this.computeShareBase();
|
||||
const canManageBlacklist = this.utils.canManageBlacklist();
|
||||
this.videoCardInstances = [];
|
||||
|
||||
displayVideos.forEach((video, index) => {
|
||||
if (!video || !video.id || !video.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
authorSet.add(video.pubkey);
|
||||
|
||||
const shareUrl = this.buildShareUrl(video, shareBase);
|
||||
const canEdit = this.utils.canEditVideo(video);
|
||||
const canDelete = this.utils.canDeleteVideo(video);
|
||||
const highlightClass =
|
||||
canEdit && video.isPrivate ? "video-card--owner-private" : "";
|
||||
const isNewlyRendered = !previouslyRenderedIds.has(video.id);
|
||||
const animationClass = isNewlyRendered ? "video-card--enter" : "";
|
||||
const knownPostedAt = this.utils.getKnownVideoPostedAt(video);
|
||||
const normalizedPostedAt = Number.isFinite(knownPostedAt)
|
||||
? Math.floor(knownPostedAt)
|
||||
: null;
|
||||
const fallbackTimestamp =
|
||||
normalizedPostedAt !== null
|
||||
? normalizedPostedAt
|
||||
: Number.isFinite(video?.created_at)
|
||||
? Math.floor(video.created_at)
|
||||
: null;
|
||||
const timeAgo =
|
||||
fallbackTimestamp !== null
|
||||
? this.formatters.formatTimeAgo(fallbackTimestamp)
|
||||
: "";
|
||||
|
||||
let hasOlder = false;
|
||||
if (canEdit && video.videoRootId) {
|
||||
hasOlder = this.utils.hasOlderVersion(video, allEvents);
|
||||
}
|
||||
|
||||
const pointerInfo = this.utils.derivePointerInfo(video);
|
||||
if (pointerInfo) {
|
||||
this.utils.persistWatchHistoryMetadata(video, pointerInfo);
|
||||
}
|
||||
|
||||
const videoCard = new VideoCard({
|
||||
document: this.document,
|
||||
video,
|
||||
index,
|
||||
shareUrl,
|
||||
pointerInfo,
|
||||
timeAgo,
|
||||
postedAt: normalizedPostedAt,
|
||||
highlightClass,
|
||||
animationClass,
|
||||
capabilities: {
|
||||
canEdit,
|
||||
canDelete,
|
||||
canRevert: hasOlder,
|
||||
canManageBlacklist,
|
||||
},
|
||||
helpers: {
|
||||
escapeHtml: this.helpers.escapeHtml,
|
||||
isMagnetSupported: this.helpers.isMagnetSupported,
|
||||
toLocaleString: this.helpers.toLocaleString,
|
||||
},
|
||||
assets: {
|
||||
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
|
||||
unsupportedBtihMessage: this.assets.unsupportedBtihMessage,
|
||||
},
|
||||
state: {
|
||||
loadedThumbnails: this.state.loadedThumbnails,
|
||||
urlHealthByVideoId: this.state.urlHealthByVideoId,
|
||||
streamHealthByVideoId: this.state.streamHealthByVideoId,
|
||||
},
|
||||
ensureGlobalMoreMenuHandlers: () =>
|
||||
this.utils.ensureGlobalMoreMenuHandlers(),
|
||||
onRequestCloseAllMenus: () => this.closeAllMenus(),
|
||||
formatters: {
|
||||
formatTimeAgo: this.formatters.formatTimeAgo,
|
||||
},
|
||||
});
|
||||
|
||||
videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => {
|
||||
const trigger = domEvent?.currentTarget || domEvent?.target;
|
||||
const detail = this.extractPlaybackDetail(trigger, cardVideo || video);
|
||||
this.emitSelected(detail);
|
||||
};
|
||||
|
||||
videoCard.onEdit = ({ video: editVideo, index: editIndex }) => {
|
||||
if (this.handlers.edit) {
|
||||
this.handlers.edit({ video: editVideo, index: editIndex });
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => {
|
||||
if (this.handlers.revert) {
|
||||
this.handlers.revert({ video: revertVideo, index: revertIndex });
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => {
|
||||
if (this.handlers.delete) {
|
||||
this.handlers.delete({ video: deleteVideo, index: deleteIndex });
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onMoreAction = ({ dataset }) => {
|
||||
const action = dataset?.action || "";
|
||||
if (action === "blacklist-author") {
|
||||
if (this.handlers.blacklist) {
|
||||
this.handlers.blacklist({ video, dataset });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "copy-link") {
|
||||
const shareDetail = this.buildShareDetail(video, dataset);
|
||||
this.emitShare(shareDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitContextAction(action, { video, dataset });
|
||||
};
|
||||
|
||||
videoCard.onAuthorNavigate = ({ pubkey }) => {
|
||||
this.emitContextAction("open-channel", {
|
||||
video,
|
||||
dataset: { author: pubkey || video.pubkey || "", context: "card" },
|
||||
});
|
||||
};
|
||||
|
||||
const cardEl = videoCard.getRoot();
|
||||
if (!cardEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerInfo) {
|
||||
this.registerVideoViewCountElement(cardEl, pointerInfo);
|
||||
}
|
||||
|
||||
if (video && video.id) {
|
||||
this.state.videosMap.set(video.id, video);
|
||||
}
|
||||
|
||||
fragment.appendChild(cardEl);
|
||||
this.renderedVideoIds.add(video.id);
|
||||
this.videoCardInstances.push(videoCard);
|
||||
|
||||
const shouldResolvePostedAt =
|
||||
normalizedPostedAt === null &&
|
||||
typeof this.utils.resolveVideoPostedAt === "function";
|
||||
|
||||
if (shouldResolvePostedAt) {
|
||||
Promise.resolve(this.utils.resolveVideoPostedAt(video))
|
||||
.then((resolvedPostedAt) => {
|
||||
if (!Number.isFinite(resolvedPostedAt)) {
|
||||
return;
|
||||
}
|
||||
if (!this.videoCardInstances.includes(videoCard)) {
|
||||
return;
|
||||
}
|
||||
if (videoCard.video?.id !== video.id) {
|
||||
return;
|
||||
}
|
||||
videoCard.updatePostedAt(Math.floor(resolvedPostedAt));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.window?.console?.warn) {
|
||||
this.window.console.warn(
|
||||
"[VideoListView] Failed to resolve posted timestamp:",
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.container.innerHTML = "";
|
||||
this.container.appendChild(fragment);
|
||||
|
||||
this.badgeHelpers.attachHealthBadges(this.container);
|
||||
this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
|
||||
this.utils.handleUrlHealthBadge({
|
||||
video: this.state.videosMap.get(eventId) || { id: eventId },
|
||||
url,
|
||||
badgeEl,
|
||||
});
|
||||
});
|
||||
|
||||
this.utils.bindThumbnailFallbacks(this.container);
|
||||
|
||||
if (this.mediaLoader && typeof this.mediaLoader.observe === "function") {
|
||||
const lazyEls = this.container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => this.mediaLoader.observe(el));
|
||||
}
|
||||
|
||||
if (authorSet.size) {
|
||||
this.utils.batchFetchProfiles(authorSet);
|
||||
}
|
||||
this.utils.refreshDiscussionCounts(displayVideos, {
|
||||
container: this.container,
|
||||
});
|
||||
this.pruneDetachedViewCountElements();
|
||||
|
||||
return displayVideos;
|
||||
}
|
||||
|
||||
cacheUrlHealth(videoId, entry) {
|
||||
if (!videoId || !(this.state.urlHealthByVideoId instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry || typeof entry !== "object") {
|
||||
this.state.urlHealthByVideoId.delete(videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
status:
|
||||
typeof entry.status === "string" && entry.status ? entry.status : "checking",
|
||||
message:
|
||||
typeof entry.message === "string" && entry.message
|
||||
? entry.message
|
||||
: "Checking hosted URL…",
|
||||
};
|
||||
|
||||
if (Number.isFinite(entry.lastCheckedAt)) {
|
||||
normalized.lastCheckedAt = Math.floor(entry.lastCheckedAt);
|
||||
}
|
||||
|
||||
this.state.urlHealthByVideoId.set(videoId, normalized);
|
||||
}
|
||||
|
||||
cacheStreamHealth(videoId, entry) {
|
||||
if (!videoId || !(this.state.streamHealthByVideoId instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry || typeof entry !== "object") {
|
||||
this.state.streamHealthByVideoId.delete(videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
state:
|
||||
typeof entry.state === "string" && entry.state ? entry.state : "unknown",
|
||||
};
|
||||
|
||||
if (Number.isFinite(entry.peers)) {
|
||||
normalized.peers = Math.max(0, Number(entry.peers));
|
||||
}
|
||||
if (typeof entry.reason === "string" && entry.reason) {
|
||||
normalized.reason = entry.reason;
|
||||
}
|
||||
if (Number.isFinite(entry.checkedAt)) {
|
||||
normalized.checkedAt = Math.floor(entry.checkedAt);
|
||||
}
|
||||
if (typeof entry.text === "string" && entry.text) {
|
||||
normalized.text = entry.text;
|
||||
}
|
||||
if (typeof entry.tooltip === "string" && entry.tooltip) {
|
||||
normalized.tooltip = entry.tooltip;
|
||||
}
|
||||
if (entry.role === "alert" || entry.role === "status") {
|
||||
normalized.role = entry.role;
|
||||
}
|
||||
if (entry.ariaLive === "assertive" || entry.ariaLive === "polite") {
|
||||
normalized.ariaLive = entry.ariaLive;
|
||||
}
|
||||
|
||||
this.state.streamHealthByVideoId.set(videoId, normalized);
|
||||
}
|
||||
|
||||
cleanupHealthCache(videos) {
|
||||
const activeIds = new Set();
|
||||
if (Array.isArray(videos)) {
|
||||
videos.forEach((video) => {
|
||||
if (video && typeof video.id === "string" && video.id) {
|
||||
activeIds.add(video.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.urlHealthByVideoId instanceof Map) {
|
||||
Array.from(this.state.urlHealthByVideoId.keys()).forEach((videoId) => {
|
||||
if (!activeIds.has(videoId)) {
|
||||
this.state.urlHealthByVideoId.delete(videoId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.streamHealthByVideoId instanceof Map) {
|
||||
Array.from(this.state.streamHealthByVideoId.keys()).forEach((videoId) => {
|
||||
if (!activeIds.has(videoId)) {
|
||||
this.state.streamHealthByVideoId.delete(videoId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateVideo(videoId, patch) {
|
||||
if (!videoId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = this.currentVideos.findIndex((video) => video?.id === videoId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = this.currentVideos[index] || {};
|
||||
const next =
|
||||
typeof patch === "function" ? patch({ ...current }) : { ...current, ...patch };
|
||||
|
||||
this.currentVideos[index] = next;
|
||||
this.state.videosMap.set(videoId, next);
|
||||
this.render(this.currentVideos.slice(), this.state.feedMetadata);
|
||||
return next;
|
||||
}
|
||||
|
||||
computeShareBase() {
|
||||
const explicit = this.utils.getShareUrlBase();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const win = this.window;
|
||||
if (win?.location) {
|
||||
const origin = win.location.origin || "";
|
||||
const pathname = win.location.pathname || "";
|
||||
if (origin || pathname) {
|
||||
return `${origin}${pathname}`;
|
||||
}
|
||||
if (win.location.href) {
|
||||
return win.location.href.split(/[?#]/)[0];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
buildShareUrl(video, shareBase) {
|
||||
if (!video?.id) {
|
||||
return "#";
|
||||
}
|
||||
|
||||
const win = this.window;
|
||||
try {
|
||||
const nevent = win?.NostrTools?.nip19?.neventEncode({ id: video.id });
|
||||
if (nevent) {
|
||||
const explicit = this.utils.buildShareUrlFromNevent(nevent);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (shareBase) {
|
||||
return `${shareBase}?v=${encodeURIComponent(nevent)}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore and fall back below
|
||||
}
|
||||
|
||||
if (shareBase) {
|
||||
return `${shareBase}?v=${encodeURIComponent(video.id)}`;
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
syncViewCountSubscriptions(videos) {
|
||||
const activePointerKeys = new Set();
|
||||
videos.forEach((video) => {
|
||||
const pointerInfo = this.utils.derivePointerInfo(video);
|
||||
if (pointerInfo?.key) {
|
||||
activePointerKeys.add(pointerInfo.key);
|
||||
}
|
||||
});
|
||||
|
||||
const keysToRemove = [];
|
||||
for (const [key, entry] of this.viewCountSubscriptions.entries()) {
|
||||
if (!activePointerKeys.has(key)) {
|
||||
keysToRemove.push(key);
|
||||
continue;
|
||||
}
|
||||
if (entry && entry.elements instanceof Set) {
|
||||
entry.elements.clear();
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
const entry = this.viewCountSubscriptions.get(key);
|
||||
if (entry && entry.pointer && entry.token) {
|
||||
try {
|
||||
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[viewCount] Failed to unsubscribe from stale pointer ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
this.viewCountSubscriptions.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
cleanupThumbnailCache(videos) {
|
||||
const cache = this.state.loadedThumbnails;
|
||||
if (!cache || !cache.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = new Set();
|
||||
videos.forEach((video) => {
|
||||
if (video && typeof video.id === "string" && video.id) {
|
||||
activeIds.add(video.id);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(cache.keys()).forEach((videoId) => {
|
||||
if (!activeIds.has(videoId)) {
|
||||
cache.delete(videoId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
extractPlaybackDetail(trigger, video) {
|
||||
const element = trigger instanceof HTMLElement ? trigger : null;
|
||||
const target = element?.closest("[data-play-url],[data-play-magnet]") || element;
|
||||
|
||||
const rawUrlValue =
|
||||
(target?.dataset && typeof target.dataset.playUrl === "string"
|
||||
? target.dataset.playUrl
|
||||
: null) ?? target?.getAttribute?.("data-play-url") ?? "";
|
||||
const rawMagnetValue =
|
||||
(target?.dataset && typeof target.dataset.playMagnet === "string"
|
||||
? target.dataset.playMagnet
|
||||
: null) ?? target?.getAttribute?.("data-play-magnet") ?? "";
|
||||
|
||||
let url = "";
|
||||
if (rawUrlValue) {
|
||||
try {
|
||||
url = decodeURIComponent(rawUrlValue);
|
||||
} catch (error) {
|
||||
url = rawUrlValue;
|
||||
}
|
||||
}
|
||||
|
||||
const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : "";
|
||||
const videoId =
|
||||
target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || "";
|
||||
|
||||
return { videoId, url, magnet, video };
|
||||
}
|
||||
|
||||
emitSelected(detail) {
|
||||
const enriched = {
|
||||
...detail,
|
||||
video: detail?.video || this.state.videosMap.get(detail?.videoId) || null,
|
||||
};
|
||||
|
||||
if (this.handlers.playback) {
|
||||
this.handlers.playback(enriched);
|
||||
}
|
||||
|
||||
this.emit("video:selected", enriched);
|
||||
}
|
||||
|
||||
buildShareDetail(video, dataset = {}) {
|
||||
const eventId = dataset.eventId || video?.id || "";
|
||||
const shareUrl = this.utils.buildShareUrlFromEventId(eventId);
|
||||
return {
|
||||
action: "copy-link",
|
||||
video,
|
||||
eventId,
|
||||
shareUrl,
|
||||
dataset,
|
||||
};
|
||||
}
|
||||
|
||||
emitShare(detail) {
|
||||
this.emit("video:share", detail);
|
||||
}
|
||||
|
||||
emitContextAction(action, payload = {}) {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
this.emit("video:context-action", { action, ...payload });
|
||||
}
|
||||
|
||||
emit(type, detail) {
|
||||
if (this.emitter) {
|
||||
this.emitter.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.dispatchEvent(
|
||||
new CustomEvent(type, { detail, bubbles: true, composed: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ensureViewCountSubscription(pointerInfo) {
|
||||
if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = this.viewCountSubscriptions.get(pointerInfo.key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
pointer: pointerInfo.pointer,
|
||||
key: pointerInfo.key,
|
||||
token: null,
|
||||
elements: new Set(),
|
||||
lastTotal: null,
|
||||
lastStatus: "idle",
|
||||
lastText: "– views",
|
||||
};
|
||||
|
||||
try {
|
||||
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status }) => {
|
||||
let text;
|
||||
if (Number.isFinite(total)) {
|
||||
const numeric = Number(total);
|
||||
entry.lastTotal = numeric;
|
||||
text = this.formatters.formatViewCountLabel(numeric);
|
||||
} else if (status === "hydrating") {
|
||||
text = "Loading views…";
|
||||
} else {
|
||||
text = "– views";
|
||||
}
|
||||
|
||||
entry.lastStatus = status;
|
||||
entry.lastText = text;
|
||||
|
||||
for (const el of Array.from(entry.elements)) {
|
||||
if (!el || !el.isConnected) {
|
||||
entry.elements.delete(el);
|
||||
continue;
|
||||
}
|
||||
el.textContent = text;
|
||||
}
|
||||
});
|
||||
entry.token = token;
|
||||
this.viewCountSubscriptions.set(pointerInfo.key, entry);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
console.warn("[viewCount] Failed to subscribe to view counter:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
registerVideoViewCountElement(cardEl, pointerInfo) {
|
||||
if (!cardEl || !pointerInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewCountEl = cardEl.querySelector("[data-view-count]");
|
||||
if (!viewCountEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) {
|
||||
viewCountEl.textContent = "– views";
|
||||
}
|
||||
|
||||
const entry = this.ensureViewCountSubscription(pointerInfo);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.elements.add(viewCountEl);
|
||||
if (typeof pointerInfo.key === "string") {
|
||||
viewCountEl.dataset.viewPointer = pointerInfo.key;
|
||||
}
|
||||
viewCountEl.textContent = entry.lastText;
|
||||
}
|
||||
|
||||
pruneDetachedViewCountElements() {
|
||||
for (const entry of this.viewCountSubscriptions.values()) {
|
||||
if (!entry || !(entry.elements instanceof Set)) {
|
||||
continue;
|
||||
}
|
||||
for (const el of Array.from(entry.elements)) {
|
||||
if (!el || !el.isConnected) {
|
||||
entry.elements.delete(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teardownAllViewCountSubscriptions() {
|
||||
const keys = Array.from(this.viewCountSubscriptions.keys());
|
||||
keys.forEach((key) => {
|
||||
const entry = this.viewCountSubscriptions.get(key);
|
||||
if (entry && entry.token && entry.pointer) {
|
||||
try {
|
||||
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[viewCount] Failed to unsubscribe from pointer ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
this.viewCountSubscriptions.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
closeAllMenus() {
|
||||
if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) {
|
||||
this.videoCardInstances.forEach((card) => {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
if (typeof card.closeMoreMenu === "function") {
|
||||
card.closeMoreMenu();
|
||||
}
|
||||
if (typeof card.closeSettingsMenu === "function") {
|
||||
card.closeSettingsMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menus = this.document.querySelectorAll("[data-more-menu]");
|
||||
menus.forEach((menu) => {
|
||||
if (menu instanceof HTMLElement) {
|
||||
menu.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = this.document.querySelectorAll("[data-more-dropdown]");
|
||||
buttons.forEach((btn) => {
|
||||
if (btn instanceof HTMLElement) {
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleContainerClick(event) {
|
||||
if (!event || !this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = target.closest("[data-play-magnet],[data-play-url]");
|
||||
if (!trigger || !this.container.contains(trigger)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPrimaryClick =
|
||||
typeof event.button !== "number" || event.button === 0;
|
||||
if (!isPrimaryClick || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const detail = this.extractPlaybackDetail(trigger, null);
|
||||
this.emitSelected(detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import {
|
||||
normalizePointerInput,
|
||||
pointerKey as derivePointerKey,
|
||||
} from "../nostr.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function resolveCardElement(trigger) {
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = trigger.closest(".video-card");
|
||||
return candidate instanceof HTMLElement ? candidate : null;
|
||||
}
|
||||
|
||||
function safeInvoke(callback, payload) {
|
||||
if (typeof callback !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] onStateChange handler failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class WatchHistoryController {
|
||||
constructor({
|
||||
watchHistoryService,
|
||||
nostrClient,
|
||||
showError,
|
||||
showSuccess,
|
||||
dropWatchHistoryMetadata,
|
||||
getActivePubkey,
|
||||
} = {}) {
|
||||
this.watchHistoryService = watchHistoryService || null;
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.showError = typeof showError === "function" ? showError : noop;
|
||||
this.showSuccess = typeof showSuccess === "function" ? showSuccess : noop;
|
||||
this.dropWatchHistoryMetadata =
|
||||
typeof dropWatchHistoryMetadata === "function"
|
||||
? dropWatchHistoryMetadata
|
||||
: noop;
|
||||
this.getActivePubkey =
|
||||
typeof getActivePubkey === "function" ? getActivePubkey : () => "";
|
||||
}
|
||||
|
||||
buildPointerFromDataset(dataset = {}) {
|
||||
if (!dataset || typeof dataset !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeValue =
|
||||
typeof dataset.pointerType === "string" ? dataset.pointerType : "";
|
||||
const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : "";
|
||||
const value =
|
||||
typeof dataset.pointerValue === "string" && dataset.pointerValue.trim()
|
||||
? dataset.pointerValue.trim()
|
||||
: "";
|
||||
|
||||
if (!normalizedType || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointer = { type: normalizedType, value };
|
||||
|
||||
if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) {
|
||||
pointer.relay = dataset.pointerRelay.trim();
|
||||
}
|
||||
|
||||
if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) {
|
||||
const parsed = Number.parseInt(dataset.pointerWatchedAt, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
pointer.watchedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataset.pointerSession === "true") {
|
||||
pointer.session = true;
|
||||
}
|
||||
|
||||
return pointer;
|
||||
}
|
||||
|
||||
resolvePointerKey(pointer, explicitKey) {
|
||||
if (typeof explicitKey === "string" && explicitKey.trim()) {
|
||||
return explicitKey.trim();
|
||||
}
|
||||
|
||||
const normalized = normalizePointerInput(pointer);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return derivePointerKey(normalized) || "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] Failed to derive pointer key:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
applyCardState(card, { status, removeCard } = {}) {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
card.dataset.historyRemovalPending = "true";
|
||||
card.classList.add("opacity-60");
|
||||
card.classList.add("pointer-events-none");
|
||||
return;
|
||||
}
|
||||
|
||||
delete card.dataset.historyRemovalPending;
|
||||
card.classList.remove("opacity-60", "pointer-events-none");
|
||||
|
||||
if (status === "removed" && removeCard) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
|
||||
resolveActor(actor) {
|
||||
if (typeof actor === "string" && actor.trim()) {
|
||||
return actor.trim();
|
||||
}
|
||||
|
||||
const candidate = this.getActivePubkey();
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
|
||||
const sessionPubkey = this.nostrClient?.sessionActor?.pubkey;
|
||||
if (typeof sessionPubkey === "string" && sessionPubkey.trim()) {
|
||||
return sessionPubkey.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async removeEntry(options = {}) {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
dataset = {},
|
||||
pointer: pointerInput = null,
|
||||
pointerKey: explicitPointerKey = "",
|
||||
reason: providedReason,
|
||||
trigger = null,
|
||||
removeCard = false,
|
||||
actor,
|
||||
items,
|
||||
onStateChange,
|
||||
} = options;
|
||||
|
||||
const pointer = pointerInput || this.buildPointerFromDataset(dataset);
|
||||
const pointerKey = this.resolvePointerKey(pointer, explicitPointerKey || dataset.pointerKey);
|
||||
|
||||
if (!pointerKey) {
|
||||
this.showError("Unable to determine which history entry to remove.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof providedReason === "string" && providedReason.trim()
|
||||
? providedReason.trim()
|
||||
: typeof dataset.reason === "string" && dataset.reason.trim()
|
||||
? dataset.reason.trim()
|
||||
: "remove-item";
|
||||
|
||||
const card = resolveCardElement(trigger);
|
||||
this.applyCardState(card, { status: "pending" });
|
||||
safeInvoke(onStateChange, { status: "pending", card, removeCard });
|
||||
|
||||
try {
|
||||
const result = await this.handleWatchHistoryRemoval({
|
||||
actor,
|
||||
items,
|
||||
reason,
|
||||
removed: {
|
||||
pointer,
|
||||
pointerKey,
|
||||
},
|
||||
});
|
||||
|
||||
this.applyCardState(card, { status: removeCard ? "removed" : "idle", removeCard });
|
||||
safeInvoke(onStateChange, {
|
||||
status: removeCard ? "removed" : "idle",
|
||||
card,
|
||||
removeCard,
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.applyCardState(card, { status: "idle" });
|
||||
safeInvoke(onStateChange, { status: "error", card, error, removeCard });
|
||||
|
||||
if (!error?.handled) {
|
||||
this.showError("Failed to remove from history. Please try again.");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWatchHistoryRemoval(payload = {}) {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
this.showError("Watch history sync is not available right now.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "remove-item";
|
||||
|
||||
const actorCandidate = this.resolveActor(payload.actor);
|
||||
|
||||
const removedPointerRaw =
|
||||
payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null;
|
||||
const removedPointerNormalized = normalizePointerInput(removedPointerRaw);
|
||||
let removedPointerKey =
|
||||
typeof payload?.removed?.pointerKey === "string"
|
||||
? payload.removed.pointerKey
|
||||
: "";
|
||||
if (!removedPointerKey && removedPointerNormalized) {
|
||||
removedPointerKey = derivePointerKey(removedPointerNormalized) || "";
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
this.dropWatchHistoryMetadata(removedPointerKey);
|
||||
}
|
||||
|
||||
const normalizeEntry = (entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const pointer = normalizePointerInput(entry.pointer || entry);
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
if (Number.isFinite(entry.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.watchedAt);
|
||||
} else if (Number.isFinite(entry.pointer?.watchedAt)) {
|
||||
pointer.watchedAt = Math.floor(entry.pointer.watchedAt);
|
||||
}
|
||||
if (entry.pointer?.session === true || entry.session === true) {
|
||||
pointer.session = true;
|
||||
}
|
||||
return pointer;
|
||||
};
|
||||
|
||||
let normalizedItems = null;
|
||||
|
||||
if (Array.isArray(payload.items) && payload.items.length) {
|
||||
normalizedItems = payload.items.map(normalizeEntry).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!normalizedItems) {
|
||||
try {
|
||||
const latest = await this.watchHistoryService.loadLatest(
|
||||
actorCandidate,
|
||||
{ allowStale: false },
|
||||
);
|
||||
normalizedItems = Array.isArray(latest)
|
||||
? latest.map(normalizeEntry).filter(Boolean)
|
||||
: [];
|
||||
} catch (error) {
|
||||
this.showError("Failed to load watch history. Please try again.");
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedPointerKey) {
|
||||
normalizedItems = normalizedItems.filter((entry) => {
|
||||
try {
|
||||
return derivePointerKey(entry) !== removedPointerKey;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.showSuccess("Removing from history…");
|
||||
|
||||
try {
|
||||
const snapshotResult = await this.watchHistoryService.snapshot(normalizedItems, {
|
||||
actor: actorCandidate || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.nostrClient?.updateWatchHistoryList?.(normalizedItems, {
|
||||
actorPubkey: actorCandidate || undefined,
|
||||
replace: true,
|
||||
source: reason,
|
||||
});
|
||||
} catch (updateError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryController] Failed to update local watch history list:",
|
||||
updateError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.showSuccess(
|
||||
"Removed from encrypted history. Relay sync may take a moment.",
|
||||
);
|
||||
|
||||
return { handledToasts: true, snapshot: snapshotResult };
|
||||
} catch (error) {
|
||||
let message = "Failed to remove from history. Please try again.";
|
||||
if (error?.result?.retryable) {
|
||||
message =
|
||||
"Removal will retry once encrypted history is accepted by your relays.";
|
||||
}
|
||||
this.showError(message);
|
||||
if (error && typeof error === "object") {
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
flush(reason = "session-end", context = "watch-history") {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
const result = this.watchHistoryService.snapshot(undefined, { reason });
|
||||
return Promise.resolve(result).catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Watch history flush failed:`, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Failed to queue watch history flush:`, error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import {
|
||||
calculateZapShares,
|
||||
fetchLightningMetadata,
|
||||
formatMinRequirement,
|
||||
getCachedLightningEntry,
|
||||
getCachedMetadataByUrl,
|
||||
getCachedPlatformLightningAddress,
|
||||
isMetadataEntryFresh,
|
||||
normalizeLightningAddressKey,
|
||||
rememberLightningMetadata,
|
||||
setCachedPlatformLightningAddress,
|
||||
validateInvoiceAmount,
|
||||
} from "../payments/zapSharedState.js";
|
||||
import {
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
requestInvoice,
|
||||
} from "../payments/lnurl.js";
|
||||
import { getPlatformLightningAddress } from "../payments/platformAddress.js";
|
||||
import {
|
||||
ensureWallet as ensureWalletDefault,
|
||||
sendPayment as sendPaymentDefault,
|
||||
} from "../payments/nwcClient.js";
|
||||
|
||||
const DEFAULT_SPLIT_SUMMARY = "Enter an amount to view the split.";
|
||||
|
||||
export default class ZapController {
|
||||
constructor({
|
||||
videoModal,
|
||||
getCurrentVideo,
|
||||
nwcSettings,
|
||||
getActiveNwcSettings,
|
||||
isUserLoggedIn,
|
||||
hasActiveWalletConnection,
|
||||
splitAndZap,
|
||||
payments = {},
|
||||
callbacks = {},
|
||||
requestWalletPane,
|
||||
} = {}) {
|
||||
this.videoModal = videoModal || null;
|
||||
this.getCurrentVideo =
|
||||
typeof getCurrentVideo === "function" ? getCurrentVideo : () => null;
|
||||
this.nwcSettings =
|
||||
nwcSettings &&
|
||||
typeof nwcSettings === "object" &&
|
||||
typeof nwcSettings.getActiveNwcSettings === "function"
|
||||
? nwcSettings
|
||||
: null;
|
||||
if (this.nwcSettings) {
|
||||
this.getActiveNwcSettings = () =>
|
||||
this.nwcSettings.getActiveNwcSettings();
|
||||
} else if (typeof getActiveNwcSettings === "function") {
|
||||
this.getActiveNwcSettings = getActiveNwcSettings;
|
||||
} else {
|
||||
this.getActiveNwcSettings = () => ({});
|
||||
}
|
||||
this.isUserLoggedIn =
|
||||
typeof isUserLoggedIn === "function" ? isUserLoggedIn : () => false;
|
||||
if (
|
||||
this.nwcSettings &&
|
||||
typeof this.nwcSettings.hasActiveWalletConnection === "function"
|
||||
) {
|
||||
this.hasActiveWalletConnection = () =>
|
||||
this.nwcSettings.hasActiveWalletConnection();
|
||||
} else if (typeof hasActiveWalletConnection === "function") {
|
||||
this.hasActiveWalletConnection = hasActiveWalletConnection;
|
||||
} else {
|
||||
this.hasActiveWalletConnection = () => false;
|
||||
}
|
||||
this.splitAndZap =
|
||||
typeof splitAndZap === "function" ? splitAndZap : () => Promise.resolve();
|
||||
this.payments = payments || {};
|
||||
this.callbacks = {
|
||||
onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null,
|
||||
onError: typeof callbacks.onError === "function" ? callbacks.onError : null,
|
||||
};
|
||||
this.requestWalletPane =
|
||||
typeof requestWalletPane === "function" ? requestWalletPane : null;
|
||||
|
||||
this.modalZapInFlight = false;
|
||||
this.modalZapRetryState = null;
|
||||
this.modalZapAmountValue = 0;
|
||||
this.modalZapCommentValue = "";
|
||||
}
|
||||
|
||||
/** Public API **/
|
||||
|
||||
setVisibility(visible) {
|
||||
const lightningVisible = !!visible;
|
||||
const shouldShow = lightningVisible && this.isUserLoggedIn();
|
||||
const hasWallet = this.hasActiveWalletConnection();
|
||||
if (this.videoModal) {
|
||||
this.videoModal.setZapVisibility(shouldShow);
|
||||
this.videoModal.setWalletPromptVisible(shouldShow && !hasWallet);
|
||||
}
|
||||
}
|
||||
|
||||
resetState({ preserveAmount = false } = {}) {
|
||||
if (!preserveAmount) {
|
||||
this.modalZapAmountValue = 0;
|
||||
}
|
||||
this.modalZapCommentValue = "";
|
||||
this.modalZapInFlight = false;
|
||||
this.resetRetryState();
|
||||
if (this.videoModal) {
|
||||
this.videoModal.setZapCompleted(false);
|
||||
this.videoModal.resetZapForm({
|
||||
amount: preserveAmount ? this.modalZapAmountValue || "" : "",
|
||||
comment: "",
|
||||
});
|
||||
this.videoModal.setZapPending(false);
|
||||
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.resetFeedback();
|
||||
this.applyDefaultAmount();
|
||||
this.preloadLightningMetadata().catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[zap] Preload metadata error:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.resetFeedback();
|
||||
}
|
||||
|
||||
setAmount(amount) {
|
||||
const rawAmount = Number(amount);
|
||||
const fallback = this.videoModal?.getZapAmountValue?.() || 0;
|
||||
const value = Number.isFinite(rawAmount) ? rawAmount : fallback;
|
||||
this.modalZapAmountValue = Math.max(0, Math.round(value || 0));
|
||||
this.resetFeedback();
|
||||
this.updateSplitSummary();
|
||||
}
|
||||
|
||||
setComment(comment) {
|
||||
if (typeof comment === "string") {
|
||||
this.modalZapCommentValue = comment.trim();
|
||||
return;
|
||||
}
|
||||
const fallback = this.videoModal?.getZapCommentValue?.();
|
||||
this.modalZapCommentValue = typeof fallback === "string" ? fallback : "";
|
||||
}
|
||||
|
||||
async sendZap({ amount, comment } = {}) {
|
||||
if (this.modalZapInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoModal?.setZapCompleted(false);
|
||||
|
||||
const numericAmount = Number.isFinite(Number(amount))
|
||||
? Number(amount)
|
||||
: this.modalZapAmountValue;
|
||||
const roundedAmount = Math.max(0, Math.round(numericAmount || 0));
|
||||
this.modalZapAmountValue = roundedAmount;
|
||||
|
||||
const trimmedComment =
|
||||
typeof comment === "string" ? comment.trim() : this.modalZapCommentValue;
|
||||
this.modalZapCommentValue = trimmedComment;
|
||||
|
||||
if (!roundedAmount) {
|
||||
const message = "Enter a zap amount greater than zero.";
|
||||
this.videoModal?.setZapStatus(message, "error");
|
||||
this.notifyError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const walletSettings = this.getWalletSettingsOrPrompt();
|
||||
if (!walletSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalZapInFlight = true;
|
||||
this.videoModal?.setZapPending(true);
|
||||
this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "warning");
|
||||
this.videoModal?.clearZapReceipts();
|
||||
|
||||
try {
|
||||
if (
|
||||
Array.isArray(this.modalZapRetryState?.shares) &&
|
||||
this.modalZapRetryState.shares.length
|
||||
) {
|
||||
const retrySuccess = await this.executeRetry({
|
||||
walletSettings,
|
||||
comment: trimmedComment,
|
||||
});
|
||||
if (retrySuccess) {
|
||||
this.modalZapCommentValue = "";
|
||||
this.videoModal?.resetZapForm({ amount: "", comment: "" });
|
||||
this.videoModal?.setZapCompleted(true);
|
||||
}
|
||||
this.applyDefaultAmount();
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = await this.runZapAttempt({
|
||||
amount: roundedAmount,
|
||||
walletSettings,
|
||||
comment: trimmedComment,
|
||||
});
|
||||
if (!attempt) {
|
||||
this.videoModal?.setZapStatus("", "neutral");
|
||||
return;
|
||||
}
|
||||
|
||||
const { context, result } = attempt;
|
||||
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
|
||||
this.videoModal?.renderZapReceipts(receipts, { partial: false });
|
||||
|
||||
const creatorShare = context.shares.creatorShare;
|
||||
const platformShare = context.shares.platformShare;
|
||||
const summary = platformShare
|
||||
? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).`
|
||||
: `Sent ${context.shares.total} sats to the creator.`;
|
||||
this.videoModal?.setZapStatus(summary, "success");
|
||||
this.notifySuccess("Zap sent successfully!");
|
||||
|
||||
this.resetRetryState();
|
||||
this.modalZapCommentValue = "";
|
||||
this.videoModal?.resetZapForm({ amount: "", comment: "" });
|
||||
this.videoModal?.setZapCompleted(true);
|
||||
this.applyDefaultAmount();
|
||||
} catch (error) {
|
||||
this.handleZapError({ error, amount: roundedAmount, comment: trimmedComment, walletSettings });
|
||||
} finally {
|
||||
this.modalZapInFlight = false;
|
||||
this.videoModal?.setZapPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleWalletLink() {
|
||||
if (this.requestWalletPane) {
|
||||
Promise.resolve()
|
||||
.then(() => this.requestWalletPane())
|
||||
.catch((error) => {
|
||||
console.error("Failed to open wallet pane:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal helpers **/
|
||||
|
||||
resetFeedback() {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
this.videoModal.setZapCompleted(false);
|
||||
this.videoModal.setZapStatus("", "neutral");
|
||||
this.videoModal.clearZapReceipts();
|
||||
this.videoModal.setZapRetryPending(false);
|
||||
this.videoModal.setZapPending(false);
|
||||
}
|
||||
|
||||
applyDefaultAmount() {
|
||||
const settings = this.getActiveNwcSettings();
|
||||
let defaultAmount = 0;
|
||||
if (Number.isFinite(settings?.defaultZap) && settings.defaultZap > 0) {
|
||||
defaultAmount = Math.max(0, Math.round(settings.defaultZap));
|
||||
}
|
||||
this.modalZapAmountValue = defaultAmount;
|
||||
if (this.videoModal) {
|
||||
this.videoModal.setZapAmount(defaultAmount || "");
|
||||
}
|
||||
this.updateSplitSummary();
|
||||
}
|
||||
|
||||
updateSplitSummary() {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
|
||||
if (!amount) {
|
||||
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = calculateZapShares(amount);
|
||||
const parts = [];
|
||||
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
||||
const creatorEntry = getCachedLightningEntry(lightningAddress);
|
||||
const creatorMin = formatMinRequirement(creatorEntry?.metadata);
|
||||
let creatorText = `Creator: ${shares.creatorShare} sats`;
|
||||
if (Number.isFinite(creatorMin) && creatorMin > 0) {
|
||||
creatorText += ` (min ${creatorMin})`;
|
||||
}
|
||||
parts.push(creatorText);
|
||||
|
||||
if (shares.platformShare > 0) {
|
||||
const platformEntry = getCachedLightningEntry(
|
||||
getCachedPlatformLightningAddress()
|
||||
);
|
||||
const platformMin = formatMinRequirement(platformEntry?.metadata);
|
||||
let platformText = `Platform: ${shares.platformShare} sats`;
|
||||
if (Number.isFinite(platformMin) && platformMin > 0) {
|
||||
platformText += ` (min ${platformMin})`;
|
||||
}
|
||||
parts.push(platformText);
|
||||
}
|
||||
|
||||
this.videoModal.setZapSplitSummary(parts.join(" • "));
|
||||
}
|
||||
|
||||
async preloadLightningMetadata() {
|
||||
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
||||
if (lightningAddress) {
|
||||
try {
|
||||
await fetchLightningMetadata(lightningAddress);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[zap] Failed to preload creator metadata:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
|
||||
if (!amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = calculateZapShares(amount);
|
||||
if (shares.platformShare > 0) {
|
||||
let platformAddress = getCachedPlatformLightningAddress();
|
||||
if (!platformAddress) {
|
||||
try {
|
||||
platformAddress = await getPlatformLightningAddress({
|
||||
forceRefresh: false,
|
||||
});
|
||||
setCachedPlatformLightningAddress(platformAddress || "");
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[zap] Failed to load platform Lightning address:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (platformAddress) {
|
||||
try {
|
||||
await fetchLightningMetadata(platformAddress);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[zap] Failed to preload platform metadata:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getWalletSettingsOrPrompt() {
|
||||
const settings = this.getActiveNwcSettings();
|
||||
const normalizedUri =
|
||||
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
||||
if (!normalizedUri) {
|
||||
this.notifyError("Connect a Lightning wallet to send zaps.");
|
||||
if (this.requestWalletPane) {
|
||||
this.handleWalletLink();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...settings,
|
||||
nwcUri: normalizedUri,
|
||||
};
|
||||
}
|
||||
|
||||
createDependencies({ creatorEntry, platformEntry, shares, shareTracker }) {
|
||||
const summarizeTracker = (tracker) =>
|
||||
Array.isArray(tracker)
|
||||
? tracker.map((entry) => ({
|
||||
type: entry?.type || "unknown",
|
||||
status: entry?.status || "unknown",
|
||||
amount:
|
||||
Number.isFinite(entry?.amount) && entry.amount >= 0
|
||||
? entry.amount
|
||||
: undefined,
|
||||
address: entry?.address || undefined,
|
||||
hasPayment: Boolean(entry?.payment),
|
||||
errorMessage:
|
||||
typeof entry?.error?.message === "string"
|
||||
? entry.error.message
|
||||
: entry?.error
|
||||
? String(entry.error)
|
||||
: undefined,
|
||||
}))
|
||||
: undefined;
|
||||
const logZapError = (stage, details, error) => {
|
||||
const summary = {
|
||||
stage,
|
||||
shareType: details?.shareType,
|
||||
address: details?.address,
|
||||
amount:
|
||||
Number.isFinite(details?.amount) && details.amount >= 0
|
||||
? details.amount
|
||||
: undefined,
|
||||
overrideFee:
|
||||
typeof details?.overrideFee === "number"
|
||||
? details.overrideFee
|
||||
: undefined,
|
||||
commentLength:
|
||||
typeof details?.comment === "string"
|
||||
? details.comment.length
|
||||
: undefined,
|
||||
wallet:
|
||||
details?.walletSettings
|
||||
? {
|
||||
hasUri: Boolean(details.walletSettings.nwcUri),
|
||||
type:
|
||||
details.walletSettings.type ||
|
||||
details.walletSettings.name ||
|
||||
details.walletSettings.client ||
|
||||
undefined,
|
||||
}
|
||||
: undefined,
|
||||
shares: details?.context?.shares
|
||||
? {
|
||||
total: details.context.shares.total,
|
||||
creator: details.context.shares.creatorShare,
|
||||
platform: details.context.shares.platformShare,
|
||||
}
|
||||
: undefined,
|
||||
tracker: summarizeTracker(details?.tracker),
|
||||
retryAttempt:
|
||||
Number.isInteger(details?.retryAttempt) && details.retryAttempt >= 0
|
||||
? details.retryAttempt
|
||||
: undefined,
|
||||
};
|
||||
console.error("[zap] Modal zap failure", summary, error);
|
||||
};
|
||||
|
||||
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
||||
const creatorKey = normalizeLightningAddressKey(
|
||||
creatorEntry?.address || lightningAddress
|
||||
);
|
||||
const platformKey = normalizeLightningAddressKey(
|
||||
platformEntry?.address || getCachedPlatformLightningAddress()
|
||||
);
|
||||
|
||||
const ensureWalletFn =
|
||||
typeof this.payments?.ensureWallet === "function"
|
||||
? (options) => this.payments.ensureWallet(options)
|
||||
: (options) => ensureWalletDefault(options);
|
||||
const sendPaymentFn =
|
||||
typeof this.payments?.sendPayment === "function"
|
||||
? (bolt11, params) => this.payments.sendPayment(bolt11, params)
|
||||
: (bolt11, params) => sendPaymentDefault(bolt11, params);
|
||||
|
||||
let activeShare = null;
|
||||
|
||||
return {
|
||||
lnurl: {
|
||||
resolveLightningAddress: (value) => {
|
||||
const normalized = normalizeLightningAddressKey(value);
|
||||
if (normalized && normalized === creatorKey) {
|
||||
activeShare = "creator";
|
||||
if (creatorEntry?.resolved) {
|
||||
return { ...creatorEntry.resolved };
|
||||
}
|
||||
} else if (normalized && normalized === platformKey) {
|
||||
activeShare = "platform";
|
||||
if (platformEntry?.resolved) {
|
||||
return { ...platformEntry.resolved };
|
||||
}
|
||||
} else {
|
||||
activeShare = null;
|
||||
}
|
||||
|
||||
const resolved = resolveLightningAddress(value);
|
||||
if (normalized) {
|
||||
rememberLightningMetadata({
|
||||
key: normalized,
|
||||
address: resolved.address || value,
|
||||
resolved,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
fetchPayServiceData: async (url) => {
|
||||
const cached = getCachedMetadataByUrl(url);
|
||||
if (cached?.metadata && isMetadataEntryFresh(cached)) {
|
||||
return cached.metadata;
|
||||
}
|
||||
|
||||
const metadata = await fetchPayServiceData(url);
|
||||
if (cached) {
|
||||
rememberLightningMetadata({
|
||||
...cached,
|
||||
metadata,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return metadata;
|
||||
},
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
},
|
||||
wallet: {
|
||||
ensureWallet: async (options) => {
|
||||
try {
|
||||
return await ensureWalletFn(options);
|
||||
} catch (error) {
|
||||
logZapError(
|
||||
"wallet.ensureWallet",
|
||||
{
|
||||
tracker: shareTracker,
|
||||
context: { shares },
|
||||
walletSettings: options?.settings,
|
||||
},
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sendPayment: async (bolt11, params) => {
|
||||
try {
|
||||
const payment = await sendPaymentFn(bolt11, params);
|
||||
if (Array.isArray(shareTracker)) {
|
||||
const shareType = activeShare || "unknown";
|
||||
const amount =
|
||||
shareType === "platform"
|
||||
? shares.platformShare
|
||||
: shares.creatorShare;
|
||||
const address =
|
||||
shareType === "platform"
|
||||
? platformEntry?.address || getCachedPlatformLightningAddress()
|
||||
: creatorEntry?.address || lightningAddress;
|
||||
shareTracker.push({
|
||||
type: shareType,
|
||||
status: "success",
|
||||
amount,
|
||||
address,
|
||||
payment,
|
||||
});
|
||||
}
|
||||
return payment;
|
||||
} catch (error) {
|
||||
if (Array.isArray(shareTracker)) {
|
||||
const shareType = activeShare || "unknown";
|
||||
const amount =
|
||||
shareType === "platform"
|
||||
? shares.platformShare
|
||||
: shares.creatorShare;
|
||||
const address =
|
||||
shareType === "platform"
|
||||
? platformEntry?.address || getCachedPlatformLightningAddress()
|
||||
: creatorEntry?.address || lightningAddress;
|
||||
shareTracker.push({
|
||||
type: shareType,
|
||||
status: "error",
|
||||
amount,
|
||||
address,
|
||||
error,
|
||||
});
|
||||
}
|
||||
logZapError(
|
||||
"wallet.sendPayment",
|
||||
{
|
||||
shareType: activeShare || "unknown",
|
||||
amount,
|
||||
address,
|
||||
tracker: shareTracker,
|
||||
context: { shares },
|
||||
},
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
activeShare = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
platformAddress: {
|
||||
getPlatformLightningAddress: async () => {
|
||||
if (platformEntry?.address) {
|
||||
return platformEntry.address;
|
||||
}
|
||||
const cachedAddress = getCachedPlatformLightningAddress();
|
||||
if (cachedAddress) {
|
||||
return cachedAddress;
|
||||
}
|
||||
const fallback = await getPlatformLightningAddress({
|
||||
forceRefresh: false,
|
||||
});
|
||||
setCachedPlatformLightningAddress(fallback || "");
|
||||
return fallback;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async prepareLightningContext({ amount, overrideFee = null }) {
|
||||
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
||||
if (!lightningAddress) {
|
||||
throw new Error("This creator has not configured a Lightning address yet.");
|
||||
}
|
||||
|
||||
const shares = calculateZapShares(amount, overrideFee);
|
||||
if (!shares.total) {
|
||||
throw new Error("Enter a zap amount greater than zero.");
|
||||
}
|
||||
|
||||
const creatorEntry = await fetchLightningMetadata(lightningAddress);
|
||||
if (shares.creatorShare > 0) {
|
||||
try {
|
||||
validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare);
|
||||
} catch (error) {
|
||||
const detail = error?.message || "Unable to validate creator share.";
|
||||
throw new Error(`Creator share error: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
let platformEntry = null;
|
||||
let platformAddress = "";
|
||||
if (shares.platformShare > 0) {
|
||||
platformAddress = getCachedPlatformLightningAddress();
|
||||
if (!platformAddress) {
|
||||
platformAddress = await getPlatformLightningAddress({
|
||||
forceRefresh: false,
|
||||
});
|
||||
setCachedPlatformLightningAddress(platformAddress || "");
|
||||
}
|
||||
if (!platformAddress) {
|
||||
throw new Error("Platform Lightning address is unavailable.");
|
||||
}
|
||||
|
||||
platformEntry = await fetchLightningMetadata(platformAddress);
|
||||
try {
|
||||
validateInvoiceAmount(platformEntry.metadata, shares.platformShare);
|
||||
} catch (error) {
|
||||
const detail = error?.message || "Unable to validate platform share.";
|
||||
throw new Error(`Platform share error: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateSplitSummary();
|
||||
|
||||
return {
|
||||
shares,
|
||||
creatorEntry,
|
||||
platformEntry,
|
||||
platformAddress,
|
||||
};
|
||||
}
|
||||
|
||||
getActiveVideoEvent() {
|
||||
const video = this.getCurrentVideo() || {};
|
||||
const lightningAddress = video.lightningAddress || "";
|
||||
return {
|
||||
kind: typeof video.kind === "number" ? video.kind : 0,
|
||||
id: typeof video.id === "string" ? video.id : "",
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
||||
tags: Array.isArray(video.tags) ? [...video.tags] : [],
|
||||
content: typeof video.content === "string" ? video.content : "",
|
||||
created_at: Number.isFinite(video.created_at)
|
||||
? video.created_at
|
||||
: Math.floor(Date.now() / 1000),
|
||||
lightningAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async runZapAttempt({ amount, overrideFee = null, walletSettings, comment }) {
|
||||
const settings = walletSettings || this.getWalletSettingsOrPrompt();
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let context;
|
||||
try {
|
||||
context = await this.prepareLightningContext({
|
||||
amount,
|
||||
overrideFee,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[zap] Modal lightning context failed",
|
||||
{
|
||||
amount,
|
||||
overrideFee,
|
||||
commentLength: typeof comment === "string" ? comment.length : undefined,
|
||||
wallet: {
|
||||
hasUri: Boolean(settings?.nwcUri),
|
||||
type:
|
||||
settings?.type || settings?.name || settings?.client || undefined,
|
||||
},
|
||||
},
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const shareTracker = [];
|
||||
const dependencies = this.createDependencies({
|
||||
...context,
|
||||
shareTracker,
|
||||
});
|
||||
const videoEvent = this.getActiveVideoEvent();
|
||||
|
||||
let previousOverride;
|
||||
const hasGlobal = typeof globalThis !== "undefined";
|
||||
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
|
||||
previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
|
||||
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.splitAndZap(
|
||||
{
|
||||
videoEvent,
|
||||
amountSats: context.shares.total,
|
||||
comment: typeof comment === "string" ? comment : "",
|
||||
walletSettings: settings,
|
||||
},
|
||||
dependencies
|
||||
);
|
||||
return { context, result, shareTracker };
|
||||
} catch (error) {
|
||||
if (Array.isArray(shareTracker) && shareTracker.length) {
|
||||
error.__zapShareTracker = shareTracker;
|
||||
}
|
||||
console.error(
|
||||
"[zap] splitAndZap failed",
|
||||
{
|
||||
amount,
|
||||
overrideFee,
|
||||
commentLength: typeof comment === "string" ? comment.length : undefined,
|
||||
wallet: {
|
||||
hasUri: Boolean(settings?.nwcUri),
|
||||
type:
|
||||
settings?.type || settings?.name || settings?.client || undefined,
|
||||
},
|
||||
shares: {
|
||||
total: context?.shares?.total,
|
||||
creator: context?.shares?.creatorShare,
|
||||
platform: context?.shares?.platformShare,
|
||||
},
|
||||
tracker: Array.isArray(shareTracker)
|
||||
? shareTracker.map((entry) => ({
|
||||
type: entry?.type || "unknown",
|
||||
status: entry?.status || "unknown",
|
||||
amount: entry?.amount,
|
||||
address: entry?.address,
|
||||
errorMessage:
|
||||
typeof entry?.error?.message === "string"
|
||||
? entry.error.message
|
||||
: entry?.error
|
||||
? String(entry.error)
|
||||
: undefined,
|
||||
}))
|
||||
: undefined,
|
||||
},
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
|
||||
if (typeof previousOverride === "number") {
|
||||
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
|
||||
} else if (
|
||||
globalThis &&
|
||||
Object.prototype.hasOwnProperty.call(globalThis, "__BITVID_PLATFORM_FEE_OVERRIDE__")
|
||||
) {
|
||||
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async executeRetry({ walletSettings, comment }) {
|
||||
const retryState = this.modalZapRetryState;
|
||||
const shares = Array.isArray(retryState?.shares) ? retryState.shares : [];
|
||||
if (!shares.length) {
|
||||
this.resetRetryState();
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = shares
|
||||
.map((share) => {
|
||||
const label =
|
||||
share.type === "platform"
|
||||
? "Platform"
|
||||
: share.type === "creator"
|
||||
? "Creator"
|
||||
: "Lightning";
|
||||
return `${label} ${share.amount} sats`;
|
||||
})
|
||||
.join(", ");
|
||||
this.videoModal?.setZapStatus(
|
||||
`Retrying failed share(s): ${summary}`,
|
||||
"warning"
|
||||
);
|
||||
this.videoModal?.clearZapReceipts();
|
||||
|
||||
const aggregatedReceipts = [];
|
||||
const aggregatedTracker = [];
|
||||
|
||||
for (const share of shares) {
|
||||
const overrideFee = share.type === "platform" ? 100 : 0;
|
||||
try {
|
||||
const attempt = await this.runZapAttempt({
|
||||
amount: share.amount,
|
||||
overrideFee,
|
||||
walletSettings,
|
||||
comment: retryState?.comment || comment || "",
|
||||
});
|
||||
if (!attempt) {
|
||||
this.videoModal?.setZapStatus("", "neutral");
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(attempt?.result?.receipts)) {
|
||||
aggregatedReceipts.push(...attempt.result.receipts);
|
||||
} else if (Array.isArray(attempt?.shareTracker)) {
|
||||
aggregatedTracker.push(...attempt.shareTracker);
|
||||
}
|
||||
} catch (error) {
|
||||
const tracker = Array.isArray(error?.__zapShareTracker)
|
||||
? error.__zapShareTracker
|
||||
: [];
|
||||
console.error(
|
||||
"[zap] Modal zap retry failed",
|
||||
{
|
||||
shareType: share?.type || "unknown",
|
||||
amount: share?.amount,
|
||||
address: share?.address,
|
||||
commentLength:
|
||||
typeof (retryState?.comment || comment) === "string"
|
||||
? (retryState?.comment || comment).length
|
||||
: undefined,
|
||||
tracker: tracker.map((entry) => ({
|
||||
type: entry?.type || "unknown",
|
||||
status: entry?.status || "unknown",
|
||||
amount: entry?.amount,
|
||||
address: entry?.address,
|
||||
errorMessage:
|
||||
typeof entry?.error?.message === "string"
|
||||
? entry.error.message
|
||||
: entry?.error
|
||||
? String(entry.error)
|
||||
: undefined,
|
||||
})),
|
||||
},
|
||||
error
|
||||
);
|
||||
if (tracker.length) {
|
||||
aggregatedTracker.push(...tracker);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedReceipts.length) {
|
||||
this.videoModal?.renderZapReceipts(aggregatedReceipts, { partial: false });
|
||||
} else if (aggregatedTracker.length) {
|
||||
this.videoModal?.renderZapReceipts(aggregatedTracker, { partial: true });
|
||||
}
|
||||
|
||||
const total = shares.reduce((sum, share) => {
|
||||
const value = Math.max(0, Math.round(Number(share.amount) || 0));
|
||||
return sum + value;
|
||||
}, 0);
|
||||
const successMessage =
|
||||
total > 0
|
||||
? `Retried ${total} sats successfully.`
|
||||
: "Retried zap shares successfully.";
|
||||
this.videoModal?.setZapStatus(successMessage, "success");
|
||||
this.notifySuccess("Zap shares retried successfully!");
|
||||
this.resetRetryState();
|
||||
return true;
|
||||
}
|
||||
|
||||
markRetryPending(shares, comment = "") {
|
||||
const validShares = Array.isArray(shares)
|
||||
? shares.filter((share) => share && share.amount > 0)
|
||||
: [];
|
||||
if (!validShares.length) {
|
||||
this.resetRetryState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalZapRetryState = {
|
||||
shares: validShares,
|
||||
comment: typeof comment === "string" ? comment : this.modalZapCommentValue,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const summary = validShares
|
||||
.map((share) => {
|
||||
const label =
|
||||
share.type === "platform"
|
||||
? "Platform"
|
||||
: share.type === "creator"
|
||||
? "Creator"
|
||||
: "Lightning";
|
||||
return `${label} ${share.amount} sats`;
|
||||
})
|
||||
.join(", ");
|
||||
this.videoModal?.setZapRetryPending(true, { summary });
|
||||
}
|
||||
|
||||
resetRetryState() {
|
||||
this.modalZapRetryState = null;
|
||||
this.videoModal?.setZapRetryPending(false);
|
||||
}
|
||||
|
||||
handleZapError({ error, amount, comment, walletSettings }) {
|
||||
const tracker = Array.isArray(error?.__zapShareTracker)
|
||||
? error.__zapShareTracker
|
||||
: [];
|
||||
console.error(
|
||||
"[zap] Modal zap attempt failed",
|
||||
{
|
||||
amount,
|
||||
commentLength: typeof comment === "string" ? comment.length : undefined,
|
||||
wallet: {
|
||||
hasUri: Boolean(walletSettings?.nwcUri),
|
||||
type:
|
||||
walletSettings?.type ||
|
||||
walletSettings?.name ||
|
||||
walletSettings?.client ||
|
||||
undefined,
|
||||
},
|
||||
tracker: tracker.map((entry) => ({
|
||||
type: entry?.type || "unknown",
|
||||
status: entry?.status || "unknown",
|
||||
amount: entry?.amount,
|
||||
address: entry?.address,
|
||||
errorMessage:
|
||||
typeof entry?.error?.message === "string"
|
||||
? entry.error.message
|
||||
: entry?.error
|
||||
? String(entry.error)
|
||||
: undefined,
|
||||
})),
|
||||
retryPending: Array.isArray(this.modalZapRetryState?.shares)
|
||||
? this.modalZapRetryState.shares.length
|
||||
: 0,
|
||||
},
|
||||
error
|
||||
);
|
||||
if (tracker.length) {
|
||||
this.videoModal?.renderZapReceipts(tracker, { partial: true });
|
||||
}
|
||||
|
||||
const failureShares = tracker.filter(
|
||||
(entry) => entry && entry.status !== "success" && entry.amount > 0
|
||||
);
|
||||
if (failureShares.length) {
|
||||
this.markRetryPending(failureShares, comment);
|
||||
const summary = failureShares
|
||||
.map((share) => {
|
||||
const label =
|
||||
share.type === "platform"
|
||||
? "Platform"
|
||||
: share.type === "creator"
|
||||
? "Creator"
|
||||
: "Lightning";
|
||||
return `${label} ${share.amount} sats`;
|
||||
})
|
||||
.join(", ");
|
||||
const tone = tracker.length > failureShares.length ? "warning" : "error";
|
||||
const statusMessage =
|
||||
tracker.length > failureShares.length
|
||||
? `Partial zap failure. Press Send again to retry: ${summary}.`
|
||||
: `Zap failed. Press Send again to retry: ${summary}.`;
|
||||
this.videoModal?.setZapStatus(statusMessage, tone);
|
||||
this.notifyError(error?.message || statusMessage);
|
||||
} else {
|
||||
this.resetRetryState();
|
||||
const message = error?.message || "Zap failed. Please try again.";
|
||||
this.videoModal?.setZapStatus(message, "error");
|
||||
this.notifyError(message);
|
||||
}
|
||||
}
|
||||
|
||||
notifySuccess(message) {
|
||||
if (this.callbacks.onSuccess) {
|
||||
this.callbacks.onSuccess(message);
|
||||
}
|
||||
}
|
||||
|
||||
notifyError(message) {
|
||||
if (this.callbacks.onError) {
|
||||
this.callbacks.onError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-76
@@ -1,53 +1,23 @@
|
||||
const containerState = new WeakMap();
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
|
||||
|
||||
const ROOT_MARGIN = "0px";
|
||||
const THRESHOLD = 0.25;
|
||||
|
||||
function decodeUrl(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureState(container) {
|
||||
let state = containerState.get(container);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const observedCards = new WeakSet();
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
processEntries(entries, state);
|
||||
},
|
||||
{ root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD }
|
||||
);
|
||||
|
||||
state = {
|
||||
observer,
|
||||
observedCards,
|
||||
onCheck: null,
|
||||
};
|
||||
containerState.set(container, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function processEntries(entries, state) {
|
||||
if (!state?.onCheck || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
|
||||
return;
|
||||
const urlCardObserver = createCardObserver({
|
||||
rootMargin: ROOT_MARGIN,
|
||||
threshold: THRESHOLD,
|
||||
isCardVisible: (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const card = entry.target;
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
const isIntersecting = Boolean(entry.isIntersecting);
|
||||
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
|
||||
return isIntersecting && ratio > 0;
|
||||
},
|
||||
createState: () => ({ onCheck: null }),
|
||||
onCardVisible: ({ card, state }) => {
|
||||
if (!state || typeof state.onCheck !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +41,7 @@ function processEntries(entries, state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = decodeUrl(encodedUrl);
|
||||
const url = safeDecodeURIComponent(encodedUrl);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
@@ -81,44 +51,20 @@ function processEntries(entries, state) {
|
||||
} catch (err) {
|
||||
console.warn("[urlHealthObserver] onCheck handler failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function attachUrlHealthBadges(container, onCheck) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = ensureState(container);
|
||||
if (typeof onCheck === "function") {
|
||||
const state = urlCardObserver.observe(container);
|
||||
if (state && typeof onCheck === "function") {
|
||||
state.onCheck = onCheck;
|
||||
}
|
||||
|
||||
const cards = container.querySelectorAll(".video-card");
|
||||
cards.forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (state.observedCards.has(card)) {
|
||||
return;
|
||||
}
|
||||
state.observedCards.add(card);
|
||||
state.observer.observe(card);
|
||||
});
|
||||
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
processEntries(records, state);
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshUrlHealthBadges(container) {
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
processEntries(records, state);
|
||||
}
|
||||
urlCardObserver.refresh(container);
|
||||
}
|
||||
+245
-59
@@ -1,7 +1,14 @@
|
||||
// js/userBlocks.js
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
const BLOCK_LIST_IDENTIFIER = "user-blocks";
|
||||
const FAST_BLOCKLIST_RELAY_LIMIT = 3;
|
||||
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
|
||||
|
||||
function normalizeHex(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
@@ -83,63 +90,214 @@ class UserBlockListManager {
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events = [];
|
||||
for (const relay of nostrClient.relays) {
|
||||
try {
|
||||
const res = await nostrClient.pool.list([relay], [filter]);
|
||||
if (Array.isArray(res) && res.length) {
|
||||
events.push(...res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[UserBlockList] Relay error at ${relay}:`, err);
|
||||
}
|
||||
}
|
||||
const relays = Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays.filter((relay) => typeof relay === "string" && relay)
|
||||
: [];
|
||||
|
||||
if (!events.length) {
|
||||
if (!relays.length) {
|
||||
this.blockedPubkeys.clear();
|
||||
this.blockEventId = null;
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.created_at - a.created_at);
|
||||
const newest = events[0];
|
||||
this.blockEventId = newest.id;
|
||||
const fastRelays = relays.slice(0, FAST_BLOCKLIST_RELAY_LIMIT);
|
||||
const backgroundRelays = relays.slice(fastRelays.length);
|
||||
|
||||
let decrypted = "";
|
||||
try {
|
||||
decrypted = await window.nostr.nip04.decrypt(
|
||||
normalized,
|
||||
newest.content
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[UserBlockList] Failed to decrypt block list:", err);
|
||||
this.blockedPubkeys.clear();
|
||||
this.loaded = true;
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching block list from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => nostrClient.pool.list([relayUrl], [filter]))
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalized)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No block list events returned from ${relayUrl}`
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
reject(emptyError);
|
||||
return;
|
||||
}
|
||||
resolve({ relayUrl, events });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
reject(wrapped);
|
||||
});
|
||||
});
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_BLOCKLIST_TIMEOUT_MS, true)
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_BLOCKLIST_TIMEOUT_MS, false)
|
||||
);
|
||||
|
||||
const applyEvents = async (events, { skipIfEmpty = false } = {}) => {
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
if (skipIfEmpty) {
|
||||
return;
|
||||
}
|
||||
this.blockedPubkeys.clear();
|
||||
this.blockEventId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = events
|
||||
.filter((event) => event && event.pubkey === normalized)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
|
||||
if (!sorted.length) {
|
||||
if (skipIfEmpty) {
|
||||
return;
|
||||
}
|
||||
this.blockedPubkeys.clear();
|
||||
this.blockEventId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const newest = sorted[0];
|
||||
if (newest?.id && newest.id === this.blockEventId && this.blockedPubkeys.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.blockEventId = newest?.id || null;
|
||||
|
||||
if (!newest?.content) {
|
||||
this.blockedPubkeys.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
let decrypted = "";
|
||||
try {
|
||||
decrypted = await window.nostr.nip04.decrypt(normalized, newest.content);
|
||||
} catch (err) {
|
||||
console.error("[UserBlockList] Failed to decrypt block list:", err);
|
||||
this.blockedPubkeys.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(decrypted);
|
||||
const list = Array.isArray(parsed?.blockedPubkeys)
|
||||
? parsed.blockedPubkeys
|
||||
: [];
|
||||
const sanitized = list
|
||||
.map((entry) => normalizeHex(entry))
|
||||
.filter((candidate) => {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (candidate === normalized) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blockedPubkeys = new Set(sanitized);
|
||||
} catch (err) {
|
||||
console.error("[UserBlockList] Failed to parse block list:", err);
|
||||
this.blockedPubkeys.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const background = Promise.allSettled([
|
||||
...fastPromises,
|
||||
...backgroundPromises,
|
||||
])
|
||||
.then(async (outcomes) => {
|
||||
const aggregated = [];
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.status === "fulfilled") {
|
||||
const events = Array.isArray(outcome.value?.events)
|
||||
? outcome.value.events
|
||||
: [];
|
||||
if (events.length) {
|
||||
aggregated.push(...events);
|
||||
}
|
||||
} else {
|
||||
const reason = outcome.reason;
|
||||
if (reason?.code === "timeout") {
|
||||
console.warn(
|
||||
`[UserBlockList] Relay ${reason.relay} timed out while loading block list (${reason.timeoutMs}ms)`
|
||||
);
|
||||
} else {
|
||||
const relay = reason?.relay || reason?.relayUrl;
|
||||
console.error(
|
||||
`[UserBlockList] Relay error at ${relay}:`,
|
||||
reason?.error ?? reason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!aggregated.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyEvents(aggregated, { skipIfEmpty: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[UserBlockList] background block list refresh failed:", error);
|
||||
});
|
||||
|
||||
let fastResult = null;
|
||||
if (fastPromises.length) {
|
||||
try {
|
||||
fastResult = await Promise.any(fastPromises);
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors?.forEach((err) => {
|
||||
if (err?.code === "timeout") {
|
||||
console.warn(
|
||||
`[UserBlockList] Relay ${err.relay} timed out while loading block list (${err.timeoutMs}ms)`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("[UserBlockList] Fast block list fetch failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fastResult?.events?.length) {
|
||||
await applyEvents(fastResult.events);
|
||||
background.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(decrypted);
|
||||
const list = Array.isArray(parsed?.blockedPubkeys)
|
||||
? parsed.blockedPubkeys
|
||||
: [];
|
||||
const sanitized = list
|
||||
.map((entry) => normalizeHex(entry))
|
||||
.filter((candidate) => {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (candidate === normalized) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blockedPubkeys = new Set(sanitized);
|
||||
} catch (err) {
|
||||
console.error("[UserBlockList] Failed to parse block list:", err);
|
||||
this.blockedPubkeys.clear();
|
||||
}
|
||||
this.blockedPubkeys.clear();
|
||||
this.blockEventId = null;
|
||||
background.catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("[UserBlockList] loadBlocks failed:", error);
|
||||
this.blockedPubkeys.clear();
|
||||
@@ -250,26 +408,54 @@ class UserBlockListManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 30002,
|
||||
const event = buildBlockListEvent({
|
||||
pubkey: normalized,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["d", BLOCK_LIST_IDENTIFIER]],
|
||||
content: cipherText,
|
||||
};
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
await Promise.all(
|
||||
nostrClient.relays.map(async (relay) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([relay], signedEvent);
|
||||
} catch (err) {
|
||||
console.error(`[UserBlockList] Failed to publish to ${relay}:`, err);
|
||||
}
|
||||
})
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "block list",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[UserBlockList] Block list rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[UserBlockList] Block list not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.blockEventId = signedEvent.id;
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user