mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cdff3efe23 | ||
|
|
65198c3abe | ||
|
|
b7f24c1070 | ||
|
|
9682875ed9 | ||
|
|
6b2c052899 | ||
|
|
d32994e50f | ||
|
|
859ca9a59a | ||
|
|
177cd191e3 | ||
|
|
31e6bacd66 | ||
|
|
6b6c97a52d | ||
|
|
f1dc7d012e | ||
|
|
7af3a536c3 | ||
|
|
fdbb24049d | ||
|
|
ad3f9cb200 | ||
|
|
5f88e85835 | ||
|
|
6039ad2fd7 | ||
|
|
5e81bc9aef | ||
|
|
e127c6f58e | ||
|
|
8d7d7a86cf | ||
|
|
fd62159e61 | ||
|
|
46a8491bbc | ||
|
|
ff14d10d9c | ||
|
|
65c402f845 | ||
|
|
97bf2e2852 | ||
|
|
8c9f2cbfaa | ||
|
|
0f98e597ed | ||
|
|
65cfad60bb | ||
|
|
32f04b94cb | ||
|
|
61e8cc88fe | ||
|
|
1bcb770556 | ||
|
|
418239f977 | ||
|
|
034c4c3761 | ||
|
|
dfa260c2d0 | ||
|
|
b3274c9e68 | ||
|
|
9aaa2db932 | ||
|
|
712e9207e3 | ||
|
|
b4b2baef32 | ||
|
|
70917449d9 | ||
|
|
2351f9e126 | ||
|
|
befa1ad5ae | ||
|
|
789e0eff26 | ||
|
|
8a6e169471 | ||
|
|
53a8800eca | ||
|
|
1ff80e521a | ||
|
|
1e3da7aebc | ||
|
|
cd96e3a2b4 | ||
|
|
80fcb22f89 | ||
|
|
c9857839f5 | ||
|
|
ab607dc7b8 | ||
|
|
7260dfe1e0 | ||
|
|
b56cd0c95f | ||
|
|
a418fb2195 | ||
|
|
511a14087e | ||
|
|
be43f35ffc | ||
|
|
57d114aa52 | ||
|
|
1d1655d2a7 | ||
|
|
4ebc6f6f4a | ||
|
|
db408f4b06 | ||
|
|
f832695a6a | ||
|
|
213f92f496 | ||
|
|
96e8adc553 | ||
|
|
233c50d238 | ||
|
|
7937410e87 | ||
|
|
b50996b907 | ||
|
|
3f4b56ae90 | ||
|
|
edbc3786eb | ||
|
|
c7419a7f74 | ||
|
|
ee09b723af | ||
|
|
c617fcbfda | ||
|
|
622aa756cc | ||
|
|
c52199b1d0 | ||
|
|
ec83b1222f | ||
|
|
033d3b0288 | ||
|
|
5cfdd79a18 | ||
|
|
ce1f2a8f37 | ||
|
|
2b1d5488d6 | ||
|
|
e50780e4b3 | ||
|
|
c2846f9348 | ||
|
|
4266d0bd18 | ||
|
|
711df9bf07 | ||
|
|
24328c4c52 | ||
|
|
d15c574b13 | ||
|
|
198fd97a80 | ||
|
|
c285bd7e9c | ||
|
|
55774f588e | ||
|
|
79e5267524 | ||
|
|
344089cd39 | ||
|
|
1dbfa9b802 | ||
|
|
7e8d5f2fef | ||
|
|
6a3bc50066 | ||
|
|
83a9947611 | ||
|
|
0bf3ce8e8a | ||
|
|
fe39e65aa5 | ||
|
|
8de0ecc47d | ||
|
|
b438564b64 | ||
|
|
37a5ee3b7b | ||
|
|
23a8eacad4 | ||
|
|
1f191e2b90 | ||
|
|
2d0ddd0d3a | ||
|
|
db8178e573 | ||
|
|
3f293f77fa | ||
|
|
ebb289a80c | ||
|
|
d5551fd6a9 | ||
|
|
622c829b46 | ||
|
|
5f33aeea79 | ||
|
|
8d2f32c62b | ||
|
|
dd60ff07e9 | ||
|
|
5f20f1eb56 | ||
|
|
4cf6294591 | ||
|
|
d01fa93689 | ||
|
|
e92e681860 | ||
|
|
cdf0b5f08c | ||
|
|
7e3acdfd68 | ||
|
|
9c537ba7c7 | ||
|
|
6f5a931caa | ||
|
|
62cbc2a2eb | ||
|
|
511a016db4 | ||
|
|
bd345f99d2 | ||
|
|
169f0c952c | ||
|
|
d39ed45c5a | ||
|
|
a0356967ce | ||
|
|
57552dcfe1 | ||
|
|
bc08598237 | ||
|
|
c9dff9d436 | ||
|
|
84b5ef77c0 | ||
|
|
c6c297e16b | ||
|
|
66595edaf6 | ||
|
|
a56568e3c7 | ||
|
|
014b1369b0 | ||
|
|
c8afe45545 | ||
|
|
875cee4428 | ||
|
|
2eb00740fd | ||
|
|
7e8db028eb | ||
|
|
a53bded07f | ||
|
|
0d5a8f4017 | ||
|
|
468444a454 | ||
|
|
a677683205 | ||
|
|
e5f05f362c | ||
|
|
0041e24c87 | ||
|
|
de2d6c409f | ||
|
|
5365fad888 | ||
|
|
8966b426f8 | ||
|
|
92a7576fcb | ||
|
|
dd2f3768db | ||
|
|
b4e11cdce2 | ||
|
|
c39fb6ffe2 | ||
|
|
f3293a8c29 | ||
|
|
546fb4d1d2 | ||
|
|
c638b273fe | ||
|
|
3e083336b3 | ||
|
|
c851abc989 | ||
|
|
1c101266a6 | ||
|
|
30c260aa82 | ||
|
|
fa638e08a5 | ||
|
|
3da14ee174 | ||
|
|
875094c800 | ||
|
|
f1e0c63e01 | ||
|
|
c773a07cdc | ||
|
|
9de3592293 | ||
|
|
1b4bf696b7 | ||
|
|
b5987c5923 | ||
|
|
538526fbf5 | ||
|
|
c95e911f76 | ||
|
|
e297bd7bb3 | ||
|
|
cc627c1e65 | ||
|
|
f6612236f0 | ||
|
|
e0e1eb381c | ||
|
|
5d0f122e48 | ||
|
|
0461d06834 | ||
|
|
f07ca9966c | ||
|
|
62b770eff0 | ||
|
|
12aaf63d3d | ||
|
|
55cae181df | ||
|
|
2b0f3547cb | ||
|
|
2dfe20907b | ||
|
|
0b4523b0be | ||
|
|
866e4f11fb | ||
|
|
f6fa9daaad | ||
|
|
0217009752 | ||
|
|
1985b16064 | ||
|
|
854216f331 | ||
|
|
cbdab9f011 | ||
|
|
801bcd2345 | ||
|
|
c6a838ebc2 | ||
|
|
2b1d0ca26e | ||
|
|
5ef94c03ba | ||
|
|
e6b6522f6f | ||
|
|
742dd12915 | ||
|
|
1684c7070b | ||
|
|
8a278ca0ac | ||
|
|
c32d2fce12 | ||
|
|
46c2b39aae | ||
|
|
67f80787d0 | ||
|
|
1b720b92d3 | ||
|
|
7890a94949 | ||
|
|
3fa08631ea | ||
|
|
9da4e40ec9 | ||
|
|
34d4895799 | ||
|
|
7f70270889 | ||
|
|
9b4dd2a6ff | ||
|
|
f121a17cd9 | ||
|
|
7d73a8c594 | ||
|
|
ea300b47bf | ||
|
|
8d5d79e96f | ||
|
|
b52be2ab42 | ||
|
|
97c4ccff50 | ||
|
|
a9ffddf679 | ||
|
|
bc085ad3be | ||
|
|
a05e0c77fd | ||
|
|
27e45f56b5 | ||
|
|
069fcb1b7f | ||
|
|
54a6e05285 | ||
|
|
9a44112c88 | ||
|
|
836509e2f0 | ||
|
|
b51a8010d0 | ||
|
|
83184bd32f | ||
|
|
40f87167b9 | ||
|
|
84e3da0a87 | ||
|
|
1e388757ff | ||
|
|
dda977b91a | ||
|
|
61750bbbca | ||
|
|
b3b85b6323 | ||
|
|
6a0692c3e3 | ||
|
|
ddd8a393f4 | ||
|
|
0ad6d53ebb | ||
|
|
c1da7dbb39 | ||
|
|
4d0731fe6e | ||
|
|
614847c99b | ||
|
|
df6878aef6 | ||
|
|
059112eab9 | ||
|
|
58f564d1b2 | ||
|
|
cb67c55753 | ||
|
|
eb989bee89 | ||
|
|
ebcb402569 | ||
|
|
7e63771651 | ||
|
|
1238de1d71 | ||
|
|
b3b0ca7979 | ||
|
|
0153965fdf | ||
|
|
65d7950932 | ||
|
|
8ea130fb22 | ||
|
|
93493815fb | ||
|
|
3b6aef5dfd | ||
|
|
61df14b2e4 | ||
|
|
96c9707113 | ||
|
|
457c80b1e7 | ||
|
|
35969b0fc6 | ||
|
|
9e3ca0b273 | ||
|
|
6f0dca0d62 | ||
|
|
e90f903b01 | ||
|
|
0de68cbd6b | ||
|
|
efa68321df | ||
|
|
90a3bf97fe | ||
|
|
c5668bfc3b | ||
|
|
ab1eaaa5a1 | ||
|
|
6fb9f91a34 | ||
|
|
32f652e7a8 | ||
|
|
7c1675805e | ||
|
|
67b8caa623 | ||
|
|
1d0100619e | ||
|
|
b7ebed9843 | ||
|
|
237724b8ac | ||
|
|
ac0777534e | ||
|
|
4cbcfcfbf4 | ||
|
|
a593ea0f5b | ||
|
|
52553165d6 | ||
|
|
ddd6e40696 | ||
|
|
2d48f89f45 | ||
|
|
6e5c73f731 | ||
|
|
c56ffdfee6 | ||
|
|
0557014986 | ||
|
|
6c6641c961 | ||
|
|
77936e7656 | ||
|
|
69ca1f1d58 | ||
|
|
f9f9c8149c | ||
|
|
9629ef01ed | ||
|
|
fa738b9cb7 | ||
|
|
7bdf93cb63 | ||
|
|
e58c16ab83 | ||
|
|
dbe4563bb7 | ||
|
|
73e5a6bc83 | ||
|
|
109f5e0746 | ||
|
|
c5b30921ae | ||
|
|
c9ebc80ab4 | ||
|
|
c488f3fa16 | ||
|
|
1a6371fee3 | ||
|
|
72f3e4f8fd | ||
|
|
adf6bd56cd | ||
|
|
d89b38006a | ||
|
|
9bf283c2d4 | ||
|
|
02de05e31a | ||
|
|
447aea3d4b | ||
|
|
b617648136 | ||
|
|
8a9e325c22 | ||
|
|
d511757473 | ||
|
|
0af08f60b0 | ||
|
|
ce8a2aab87 | ||
|
|
fbd6fa4121 | ||
|
|
d6a34571a9 | ||
|
|
e85831bbf6 | ||
|
|
ff4fd71cbe | ||
|
|
aaf9066666 | ||
|
|
72db5396b4 | ||
|
|
16d4578ead | ||
|
|
14589b571c | ||
|
|
f482124411 | ||
|
|
f61291f75b | ||
|
|
42bfde21d5 | ||
|
|
8e0a5e0034 | ||
|
|
b031e98c4a | ||
|
|
5bf3112436 | ||
|
|
3b4bb7f0df | ||
|
|
5795a7e7d4 | ||
|
|
b66d6e1080 | ||
|
|
8c730e772b | ||
|
|
8e42816ec5 | ||
|
|
e7d895f123 | ||
|
|
04127bc22a | ||
|
|
996647f23d | ||
|
|
b52076cc26 | ||
|
|
940d9e66f7 | ||
|
|
5211897412 | ||
|
|
4456719bc9 | ||
|
|
96e1679618 | ||
|
|
82bd833594 | ||
|
|
9b11fe720c | ||
|
|
6ecd625fce | ||
|
|
9342c967ba | ||
|
|
9927b7e4ec | ||
|
|
2542e97398 | ||
|
|
5d0003c13d | ||
|
|
713cc909c7 | ||
|
|
02a1b5121d | ||
|
|
c94eb928e1 | ||
|
|
c384fc515f | ||
|
|
c5f67bc31e | ||
|
|
f0f1a7cf8f | ||
|
|
8a10a7cfc8 | ||
|
|
2ae9f01cb5 | ||
|
|
48a1f24ac9 | ||
|
|
b29ee486f0 | ||
|
|
9229f0fe80 | ||
|
|
e0da106442 | ||
|
|
532ce8e2d2 | ||
|
|
86928b8e8c | ||
|
|
b52e767f86 | ||
|
|
1be2c124b7 | ||
|
|
0d56082c54 | ||
|
|
d3f42b6eb3 | ||
|
|
2150ac0f0b | ||
|
|
e4ae6fadef | ||
|
|
eb9b50b572 | ||
|
|
8188b9f71a | ||
|
|
52ecb76ac9 | ||
|
|
80263bdba5 | ||
|
|
57496969d7 | ||
|
|
d9afdabe6c | ||
|
|
621465c538 | ||
|
|
1450e09697 | ||
|
|
dd47fbed93 | ||
|
|
548e0fd3c0 | ||
|
|
ed6b69b2e0 | ||
|
|
ee4ceb95b7 | ||
|
|
7742e3968c | ||
|
|
2442be4e77 | ||
|
|
3593fdd0ef | ||
|
|
873d1065e4 | ||
|
|
0d622bdb8d | ||
|
|
fc0bae85af | ||
|
|
db43e3634b | ||
|
|
c88bc404b6 | ||
|
|
d271bd335c | ||
|
|
fea5835a62 | ||
|
|
985acf6456 | ||
|
|
3e7eab1c92 | ||
|
|
bfee3b7a2a | ||
|
|
88eaff3107 | ||
|
|
33f29380f5 | ||
|
|
ef2fad74f8 | ||
|
|
167691d256 | ||
|
|
e3ee938f8d | ||
|
|
0569755901 | ||
|
|
a84f635539 | ||
|
|
c6891ca57c | ||
|
|
9f3f42181c | ||
|
|
0e0249ff91 | ||
|
|
fe0b89697f | ||
|
|
1e77d617ec | ||
|
|
56b478454c | ||
|
|
867e111a48 | ||
|
|
1256610429 | ||
|
|
4107796639 | ||
|
|
c84dc1e812 | ||
|
|
02ee898923 | ||
|
|
bd1c68d202 | ||
|
|
886b02a5ff | ||
|
|
0e5b871488 | ||
|
|
2ed1cc50db | ||
|
|
ad5f8958c5 | ||
|
|
9014371263 | ||
|
|
7350f43ca7 | ||
|
|
ba9bba517a | ||
|
|
811e1e30e1 | ||
|
|
7812034f0d | ||
|
|
b51b692f31 | ||
|
|
3ca66c7288 | ||
|
|
fe0245e30c | ||
|
|
2a26ef58f6 | ||
|
|
c4289161e1 | ||
|
|
0373859ad3 |
No files matched your search
+2
-1
@@ -1,3 +1,4 @@
|
||||
saved_config.yaml
|
||||
repo-context.txt
|
||||
src/webtorrent-docs/
|
||||
node_modules/
|
||||
src/webtorrent-docs/
|
||||
@@ -0,0 +1,93 @@
|
||||
# Bitvid — AI Agent Guide
|
||||
|
||||
This guide tells AI agents how to keep Bitvid aligned with the current product direction. Follow it whenever you touch code, content, or documentation inside this repository.
|
||||
|
||||
---
|
||||
|
||||
## 1. Release Channels: Main vs. Unstable
|
||||
|
||||
* **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.
|
||||
|
||||
## 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.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Magnet Handling — Do’s & Don’ts
|
||||
|
||||
* **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.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Upload Modal Troubleshooting
|
||||
|
||||
1. **Required fields:** Validation must ensure a title exists and that either a hosted URL or a magnet (or both) is supplied. Optional `ws` / `xs` inputs should be appended only when a magnet is present.
|
||||
2. **Magnet parsing errors:** When a user pastes encoded magnets, run `safeDecodeMagnet()` before normalizing. Show inline errors if decoding fails and confirm the raw `magnet:?xt=` string survives the round trip.
|
||||
3. **URL-first promise:** After submission, confirm the resulting feed item carries `data-play-url` and `data-play-magnet` attributes so playback utilities can attempt the URL first.
|
||||
4. **Mixed-content warnings:** If the page is served over HTTPS, reject `ws=` or `xs=` hints that begin with `http://` and show guidance to upgrade to HTTPS. Document the copy in README/UX strings so operators can align messaging.
|
||||
5. **Telemetry hooks:** Keep existing analytics/logging untouched unless instructed. If you add new modal states, annotate them clearly in code comments.
|
||||
6. **Modal regressions:** If validation or helper wiring breaks in Main, disable new feature flags and ship a revert PR immediately. Note the rollback steps in AGENTS.md for posterity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual QA Checklist
|
||||
|
||||
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
|
||||
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Content Schema v3 & Playback Rules
|
||||
|
||||
* **Event payloads:** New video notes serialize as version `3` with the JSON shape:
|
||||
`{ "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.
|
||||
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
|
||||
|
||||
## 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**
|
||||
@@ -26,11 +26,25 @@
|
||||
1. **Visit the Site**: Navigate to the live instance of **bitvid** (e.g., `[https://bitvid.network](https://bitvid.network)`).
|
||||
2. **Login with Nostr**:
|
||||
- Use a compatible Nostr browser extension or manually input your public key.
|
||||
3. **Upload Videos**:
|
||||
- Provide a title, magnet link, and optional thumbnail or description.
|
||||
- Toggle "Private" for encrypted listings.
|
||||
4. **Stream Videos**:
|
||||
- Play videos directly in the browser using WebTorrent technology.
|
||||
|
||||
### Post a video
|
||||
|
||||
The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly recommended so the player can start instantly, and you may also add a magnet for resilience.
|
||||
|
||||
- **Title**: Required. Matches the in-app validation copy.
|
||||
- **Hosted URL (recommended)**: Supply an HTTPS MP4/WebM/HLS/DASH asset. This is the primary playback path.
|
||||
- **Magnet (optional but encouraged)**: Paste the literal `magnet:?xt=urn:btih:...` string. The form decodes it with `safeDecodeMagnet()` to prevent hash corruption. Never wrap magnets in `new URL()`—store them raw or decode then pass directly to the helpers.
|
||||
- **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.
|
||||
|
||||
### How playback works
|
||||
|
||||
1. **URL-first**: `playVideoWithFallback({ url, magnet })` attempts the hosted URL immediately. Healthy URLs deliver the full experience without touching P2P resources.
|
||||
2. **WebTorrent fallback**: If the URL probe fails or returns an error status, Bitvid falls back to WebTorrent using the raw magnet. The helpers append HTTPS `ws=`/`xs=` hints so peers seed quickly.
|
||||
3. **Safety checks**: Magnets are decoded with `safeDecodeMagnet()` and normalized via `normalizeAndAugmentMagnet()` before reaching WebTorrent. Trackers remain WSS-only to satisfy browser constraints.
|
||||
4. **Operator playbook**: If a deployment causes playback regressions, flip the relevant feature flags back to their default values in `js/constants.js` and redeploy. Capture the rollback steps in AGENTS.md and the PR description so the Main channel stays stable.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,14 +73,46 @@ 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:
|
||||
|
||||
```bash
|
||||
npm install # first run only
|
||||
npm run build:css
|
||||
```
|
||||
|
||||
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
|
||||
regenerated file alongside any template changes so deployments pick up the
|
||||
latest styles.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`config/instance-config.js`**:
|
||||
- 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.
|
||||
- **`config.js`**:
|
||||
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
||||
- **`js/constants.js`**:
|
||||
- Source for browser-safe tracker lists and feature flags that govern WebTorrent behavior.
|
||||
- **Magnet helpers**:
|
||||
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
|
||||
|
||||
### Relay compatibility
|
||||
|
||||
Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
|
||||
`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
|
||||
or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
|
||||
placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
|
||||
|
||||
### Adding Features
|
||||
|
||||
@@ -101,6 +147,21 @@ To run **bitvid** locally:
|
||||
- Follow the [MIT License](https://opensource.org/licenses/MIT).
|
||||
- Use clear, concise commit messages.
|
||||
- Respect the existing coding style and architecture.
|
||||
- Run the manual QA script (see below) and note results in PR descriptions for changes that affect upload or playback.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Use the manual QA checklist before releases or when altering upload/playback flows:
|
||||
|
||||
1. Open the Upload modal, confirm validation (title plus URL or magnet), and test submissions for URL-only, magnet-only, and combined entries.
|
||||
2. Publish a post with both URL and magnet, verify the player streams the hosted URL, then simulate a URL failure and confirm WebTorrent playback.
|
||||
3. Paste encoded magnets to ensure `safeDecodeMagnet()` returns the raw string and `normalizeAndAugmentMagnet()` adds `ws=` / `xs=` hints without corruption.
|
||||
4. Confirm magnets include HTTPS `ws=` / optional `xs=` hints and use the WSS tracker list from `js/constants.js`.
|
||||
5. Spot-check Chromium and Firefox for console warnings (CORS, Range requests, tracker connectivity).
|
||||
|
||||
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="5" cy="12" r="2" fill="#ffffff" />
|
||||
<circle cx="12" cy="12" r="2" fill="#ffffff" />
|
||||
<circle cx="19" cy="12" r="2" fill="#ffffff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
xmlns:serif="http://www.serif.com/"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,12,12)">
|
||||
<g transform="matrix(1,0,0,1,-12,-12)">
|
||||
<path
|
||||
d="M12,2C6.486,2 2,6.486 2,12C2,17.514 6.486,22 12,22C17.514,22 22,17.514 22,12C22,6.486 17.514,2 12,2ZM12,4C16.411,4 20,7.589 20,12C20,16.411 16.411,20 12,20C7.589,20 4,16.411 4,12C4,7.589 7.589,4 12,4ZM11,7C10.448,7 10,7.448 10,8L10,13C10,13.266 10.105,13.52 10.293,13.707L13.293,16.707C13.683,17.098 14.317,17.098 14.707,16.707C15.098,16.317 15.098,15.683 14.707,15.293L12,12.586L12,8C12,7.448 11.552,7 11,7Z"
|
||||
style="fill:rgb(92,111,138);fill-rule:nonzero;"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -33,7 +33,13 @@
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Explore Bitvid release notes, roadmap updates, and community highlights across the decentralized video network."
|
||||
/>
|
||||
<meta name="robots" content="index,follow" />
|
||||
<title>Bitvid Blog</title>
|
||||
<script src="./js/tracking.js" defer></script>
|
||||
<script type="module" crossorigin>
|
||||
var pv = Object.defineProperty;
|
||||
var gv = (e, t, n) =>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Application Form</title>
|
||||
<!-- Include Tailwind CSS -->
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="css/tailwind.generated.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100">
|
||||
<!-- Application Form Modal -->
|
||||
|
||||
+14
-17
@@ -20,21 +20,18 @@
|
||||
>
|
||||
<!-- The .modal-content, same classes: bg-gray-900, w-full max-w-[90%], etc. -->
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative flex flex-col"
|
||||
>
|
||||
<!-- Sticky top bar, if you want a top heading or 'X' button up here -->
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||
>
|
||||
class="modal-header sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="p-6">
|
||||
<div class="modal-body flex-1 overflow-y-auto">
|
||||
<!-- Scrollable disclaimers area -->
|
||||
<div
|
||||
class="space-y-6 text-gray-300"
|
||||
style="max-height: 70vh; overflow-y: auto"
|
||||
>
|
||||
<div class="space-y-6 text-gray-300">
|
||||
<!-- Example: Insert your disclaimers here -->
|
||||
|
||||
<div class="flex justify-center mb-8">
|
||||
@@ -114,16 +111,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button at bottom -->
|
||||
<div class="mt-6">
|
||||
<button
|
||||
id="acceptDisclaimer"
|
||||
class="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-black transition-colors duration-200"
|
||||
>
|
||||
I Understand
|
||||
</button>
|
||||
</div>
|
||||
<!-- Button at bottom -->
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
id="acceptDisclaimer"
|
||||
class="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-black transition-colors duration-200"
|
||||
>
|
||||
I Understand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<!-- components/edit-video-modal.html -->
|
||||
<div
|
||||
id="editVideoModal"
|
||||
class="fixed inset-0 z-50 hidden"
|
||||
style="background: transparent"
|
||||
>
|
||||
<div
|
||||
id="editVideoModalOverlay"
|
||||
class="absolute inset-0 z-10"
|
||||
style="
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-4xl my-0 rounded-lg overflow-y-auto relative"
|
||||
style="max-height: 90vh"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Edit Video Note</h2>
|
||||
<button
|
||||
id="closeEditVideoModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<p class="text-sm text-gray-400">
|
||||
Fields that already have values are locked. Click <strong>Edit field</strong> to make changes.
|
||||
</p>
|
||||
|
||||
<form id="editVideoForm" class="space-y-5">
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoTitle" class="block text-sm font-medium text-gray-200">
|
||||
Title
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
id="editVideoTitle"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Video title"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoTitle"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
|
||||
Hosted video URL (https)
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="url"
|
||||
id="editVideoUrl"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://cdn.example.com/video.mp4"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoUrl"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoWs" class="block text-sm font-medium text-gray-200">
|
||||
Web seed (ws=)
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="url"
|
||||
id="editVideoWs"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://cdn.example.com/video.mp4"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoWs"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoXs" class="block text-sm font-medium text-gray-200">
|
||||
.torrent URL (xs=)
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="url"
|
||||
id="editVideoXs"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://cdn.example.com/video.torrent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoXs"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoMagnet" class="block text-sm font-medium text-gray-200">
|
||||
Magnet link
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
id="editVideoMagnet"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoMagnet"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoThumbnail" class="block text-sm font-medium text-gray-200">
|
||||
Thumbnail URL
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="url"
|
||||
id="editVideoThumbnail"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://cdn.example.com/preview.jpg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoThumbnail"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoDescription" class="block text-sm font-medium text-gray-200">
|
||||
Description
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
id="editVideoDescription"
|
||||
rows="4"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="What is this video about?"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button self-start hidden"
|
||||
data-edit-target="editVideoDescription"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editEnableComments" class="block text-sm font-medium text-gray-200">
|
||||
Enable comments
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-md border border-gray-800 bg-black/30 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editEnableComments"
|
||||
class="h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">
|
||||
Allow replies once comment threads are available.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editEnableComments"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
id="cancelEditVideo"
|
||||
class="w-full sm:w-auto px-4 py-2 rounded-md bg-gray-800 text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
id="submitEditVideo"
|
||||
class="w-full sm:w-auto px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,16 +112,19 @@
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
/* Custom Scrollbar styling for Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 transparent;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
|
||||
@@ -112,16 +112,19 @@
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
/* Custom Scrollbar styling for Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 transparent;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
|
||||
@@ -105,16 +105,19 @@
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
/* Custom Scrollbar styling for Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 transparent;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
|
||||
@@ -119,16 +119,19 @@
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
/* Custom Scrollbar styling for Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 transparent;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
|
||||
@@ -112,15 +112,18 @@
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 transparent;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
|
||||
+483
-16
@@ -2,6 +2,10 @@
|
||||
<div
|
||||
id="profileModal"
|
||||
class="fixed inset-0 z-50 hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="profileModalTitle"
|
||||
tabindex="-1"
|
||||
style="background: transparent"
|
||||
>
|
||||
<!-- Overlay Layer with dark background and blur -->
|
||||
@@ -27,7 +31,7 @@
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Profile</h2>
|
||||
<h2 id="profileModalTitle" class="text-xl font-bold text-white">Profile</h2>
|
||||
<button
|
||||
id="closeProfileModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
@@ -50,28 +54,491 @@
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 flex-1 text-gray-200 space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Profile"
|
||||
class="w-16 h-16 object-cover rounded-full"
|
||||
/>
|
||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
||||
Loading...
|
||||
</p>
|
||||
<div class="p-6 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col lg:flex-row gap-6 h-full">
|
||||
<!-- Left navigation column -->
|
||||
<nav
|
||||
class="flex lg:flex-col gap-2 flex-shrink-0"
|
||||
aria-label="Profile sections"
|
||||
>
|
||||
<button
|
||||
id="profileNavAccount"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-white bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneAccount"
|
||||
aria-selected="true"
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
<button
|
||||
id="profileNavRelays"
|
||||
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="profilePaneRelays"
|
||||
aria-selected="false"
|
||||
>
|
||||
Relays
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
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="profilePaneBlocked"
|
||||
aria-selected="false"
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
<button
|
||||
id="profileNavHistory"
|
||||
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="profilePaneHistory"
|
||||
aria-selected="false"
|
||||
>
|
||||
Watch history
|
||||
</button>
|
||||
<button
|
||||
id="profileNavAdmin"
|
||||
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 hidden"
|
||||
aria-controls="profilePaneAdmin"
|
||||
aria-selected="false"
|
||||
>
|
||||
Admin (Moderation)
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Right content panes -->
|
||||
<div class="flex-1 min-h-[320px] overflow-y-auto pr-1">
|
||||
<section
|
||||
id="profilePaneAccount"
|
||||
class="profile-pane space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavAccount"
|
||||
>
|
||||
<!--
|
||||
The account pane now relies on bespoke CSS (see css/style.css under
|
||||
"Profile modal account pane") so we are not dependent on utility
|
||||
classes that may be purged or overridden by other builds.
|
||||
-->
|
||||
<div class="profile-switcher" id="profileSwitcherTray">
|
||||
<div class="profile-switcher__header">
|
||||
<div class="profile-switcher__intro">
|
||||
<h3 class="profile-switcher__title">Switch profiles</h3>
|
||||
<p class="profile-switcher__subtitle">
|
||||
Save multiple Nostr profiles so you can swap identities without leaving BitVid.
|
||||
</p>
|
||||
</div>
|
||||
<span class="profile-switcher__badge">Preview</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="profile-card profile-card--active"
|
||||
aria-label="Current Nostr profile"
|
||||
aria-pressed="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="profile-card__avatar">
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Active profile avatar"
|
||||
/>
|
||||
</span>
|
||||
<span class="profile-card__meta">
|
||||
<span class="profile-card__topline">
|
||||
<span class="profile-card__label">Active profile</span>
|
||||
<a
|
||||
id="profileChannelLink"
|
||||
href="#"
|
||||
class="profile-card__action hidden"
|
||||
>
|
||||
View channel
|
||||
</a>
|
||||
</span>
|
||||
<span id="profileModalName" class="profile-card__name">Loading...</span>
|
||||
<span id="profileModalNpub" class="profile-card__npub">npub1...</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="profileSwitcherList"
|
||||
class="profile-switcher__list"
|
||||
aria-live="polite"
|
||||
aria-label="Additional Nostr profiles"
|
||||
></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="profileAddAccountBtn"
|
||||
class="profile-switcher__add"
|
||||
aria-label="Add a new Nostr profile"
|
||||
>
|
||||
<span class="profile-switcher__addAvatar">
|
||||
<svg
|
||||
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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="profile-switcher__addCopy">
|
||||
<span class="profile-switcher__addTitle">Add profile</span>
|
||||
<span class="profile-switcher__addHint">Link another npub</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p class="profile-switcher__helper">
|
||||
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneRelays"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavRelays"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Configure the relays Bitvid will use to discover and publish your videos.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="relayInput">
|
||||
<span class="mb-2 block font-medium">Add a relay</span>
|
||||
<input
|
||||
id="relayInput"
|
||||
type="url"
|
||||
placeholder="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>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
id="addRelayBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add relay
|
||||
</button>
|
||||
<button
|
||||
id="restoreRelaysBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-gray-800 text-sm font-medium text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Restore defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="relayList" class="space-y-3"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavBlocked"
|
||||
>
|
||||
<p class="text-sm text-gray-400">
|
||||
Creators you block are hidden from your BitVid experience. This
|
||||
list is private to your account.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="blockedInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="blockedInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
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"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="addBlockedBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Block creator
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="blockedEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
|
||||
>
|
||||
You haven’t blocked any creators yet.
|
||||
</div>
|
||||
<ul id="blockedList" class="space-y-3"></ul>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneHistory"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavHistory"
|
||||
>
|
||||
<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 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"
|
||||
>
|
||||
You haven’t watched any videos yet.
|
||||
</div>
|
||||
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
|
||||
<div
|
||||
id="profileHistoryGrid"
|
||||
class="watch-history-grid watch-history-grid--modal hidden"
|
||||
role="list"
|
||||
></div>
|
||||
<div id="profileHistorySentinel" class="h-1 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneAdmin"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavAdmin"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-white">Platform moderation</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Moderators</h4>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">
|
||||
Super Admin only
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Moderators can edit the whitelist and blacklist but cannot manage other moderators or whitelist mode.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminModeratorInput">
|
||||
<span class="mb-2 block font-medium">Moderator npub</span>
|
||||
<input
|
||||
id="adminModeratorInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
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"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddModeratorBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add moderator
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminModeratorsEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No moderators configured yet.
|
||||
</div>
|
||||
<ul id="adminModeratorList" class="space-y-3"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminWhitelistSection" class="space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Whitelist</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminWhitelistInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="adminWhitelistInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
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"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddWhitelistBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add to whitelist
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminWhitelistEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No whitelisted creators yet.
|
||||
</div>
|
||||
<ul id="adminWhitelistList" class="space-y-3"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminBlacklistSection" class="space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Blacklist</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminBlacklistInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="adminBlacklistInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
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"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddBlacklistBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add to blacklist
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminBlacklistEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No blacklisted creators yet.
|
||||
</div>
|
||||
<ul id="adminBlacklistList" class="space-y-3"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Some future profile details or settings could go here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
|
||||
<button
|
||||
id="profileLogoutBtn"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<!-- components/revert-video-modal.html -->
|
||||
<div
|
||||
id="revertVideoModal"
|
||||
class="fixed inset-0 z-50 hidden"
|
||||
style="background: transparent"
|
||||
>
|
||||
<div
|
||||
id="revertVideoModalOverlay"
|
||||
class="absolute inset-0 z-10"
|
||||
style="
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-[95%] lg:max-w-5xl my-0 rounded-lg overflow-hidden relative"
|
||||
style="max-height: 92vh"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<h2 id="revertModalTitle" class="text-xl font-bold text-white">
|
||||
Revert Video Note
|
||||
</h2>
|
||||
<p id="revertModalSubtitle" class="text-xs text-gray-400">
|
||||
Review previous versions before restoring an older state.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="closeRevertVideoModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6 overflow-y-auto" style="max-height: calc(92vh - 4rem)">
|
||||
<div class="rounded-md border border-gray-700 bg-gray-800/60 p-4 text-sm text-gray-300">
|
||||
<p>
|
||||
Selecting an older version will mark any newer edits as reverted.
|
||||
The previous revision will become the active note on the relays.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
Versions are grouped by their <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">d</code>
|
||||
tag or shared <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">videoRootId</code>. This ensures legacy
|
||||
clients see consistent history when multiple edits were published.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(240px,280px)_1fr]">
|
||||
<aside class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-200">Revision history</h3>
|
||||
<span
|
||||
id="revertHistoryCount"
|
||||
class="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400"
|
||||
></span>
|
||||
</div>
|
||||
<div
|
||||
id="revertVersionsList"
|
||||
class="space-y-2 overflow-y-auto pr-1"
|
||||
style="max-height: 48vh"
|
||||
></div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
id="revertVersionDetails"
|
||||
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.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="revertSelectionStatus"
|
||||
class="text-xs text-gray-400"
|
||||
></p>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
id="cancelRevertVideo"
|
||||
class="w-full sm:w-auto px-4 py-2 rounded-md bg-gray-800 text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="confirmRevertVideo"
|
||||
class="w-full sm:w-auto px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-red-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Revert to selected version
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+197
-180
@@ -1,6 +1,11 @@
|
||||
<!--
|
||||
Sidebar fades in once when hydrated on the client. js/index.js removes the
|
||||
helper classes after the initial animation to avoid flashes each time we
|
||||
re-render navigation links.
|
||||
-->
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Top Navigation Links -->
|
||||
@@ -29,6 +34,17 @@
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/history-icon.svg"
|
||||
alt="History"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
/>
|
||||
<span class="sidebar-text">History</span>
|
||||
</a>
|
||||
<a
|
||||
id="subscriptionsLink"
|
||||
href="#view=subscriptions"
|
||||
@@ -45,178 +61,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Dropdown Button for Footer Links -->
|
||||
<!-- Added p-4 around the button for spacing, and styling consistent with top links -->
|
||||
<div class="md:hidden p-4">
|
||||
<button
|
||||
id="footerDropdownButton"
|
||||
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"
|
||||
>
|
||||
<!-- Arrow on the left side -->
|
||||
<svg
|
||||
id="footerDropdownIcon"
|
||||
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
<span id="footerDropdownText">More</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links Container -->
|
||||
<!-- On mobile: hidden by default; on desktop: visible and pushed to the bottom -->
|
||||
<div id="footerLinksContainer" class="hidden md:block md:mt-auto">
|
||||
<hr class="border-gray-700" />
|
||||
<!-- Footer Links Toggle -->
|
||||
<div class="md:mt-auto">
|
||||
<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"
|
||||
<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"
|
||||
aria-expanded="false"
|
||||
aria-controls="footerLinksContainer"
|
||||
>
|
||||
<!-- Arrow on the left side -->
|
||||
<svg
|
||||
id="footerDropdownIcon"
|
||||
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=ipns"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/ipns-icon.svg"
|
||||
alt="IPNS"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">IPNS</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
||||
<span class="sidebar-text">DNS</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
</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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=ipns"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/ipns-icon.svg"
|
||||
alt="IPNS"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">IPNS</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
||||
<span class="sidebar-text">DNS</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,18 +248,15 @@
|
||||
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 (footerLinks.classList.contains("hidden")) {
|
||||
textSpan.textContent = "More";
|
||||
if (icon) {
|
||||
icon.classList.remove("rotate-180");
|
||||
}
|
||||
} else {
|
||||
textSpan.textContent = "Less";
|
||||
if (icon) {
|
||||
icon.classList.add("rotate-180");
|
||||
}
|
||||
if (textSpan) {
|
||||
textSpan.textContent = isHidden ? "More" : "Less";
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.toggle("rotate-180", !isHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-y-auto relative"
|
||||
style="max-height: 90vh"
|
||||
>
|
||||
<!-- Top Bar (similar to video-modal) -->
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300 p-4 flex items-center justify-between"
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Share a Video</h2>
|
||||
<button
|
||||
@@ -50,8 +50,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Form Content -->
|
||||
<div class="p-6 space-y-4">
|
||||
<form id="uploadForm" class="space-y-4">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="inline-flex items-center rounded-full bg-gray-800/80 p-1 text-sm"
|
||||
role="group"
|
||||
aria-label="Upload mode selector"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="upload-mode-toggle flex-1 rounded-full px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
|
||||
data-upload-mode="custom"
|
||||
aria-pressed="true"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="upload-mode-toggle flex-1 rounded-full px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
|
||||
data-upload-mode="cloudflare"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Cloudflare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="customUploadSection">
|
||||
<form id="uploadForm" class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.
|
||||
</p>
|
||||
<div>
|
||||
<label
|
||||
for="uploadTitle"
|
||||
@@ -66,20 +95,58 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hosted video URL -->
|
||||
<div class="form-group">
|
||||
<label
|
||||
for="uploadUrl"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Hosted video URL (https)</label
|
||||
>
|
||||
<input
|
||||
id="uploadUrl"
|
||||
type="url"
|
||||
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="https://cdn.example.com/video.mp4"
|
||||
/>
|
||||
<small class="mt-1 block text-xs text-gray-400"
|
||||
>Optional but preferred. We will play this first and fall back to
|
||||
P2P.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Optional: seed bootstraps for magnet -->
|
||||
<div class="grid grid-cols-1 gap-3 mb-2 text-xs">
|
||||
<input
|
||||
id="uploadWs"
|
||||
type="url"
|
||||
class="w-full p-2 rounded"
|
||||
placeholder="Web seed (ws=) e.g., https://cdn.example.com/video.mp4"
|
||||
>
|
||||
<input
|
||||
id="uploadXs"
|
||||
type="url"
|
||||
class="w-full p-2 rounded"
|
||||
placeholder=".torrent URL (xs=) e.g., https://cdn.example.com/video.torrent"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadMagnet"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Magnet Link</label
|
||||
>Magnet link (optional if URL provided)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="uploadMagnet"
|
||||
required
|
||||
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="magnet:?xt=urn:btih:..."
|
||||
class="mt-1 block w-full rounded-md border 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 a hosted URL for instant playback. Add a magnet for P2P
|
||||
cost control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadThumbnail"
|
||||
@@ -105,6 +172,23 @@
|
||||
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"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 rounded-md border border-gray-800 bg-black/30 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="uploadEnableComments"
|
||||
class="mt-1 h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
checked
|
||||
/>
|
||||
<div>
|
||||
<label for="uploadEnableComments" class="text-sm font-medium text-gray-200"
|
||||
>Enable comments</label
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Keep this on to let viewers discuss the video once comment threads launch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
@@ -124,6 +208,300 @@
|
||||
Publish
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="cloudflareUploadSection" class="hidden">
|
||||
<div class="space-y-8">
|
||||
<p class="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
|
||||
This feature is experimental and may not be working correctly.
|
||||
</p>
|
||||
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-100">
|
||||
Cloudflare R2 credentials
|
||||
</h3>
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500">
|
||||
stored locally
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-gray-400">
|
||||
Paste your Cloudflare R2 API credentials. We keep them in your
|
||||
browser so uploads happen client-side.
|
||||
</p>
|
||||
<form id="cloudflareSettingsForm" class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="r2AccountId" class="block text-sm font-medium text-gray-200"
|
||||
>Account ID</label
|
||||
>
|
||||
<input
|
||||
id="r2AccountId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2AccessKeyId" class="block text-sm font-medium text-gray-200"
|
||||
>S3 Access Key ID</label
|
||||
>
|
||||
<input
|
||||
id="r2AccessKeyId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="AKIA..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2SecretAccessKey" class="block text-sm font-medium text-gray-200"
|
||||
>S3 Secret Access Key</label
|
||||
>
|
||||
<input
|
||||
id="r2SecretAccessKey"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t border-gray-800 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
id="cloudflareAdvancedToggle"
|
||||
class="flex items-center gap-2 text-sm font-medium text-gray-300 transition-colors hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-expanded="false"
|
||||
aria-controls="cloudflareAdvancedFields"
|
||||
>
|
||||
<svg
|
||||
id="cloudflareAdvancedToggleIcon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-300 transition-transform duration-150 transform"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span id="cloudflareAdvancedToggleLabel">Show advanced options</span>
|
||||
</button>
|
||||
<div
|
||||
id="cloudflareAdvancedFields"
|
||||
class="mt-3 space-y-4 hidden"
|
||||
>
|
||||
<div>
|
||||
<label for="r2ApiToken" class="block text-sm font-medium text-gray-200"
|
||||
>Cloudflare API Token (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2ApiToken"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Token with Workers R2 Storage Write"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Used to create buckets, configure CORS, and attach domains automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="r2ZoneId" class="block text-sm font-medium text-gray-200"
|
||||
>Zone ID (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2ZoneId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Required when you want us to attach a custom domain on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="r2BaseDomain" class="block text-sm font-medium text-gray-200"
|
||||
>Base domain (optional)</label
|
||||
>
|
||||
<input
|
||||
id="r2BaseDomain"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="cdn.example.com"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
We'll attach <code class="rounded bg-gray-800 px-1 py-0.5">npub.<baseDomain></code>.
|
||||
Leave blank to use the managed <code class="rounded bg-gray-800 px-1 py-0.5">*.r2.dev</code> domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Save settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cloudflareClearSettings"
|
||||
class="rounded-md border border-gray-600 px-4 py-2 text-sm font-semibold text-gray-200 transition hover:border-gray-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<span
|
||||
id="cloudflareSettingsStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-100">
|
||||
Upload & publish
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-400">
|
||||
Choose a file, we will upload it to R2, fill the hosted URL,
|
||||
and publish your note automatically.
|
||||
</p>
|
||||
</div>
|
||||
<form id="cloudflareUploadForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="cloudflareTitle" class="block text-sm font-medium text-gray-200"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareTitle"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareDescription" class="block text-sm font-medium text-gray-200"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="cloudflareDescription"
|
||||
rows="3"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="cloudflareThumbnail" class="block text-sm font-medium text-gray-200"
|
||||
>Thumbnail URL (optional)</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareThumbnail"
|
||||
type="url"
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareMagnet" class="block text-sm font-medium text-gray-200"
|
||||
>Magnet link (optional)</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareMagnet"
|
||||
type="text"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
id="cloudflareWs"
|
||||
type="url"
|
||||
placeholder="Web seed (ws=)"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
id="cloudflareXs"
|
||||
type="url"
|
||||
placeholder=".torrent URL (xs=)"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-md border border-gray-800 bg-black/30 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cloudflareEnableComments"
|
||||
class="mt-1 h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
checked
|
||||
/>
|
||||
<div>
|
||||
<label for="cloudflareEnableComments" class="text-sm font-medium text-gray-200"
|
||||
>Enable comments</label
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Publish with comments switched on. Toggle off to quietly disable replies on this video.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflareFile" class="block text-sm font-medium text-gray-200"
|
||||
>Video or HLS file</label
|
||||
>
|
||||
<input
|
||||
id="cloudflareFile"
|
||||
type="file"
|
||||
accept="video/*,.m3u8,.ts,.mp4,.webm,.mov,.mkv,.mpg,.mpeg"
|
||||
class="mt-1 w-full rounded-md border border-dashed border-gray-600 bg-gray-900 px-3 py-4 text-sm text-gray-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
We use multipart uploads with automatic bucket creation.
|
||||
Large files are supported.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-md border border-gray-800 bg-black/40 p-3 text-xs text-gray-400">
|
||||
<p>
|
||||
<strong class="text-gray-200">Bucket preview:</strong>
|
||||
<span id="cloudflareBucketPreview" class="ml-1">We'll show your
|
||||
bucket and public domain after setup.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
id="cloudflareProgressBar"
|
||||
class="hidden h-2 w-full overflow-hidden rounded-full bg-gray-800"
|
||||
>
|
||||
<div
|
||||
id="cloudflareProgressFill"
|
||||
class="h-full w-0 bg-blue-500 transition-all duration-150"
|
||||
></div>
|
||||
</div>
|
||||
<p
|
||||
id="cloudflareUploadStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
></p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
id="cloudflareUploadButton"
|
||||
class="w-full rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-blue-900"
|
||||
>
|
||||
Upload to R2 & publish
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+93
-23
@@ -1,15 +1,11 @@
|
||||
<!-- components/video-modal.html -->
|
||||
<div id="playerModal" class="fixed inset-0 bg-black/90 z-50 hidden">
|
||||
<div
|
||||
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
|
||||
>
|
||||
<div id="playerModal" class="player-modal hidden">
|
||||
<div class="player-modal__container">
|
||||
<div class="player-modal__content">
|
||||
<!-- Navigation bar - sliding at top -->
|
||||
<div
|
||||
id="modalNav"
|
||||
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300"
|
||||
class="modal-nav sticky top-0 z-60 transition-transform duration-150"
|
||||
>
|
||||
<div class="flex items-center px-6 py-4">
|
||||
<button
|
||||
@@ -31,19 +27,7 @@
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-container w-full bg-black">
|
||||
<video id="modalVideo" class="w-full aspect-video" controls></video>
|
||||
</div>
|
||||
|
||||
<div class="video-info p-6">
|
||||
<!-- Title and icons row -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 id="videoTitle" class="text-2xl font-bold text-white"></h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Copy Magnet Button (circular) -->
|
||||
<div class="modal-actions">
|
||||
<button id="copyMagnetBtn" class="icon-button">
|
||||
<img
|
||||
src="assets/svg/copy-magnet.svg"
|
||||
@@ -51,7 +35,18 @@
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<!-- Share Button (circular) -->
|
||||
<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>
|
||||
<button id="shareBtn" class="icon-button">
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
@@ -59,14 +54,89 @@
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="modal-actions__overflow"
|
||||
data-more-menu-wrapper="true"
|
||||
>
|
||||
<button
|
||||
id="modalMoreBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More"
|
||||
data-more-dropdown="modal"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-modal"
|
||||
class="hidden absolute right-0 top-14 w-44 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="open-channel"
|
||||
data-context="modal"
|
||||
>
|
||||
Open channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="copy-link"
|
||||
data-context="modal"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<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"
|
||||
data-context="modal"
|
||||
data-blacklist-menu="true"
|
||||
>
|
||||
Blacklist creator
|
||||
</button>
|
||||
<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="block-author"
|
||||
data-context="modal"
|
||||
>
|
||||
Block creator
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="report"
|
||||
data-context="modal"
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-modal__video modal-video-shell">
|
||||
<video id="modalVideo" class="w-full aspect-video" controls></video>
|
||||
</div>
|
||||
|
||||
<div class="player-modal__info modal-info-panel">
|
||||
<!-- Title and icons row -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 id="videoTitle" class="text-2xl font-bold text-white"></h2>
|
||||
</div>
|
||||
|
||||
<!-- Video Timestamp -->
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-gray-400 mb-4"
|
||||
>
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<div class="flex items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">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,179 @@
|
||||
// config/instance-config.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// BitVid instance configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// This file consolidates the values that operators usually customize when they
|
||||
// deploy their own BitVid instance. Update the exports below to match your
|
||||
// environment, commit the changes, and redeploy. Leaving this file in the repo
|
||||
// (instead of hidden in environment variables) makes it easy for future
|
||||
// maintainers to understand how your instance is configured.
|
||||
//
|
||||
// Each setting includes guidance about how BitVid uses the value and what
|
||||
// adjustments are safe. When in doubt, mirror the structure shown here so other
|
||||
// contributors can follow along.
|
||||
|
||||
/**
|
||||
* The primary administrator for this BitVid instance.
|
||||
*
|
||||
* BitVid treats this Nostr public key (npub) as the "Super Admin". This user
|
||||
* cannot be removed from moderator lists, and only they can promote new
|
||||
* moderators or toggle whitelist-only mode. Replace the string below with the
|
||||
* npub of the account you want to act as the ultimate authority for your
|
||||
* deployment.
|
||||
*/
|
||||
export const ADMIN_SUPER_NPUB =
|
||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
|
||||
|
||||
/**
|
||||
* Storage key used to persist whitelist-only mode in the browser.
|
||||
*
|
||||
* You usually do not need to change this, but the export lives here so that all
|
||||
* whitelist-related knobs are grouped together. If you do change the key, make
|
||||
* sure to migrate any previously stored values in localStorage.
|
||||
*/
|
||||
export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode";
|
||||
|
||||
/**
|
||||
* Whether whitelist-only mode should be enabled the first time an operator
|
||||
* loads the admin dashboard.
|
||||
*
|
||||
* Set this to `true` if you want BitVid to start with whitelist-only access and
|
||||
* require an explicit opt-out. Set to `false` to allow all creators by default.
|
||||
* Operators can still toggle the mode at runtime; this value only controls the
|
||||
* default state when no preference has been stored in localStorage yet.
|
||||
*/
|
||||
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
||||
|
||||
/**
|
||||
* Nostr kind used when persisting watch history events.
|
||||
*
|
||||
* 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 = 30079;
|
||||
|
||||
/**
|
||||
* Identifier applied to the watch-history list when storing it on relays.
|
||||
*
|
||||
* This becomes the value of the `d` tag so clients can find the correct list
|
||||
* without guessing. Customize the slug if you need isolation between multiple
|
||||
* BitVid deployments that share relays, but keep it stable once clients begin
|
||||
* syncing history.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* The roadmap targets a rolling window of 1,500 items so the UI can highlight
|
||||
* recently played videos without ballooning relay storage. Adjust this cap up
|
||||
* or down depending on your retention and privacy policies; smaller values
|
||||
* reduce storage pressure while larger ones surface a deeper backlog.
|
||||
*/
|
||||
export const WATCH_HISTORY_MAX_ITEMS = 1500;
|
||||
|
||||
/**
|
||||
* Whether clients should resolve watch-history entries in batches.
|
||||
*
|
||||
* When enabled, BitVid fetches video metadata in grouped queries instead of
|
||||
* issuing one request per entry. Operators with relays that struggle under
|
||||
* bursty loads can disable batching at the cost of additional round trips.
|
||||
*/
|
||||
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 = null;
|
||||
|
||||
/**
|
||||
* Maximum size of the JSON payload for each watch-history chunk, measured
|
||||
* before encryption.
|
||||
*
|
||||
* BitVid splits large histories across multiple events to stay within relay
|
||||
* limits. This cap keeps individual chunks under roughly 60 KB so that
|
||||
* encrypted payloads remain relay-friendly even after base64 expansion.
|
||||
* Lower the number if your relays enforce tighter limits.
|
||||
*/
|
||||
export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000;
|
||||
|
||||
/**
|
||||
* Number of watch-history events to request when syncing from relays.
|
||||
*
|
||||
* Chunked histories can span several events. This fetch limit should exceed
|
||||
* 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 = 64;
|
||||
|
||||
/**
|
||||
* How long clients should cache watch-history snapshots in localStorage.
|
||||
*
|
||||
* A 24-hour window keeps recently played videos available across reloads
|
||||
* without requiring a fresh relay sync on every visit. Increase the value for
|
||||
* longer-lived caches or decrease it if your deployment needs tighter
|
||||
* 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;
|
||||
+3
-3
@@ -46,9 +46,9 @@
|
||||
- Add zaps to videos, profiles, and comments.
|
||||
- Create a VRR (View, Rating, and Retention) Penalty Scoring system. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/07_bitvid_Enhanced_View_Rating_%26_Retention_Penalty_Scoring.md)
|
||||
- Implement visibility filtering for videos:
|
||||
- Show only videos whose magnet links have at least **one active peer online**.
|
||||
- Integrate the filtering mechanism into the video list rendering process.
|
||||
- Update the video list dynamically based on real-time peer availability. (this may not be possible)
|
||||
- Run lightweight URL-health probes so cards stay visible when the hosted playback link responds.
|
||||
- When a URL is unreachable, fall back to WebTorrent and surface items only if their magnets can stream via peers or web seeds.
|
||||
- Align the filtering logic with the player’s URL-first, WebTorrent-fallback behavior during list rendering.
|
||||
- Dynamic Home Page and Video Tracking Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_%26_Video_Tracking_Specification.md)
|
||||
- Add multi-language support for content and filtration. (v4?)
|
||||
- Create a settings menu for local account preferences, including relay, adult content, theme, and language.
|
||||
|
||||
+1086
-94
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
*,: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
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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,73 @@
|
||||
# Nostr authentication storage
|
||||
|
||||
Bitvid keeps lightweight state in `localStorage` so the UI can remember which
|
||||
accounts signed in recently. This document explains the storage schema so future
|
||||
updates can evolve it without breaking existing users.
|
||||
|
||||
## Storage keys
|
||||
|
||||
- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
|
||||
avatars. See `js/app.js` for TTL and eviction rules.
|
||||
- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
|
||||
with in this browser.
|
||||
|
||||
Both keys live in the main origin scope. Clearing one should not affect the
|
||||
other.
|
||||
|
||||
## Saved profile schema
|
||||
|
||||
`bitvid:savedProfiles:v1` is a JSON object with the shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"pubkey": "<hex-encoded pubkey>",
|
||||
"npub": "<bech32 npub, optional>",
|
||||
"name": "<cached display name>",
|
||||
"picture": "<cached avatar URL>",
|
||||
"authType": "<auth strategy>"
|
||||
}
|
||||
],
|
||||
"activePubkey": "<hex pubkey or null>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `pubkey` always stores a lowercase 64-character hex string and is the primary
|
||||
dedupe key.
|
||||
- `npub` is optional. When omitted, the UI regenerates it on load using
|
||||
`NostrTools.nip19.npubEncode`.
|
||||
- `name` and `picture` are cached hints that keep the profile switcher snappy.
|
||||
They may be empty strings and should be treated as hints, not the source of
|
||||
truth.
|
||||
- `activePubkey` tracks the last account that successfully authenticated in this
|
||||
browser. It may be `null` when the user logs out but keeps saved entries.
|
||||
|
||||
### `authType` enum
|
||||
|
||||
`authType` describes how the profile authenticated:
|
||||
|
||||
- `"nip07"` – Browser extension flow (current default).
|
||||
- `"nsec"` – Reserved for future direct key import or signer integrations. New
|
||||
auth strategies should pick a distinct string and document how migration works
|
||||
alongside any recovery tooling.
|
||||
|
||||
When the app reads stored entries it normalises unknown values back to
|
||||
`"nip07"` but keeps recognised alternatives intact.
|
||||
|
||||
## Migration notes
|
||||
|
||||
Earlier builds only persisted a single `userPubKey` string. During startup the
|
||||
app now:
|
||||
|
||||
1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
|
||||
2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
|
||||
it seeds `savedProfiles` with that value and writes the new structure.
|
||||
3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
|
||||
is removed.
|
||||
|
||||
Future migrations should follow the same pattern: validate, normalise, write the
|
||||
new format, then clean up legacy keys to avoid data loss.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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,
|
||||
} 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.
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
### 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】
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Bitvid Manual QA Script
|
||||
|
||||
Run this checklist before publishing releases or merging changes that touch upload or playback flows. Copy/paste the steps into QA tickets when requesting validation.
|
||||
|
||||
1. **Upload Modal Smoke Test**
|
||||
- Open the Upload modal, verify title is required, and ensure either a hosted URL or a magnet (or both) must be supplied.
|
||||
- Submit three variants: URL-only, magnet-only, and both URL + magnet. Confirm success messaging or event publication in each case.
|
||||
2. **URL-First Playback**
|
||||
- For a post with both URL and magnet, play the video and confirm the hosted URL loads first (inspect network requests or logs).
|
||||
- Simulate a dead URL (offline mode or request blocker) and confirm playback falls back to WebTorrent without breaking the UI.
|
||||
3. **Magnet Safety**
|
||||
- Paste an encoded magnet and confirm `safeDecodeMagnet()` outputs the raw string before playback.
|
||||
- Ensure `normalizeAndAugmentMagnet()` adds provided `ws=` / `xs=` hints while keeping the original `xt` hash intact.
|
||||
4. **P2P Hints & Trackers**
|
||||
- Confirm magnets generated through the modal include HTTPS `ws=` seeds and optional HTTPS `xs=` torrent URLs when supplied.
|
||||
- Check that tracker arrays originate from `js/constants.js` and only include WSS endpoints.
|
||||
5. **Cross-Browser Verification**
|
||||
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
|
||||
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
|
||||
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.
|
||||
+90
-22
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Decentralized Video Sharing</title>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="Bitvid is a decentralized video hub that prioritizes URL-first playback with WebTorrent fallback so your audience can stream reliably."
|
||||
/>
|
||||
<meta name="robots" content="index,follow" />
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
||||
<meta
|
||||
@@ -39,7 +45,8 @@
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<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" />
|
||||
</head>
|
||||
@@ -58,14 +65,25 @@
|
||||
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.
|
||||
-->
|
||||
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col">
|
||||
<!--
|
||||
`fade-in` is scrubbed by js/index.js after the first animation so that
|
||||
re-rendering views does not cause the shell to flicker. Only apply it to
|
||||
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">
|
||||
<!-- Header -->
|
||||
<header class="mb-8 flex items-center w-full">
|
||||
<!-- Mobile hamburger button (hidden on md+) -->
|
||||
<!-- New button -->
|
||||
<!--
|
||||
Mobile menu button: uses `fade-in` to match the rest of the chrome.
|
||||
The JS animation cleanup removes the helper classes once the entrance
|
||||
finishes to prevent flashes on future sidebar toggles.
|
||||
-->
|
||||
<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]"
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
||||
@@ -74,14 +92,24 @@
|
||||
/>
|
||||
</button>
|
||||
<!-- Logo -->
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
/>
|
||||
<!-- Logo fades in once on initial load. -->
|
||||
<a
|
||||
href="index.html"
|
||||
aria-label="Return to the bitvid home page"
|
||||
class="fade-in fade-in-delay-100"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- Buttons on the far right -->
|
||||
<div class="ml-auto flex items-center space-x-4">
|
||||
|
||||
<!-- Header action buttons share the fade-in treatment for consistency. -->
|
||||
|
||||
<div class="ml-auto flex items-center space-x-4 fade-in fade-in-delay-200">
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
id="loginButton"
|
||||
@@ -121,17 +149,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Container -->
|
||||
<!-- Notification Portal -->
|
||||
<div
|
||||
id="errorContainer"
|
||||
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
|
||||
></div>
|
||||
id="notificationPortal"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label="System notifications"
|
||||
>
|
||||
<!-- Error Container -->
|
||||
<div
|
||||
id="errorContainer"
|
||||
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
|
||||
></div>
|
||||
|
||||
<!-- Success Container -->
|
||||
<div
|
||||
id="successContainer"
|
||||
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
|
||||
></div>
|
||||
<!-- Status Container -->
|
||||
<div
|
||||
id="statusContainer"
|
||||
class="hidden status-banner"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="status-spinner" aria-hidden="true"></span>
|
||||
<span class="status-message" data-status-message></span>
|
||||
</div>
|
||||
|
||||
<!-- Success Container -->
|
||||
<div
|
||||
id="successContainer"
|
||||
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic views (like most-recent-videos.html, etc.) -->
|
||||
<main id="viewContainer" class="flex-grow mb-8"></main>
|
||||
@@ -140,7 +187,10 @@
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- Tagline / Slogan -->
|
||||
<div class="text-center mb-8">
|
||||
|
||||
<!-- Tagline fade matches the cards. -->
|
||||
|
||||
<div class="text-center mb-8 fade-in fade-in-delay-300">
|
||||
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
||||
seed. zap. subscribe.
|
||||
</h2>
|
||||
@@ -148,10 +198,28 @@
|
||||
</div>
|
||||
|
||||
<!-- Additional Scripts -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<script type="module">
|
||||
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
|
||||
window.NostrTools = { nip19, SimplePool };
|
||||
import * as nostrTools from "https://esm.sh/[email protected]";
|
||||
|
||||
const normalizedGeneratePrivateKey =
|
||||
typeof nostrTools.generatePrivateKey === "function"
|
||||
? nostrTools.generatePrivateKey
|
||||
: typeof nostrTools.generateSecretKey === "function"
|
||||
? nostrTools.generateSecretKey
|
||||
: undefined;
|
||||
|
||||
const normalizedGenerateSecretKey =
|
||||
typeof nostrTools.generateSecretKey === "function"
|
||||
? nostrTools.generateSecretKey
|
||||
: typeof nostrTools.generatePrivateKey === "function"
|
||||
? nostrTools.generatePrivateKey
|
||||
: undefined;
|
||||
|
||||
window.NostrTools = {
|
||||
...nostrTools,
|
||||
generatePrivateKey: normalizedGeneratePrivateKey,
|
||||
generateSecretKey: normalizedGenerateSecretKey,
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="js/config.js"></script>
|
||||
<script type="module" src="js/lists.js"></script>
|
||||
|
||||
+384
-125
@@ -1,154 +1,413 @@
|
||||
// js/accessControl.js
|
||||
|
||||
import { isDevMode, isWhitelistEnabled } from "./config.js";
|
||||
import { initialWhitelist, initialBlacklist } from "./lists.js";
|
||||
import {
|
||||
ADMIN_EDITORS_NPUBS,
|
||||
ADMIN_SUPER_NPUB,
|
||||
getWhitelistMode,
|
||||
setWhitelistMode as persistWhitelistMode,
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
} from "./config.js";
|
||||
import { loadAdminState, persistAdminState } from "./adminListStore.js";
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
normalized.reduce((set, npub) => {
|
||||
if (npub) {
|
||||
set.add(npub);
|
||||
}
|
||||
return set;
|
||||
}, new Set())
|
||||
);
|
||||
}
|
||||
|
||||
function isValidNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
return decoded?.type === "npub";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class AccessControl {
|
||||
constructor() {
|
||||
// Debug logging for initialization
|
||||
console.log("DEBUG: AccessControl constructor called");
|
||||
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
|
||||
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
|
||||
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
|
||||
|
||||
// Initialize empty sets
|
||||
this.whitelist = new Set(initialWhitelist);
|
||||
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
||||
|
||||
// Debug the sets
|
||||
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
|
||||
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
|
||||
|
||||
// Save to localStorage
|
||||
this.saveWhitelist();
|
||||
this.saveBlacklist();
|
||||
this.editors = new Set();
|
||||
this.whitelist = new Set();
|
||||
this.blacklist = new Set();
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = false;
|
||||
this.lastError = null;
|
||||
this._isRefreshing = false;
|
||||
this._refreshPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
// Rest of the class remains the same...
|
||||
loadWhitelist() {
|
||||
try {
|
||||
const stored = localStorage.getItem("bitvid_whitelist");
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error("Error loading whitelist:", error);
|
||||
return [];
|
||||
_performRefresh() {
|
||||
if (this._isRefreshing) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
}
|
||||
|
||||
loadBlacklist() {
|
||||
try {
|
||||
const stored = localStorage.getItem("bitvid_blacklist");
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error("Error loading blacklist:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this._isRefreshing = true;
|
||||
|
||||
saveWhitelist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"bitvid_whitelist",
|
||||
JSON.stringify([...this.whitelist])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error saving whitelist:", error);
|
||||
}
|
||||
}
|
||||
|
||||
saveBlacklist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"bitvid_blacklist",
|
||||
JSON.stringify([...this.blacklist])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error saving blacklist:", error);
|
||||
}
|
||||
}
|
||||
|
||||
addToWhitelist(npub) {
|
||||
if (!this.isValidNpub(npub)) {
|
||||
throw new Error("Invalid npub format");
|
||||
}
|
||||
this.whitelist.add(npub);
|
||||
this.saveWhitelist();
|
||||
if (isDevMode) console.log(`Added ${npub} to whitelist`);
|
||||
}
|
||||
|
||||
removeFromWhitelist(npub) {
|
||||
this.whitelist.delete(npub);
|
||||
this.saveWhitelist();
|
||||
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
|
||||
}
|
||||
|
||||
addToBlacklist(npub) {
|
||||
if (!this.isValidNpub(npub)) {
|
||||
throw new Error("Invalid npub format");
|
||||
}
|
||||
this.blacklist.add(npub);
|
||||
this.saveBlacklist();
|
||||
if (isDevMode) console.log(`Added ${npub} to blacklist`);
|
||||
}
|
||||
|
||||
removeFromBlacklist(npub) {
|
||||
this.blacklist.delete(npub);
|
||||
this.saveBlacklist();
|
||||
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
|
||||
}
|
||||
|
||||
isWhitelisted(npub) {
|
||||
const result = this.whitelist.has(npub);
|
||||
if (isDevMode)
|
||||
console.log(
|
||||
`Checking if ${npub} is whitelisted:`,
|
||||
result,
|
||||
"Current whitelist:",
|
||||
[...this.whitelist]
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
isBlacklisted(npub) {
|
||||
return this.blacklist.has(npub);
|
||||
}
|
||||
|
||||
canAccess(npub) {
|
||||
if (this.isBlacklisted(npub)) {
|
||||
return false;
|
||||
}
|
||||
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
|
||||
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
filterVideos(videos) {
|
||||
return videos.filter((video) => {
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
|
||||
return !this.isBlacklisted(npub);
|
||||
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 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) {
|
||||
console.error("Error filtering video:", error);
|
||||
return false;
|
||||
this.lastError = error;
|
||||
if (!this.hasLoaded) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.blacklist.clear();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this._isRefreshing = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
this._refreshPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
isValidNpub(npub) {
|
||||
refresh() {
|
||||
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;
|
||||
});
|
||||
this._refreshPromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async ensureReady() {
|
||||
if (!this.hasLoaded && !this._isRefreshing) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
try {
|
||||
return npub.startsWith("npub1") && npub.length === 63;
|
||||
await this._refreshPromise;
|
||||
} catch (error) {
|
||||
if (!this.hasLoaded) {
|
||||
await this.refresh();
|
||||
await this._refreshPromise;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
whitelistMode() {
|
||||
return this.whitelistEnabled;
|
||||
}
|
||||
|
||||
isSuperAdmin(npub) {
|
||||
const normalized = normalizeNpub(npub);
|
||||
return normalized ? normalized === ADMIN_SUPER_NPUB : false;
|
||||
}
|
||||
|
||||
isAdminEditor(npub) {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (this.isSuperAdmin(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return this.editors.has(normalized);
|
||||
}
|
||||
|
||||
canEditAdminLists(npub) {
|
||||
return this.isAdminEditor(npub);
|
||||
}
|
||||
|
||||
getWhitelist() {
|
||||
return [...this.whitelist];
|
||||
return Array.from(this.whitelist);
|
||||
}
|
||||
|
||||
getBlacklist() {
|
||||
return [...this.blacklist];
|
||||
return Array.from(this.blacklist);
|
||||
}
|
||||
|
||||
getEditors() {
|
||||
return Array.from(this.editors);
|
||||
}
|
||||
|
||||
async addModerator(requestorNpub, moderatorNpub) {
|
||||
if (!this.isSuperAdmin(requestorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
if (!isValidNpub(moderatorNpub)) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(moderatorNpub);
|
||||
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
|
||||
const nextEditors = dedupeNpubs([...this.getEditors(), normalized]);
|
||||
|
||||
try {
|
||||
await persistAdminState(requestorNpub, { editors: nextEditors });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
async removeModerator(requestorNpub, moderatorNpub) {
|
||||
if (!this.isSuperAdmin(requestorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(moderatorNpub);
|
||||
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
const nextEditors = this.getEditors().filter((value) => value !== normalized);
|
||||
|
||||
try {
|
||||
await persistAdminState(requestorNpub, { editors: nextEditors });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
async addToWhitelist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
if (!isValidNpub(targetNpub)) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(targetNpub);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]);
|
||||
const nextBlacklist = this.getBlacklist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, {
|
||||
whitelist: nextWhitelist,
|
||||
blacklist: nextBlacklist,
|
||||
});
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromWhitelist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(targetNpub);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
const nextWhitelist = this.getWhitelist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, { whitelist: nextWhitelist });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
async addToBlacklist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
if (!isValidNpub(targetNpub)) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(targetNpub);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
const normalizedActor = normalizeNpub(actorNpub);
|
||||
if (normalizedActor && normalized === normalizedActor) {
|
||||
return { ok: false, error: "self" };
|
||||
}
|
||||
|
||||
if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
|
||||
const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]);
|
||||
const nextWhitelist = this.getWhitelist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, {
|
||||
blacklist: nextBlacklist,
|
||||
whitelist: nextWhitelist,
|
||||
});
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromBlacklist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(targetNpub);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
const nextBlacklist = this.getBlacklist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, { blacklist: nextBlacklist });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
setWhitelistMode(actorNpub, enabled) {
|
||||
if (!this.isSuperAdmin(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
persistWhitelistMode(!!enabled);
|
||||
this.whitelistEnabled = !!enabled;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
isBlacklisted(npub) {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (this.whitelist.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return this.blacklist.has(normalized);
|
||||
}
|
||||
|
||||
canAccess(candidate) {
|
||||
let npub = "";
|
||||
|
||||
if (typeof candidate === "string") {
|
||||
npub = candidate;
|
||||
} else if (candidate && typeof candidate === "object") {
|
||||
if (typeof candidate.npub === "string") {
|
||||
npub = candidate.npub;
|
||||
} else if (typeof candidate.pubkey === "string") {
|
||||
try {
|
||||
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
|
||||
} catch (error) {
|
||||
npub = candidate.pubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAdminEditor(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.whitelist.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.blacklist.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const accessControl = new AccessControl();
|
||||
export {
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
normalizeNpub,
|
||||
isValidNpub,
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
// js/adminListStore.js
|
||||
|
||||
import {
|
||||
ADMIN_LIST_MODE,
|
||||
ADMIN_LIST_NAMESPACE,
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_EDITORS_NPUBS,
|
||||
isDevMode,
|
||||
} from "./config.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
buildAdminListEvent,
|
||||
ADMIN_LIST_IDENTIFIERS,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
const LEGACY_STORAGE_KEYS = {
|
||||
editors: "bitvid_admin_editors",
|
||||
whitelist: "bitvid_admin_whitelist",
|
||||
whitelistLegacy: "bitvid_whitelist",
|
||||
blacklist: "bitvid_admin_blacklist",
|
||||
blacklistLegacy: "bitvid_blacklist",
|
||||
};
|
||||
|
||||
const PUBLISH_TIMEOUT_MS = 7000;
|
||||
|
||||
function createError(code, message, cause) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
if (cause) {
|
||||
error.cause = cause;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
normalized.reduce((set, npub) => {
|
||||
if (npub) {
|
||||
set.add(npub);
|
||||
}
|
||||
return set;
|
||||
}, new Set())
|
||||
);
|
||||
}
|
||||
|
||||
function canDecodeNpub() {
|
||||
return !!(
|
||||
typeof window !== "undefined" &&
|
||||
window?.NostrTools?.nip19 &&
|
||||
typeof window.NostrTools.nip19.decode === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyNpub(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!canDecodeNpub()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||||
return decoded?.type === "npub";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNpubList(values) {
|
||||
return dedupeNpubs(values).filter(isLikelyNpub);
|
||||
}
|
||||
|
||||
function sanitizeAdminState(state = {}) {
|
||||
const sanitizedEditors = sanitizeNpubList(state.editors || []);
|
||||
const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []);
|
||||
const whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub));
|
||||
|
||||
const adminGuardSet = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
|
||||
...sanitizedEditors.map(normalizeNpub),
|
||||
]);
|
||||
|
||||
const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter(
|
||||
(npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (adminGuardSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
editors: sanitizedEditors,
|
||||
whitelist: sanitizedWhitelist,
|
||||
blacklist: sanitizedBlacklist,
|
||||
};
|
||||
}
|
||||
|
||||
function hasAnyEntries(state = {}) {
|
||||
return Boolean(
|
||||
(Array.isArray(state.editors) && state.editors.length) ||
|
||||
(Array.isArray(state.whitelist) && state.whitelist.length) ||
|
||||
(Array.isArray(state.blacklist) && state.blacklist.length)
|
||||
);
|
||||
}
|
||||
|
||||
function readJsonListFromStorage(key) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[adminListStore] Failed to parse legacy list for ${key}:`, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyAdminState() {
|
||||
const editors = readJsonListFromStorage(LEGACY_STORAGE_KEYS.editors) || [];
|
||||
const whitelist =
|
||||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelist) ||
|
||||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelistLegacy) ||
|
||||
[];
|
||||
const blacklist =
|
||||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklist) ||
|
||||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklistLegacy) ||
|
||||
[];
|
||||
|
||||
const sanitized = sanitizeAdminState({ editors, whitelist, blacklist });
|
||||
return hasAnyEntries(sanitized) ? sanitized : null;
|
||||
}
|
||||
|
||||
function clearLegacyStorageFor(listKey) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyGroups = {
|
||||
editors: [LEGACY_STORAGE_KEYS.editors],
|
||||
whitelist: [
|
||||
LEGACY_STORAGE_KEYS.whitelist,
|
||||
LEGACY_STORAGE_KEYS.whitelistLegacy,
|
||||
],
|
||||
blacklist: [
|
||||
LEGACY_STORAGE_KEYS.blacklist,
|
||||
LEGACY_STORAGE_KEYS.blacklistLegacy,
|
||||
],
|
||||
};
|
||||
|
||||
const keys = keyGroups[listKey];
|
||||
if (!Array.isArray(keys)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[adminListStore] Failed to clear legacy storage for ${key}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNostrReady() {
|
||||
if (!nostrClient || !nostrClient.pool) {
|
||||
throw createError(
|
||||
"nostr-unavailable",
|
||||
"Nostr relay pool is not initialized."
|
||||
);
|
||||
}
|
||||
|
||||
const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : [];
|
||||
if (!relays.length) {
|
||||
throw createError("nostr-unavailable", "No Nostr relays configured.");
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
function decodeNpubToHex(npub) {
|
||||
const trimmed = normalizeNpub(npub);
|
||||
if (!trimmed) {
|
||||
throw createError("invalid npub", "Empty npub provided.");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded?.type !== "npub" || !decoded?.data) {
|
||||
throw new Error("Invalid npub format");
|
||||
}
|
||||
return typeof decoded.data === "string"
|
||||
? decoded.data
|
||||
: decoded.data?.pubkey || "";
|
||||
} catch (error) {
|
||||
throw createError("invalid npub", "Unable to decode npub.", error);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHexToNpub(hex) {
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.npubEncode(hex) || "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("Failed to encode hex pubkey to npub:", error);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractNpubsFromEvent(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hexKeys = event.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
const npubs = hexKeys
|
||||
.map((hex) => encodeHexToNpub(hex))
|
||||
.filter((npub) => typeof npub === "string" && npub);
|
||||
|
||||
return dedupeNpubs(npubs);
|
||||
}
|
||||
|
||||
async function loadNostrList(identifier) {
|
||||
const relays = ensureNostrReady();
|
||||
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
|
||||
const filter = {
|
||||
kinds: [30000],
|
||||
"#d": [dTagValue],
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
const combined = await nostrClient.pool.list(relays, [filter]);
|
||||
if (Array.isArray(combined)) {
|
||||
events = combined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Combined relay fetch failed for ${identifier}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
const perRelay = await Promise.all(
|
||||
relays.map(async (url) => {
|
||||
try {
|
||||
const result = await nostrClient.pool.list([url], [filter]);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Relay fetch failed for ${identifier} on ${url}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
events = perRelay.flat();
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newest = events.reduce((latest, event) => {
|
||||
if (!latest) {
|
||||
return event;
|
||||
}
|
||||
if (event.created_at === latest.created_at) {
|
||||
return event.id > latest.id ? event : latest;
|
||||
}
|
||||
return event.created_at > latest.created_at ? event : latest;
|
||||
}, null);
|
||||
|
||||
return extractNpubsFromEvent(newest);
|
||||
}
|
||||
|
||||
async function loadNostrState() {
|
||||
const [editors, whitelist, blacklist] = await Promise.all([
|
||||
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 hexPubkeys = Array.from(
|
||||
new Set(npubs.map((npub) => {
|
||||
try {
|
||||
return decodeNpubToHex(npub);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Failed to decode npub while publishing ${listKey}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}).filter((hex) => !!hex))
|
||||
);
|
||||
|
||||
if (listKey === "whitelist" || listKey === "editors") {
|
||||
try {
|
||||
const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB);
|
||||
if (superHex && !hexPubkeys.includes(superHex)) {
|
||||
hexPubkeys.push(superHex);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("Failed to ensure super admin presence:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildAdminListEvent(listKey, {
|
||||
pubkey: actorHex,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
hexPubkeys,
|
||||
});
|
||||
}
|
||||
|
||||
function publishToRelay(url, signedEvent, listKey) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ url, success: false, error: new Error("timeout") });
|
||||
}
|
||||
}, PUBLISH_TIMEOUT_MS);
|
||||
|
||||
const finalize = (success, error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({ url, success, error });
|
||||
};
|
||||
|
||||
try {
|
||||
const pub = nostrClient.pool.publish([url], signedEvent);
|
||||
if (!pub) {
|
||||
finalize(false, new Error("publish returned no result"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof pub.on === "function") {
|
||||
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"));
|
||||
finalize(false, error);
|
||||
};
|
||||
|
||||
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") {
|
||||
pub.then(() => finalize(true)).catch((error) => finalize(false, error));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
finalize(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
finalize(true);
|
||||
}).then((result) => {
|
||||
if (!result.success && isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Publish failed for ${listKey} on ${result.url}:`,
|
||||
result.error
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function persistNostrState(actorNpub, updates = {}) {
|
||||
ensureNostrReady();
|
||||
const extension = window?.nostr;
|
||||
if (!extension || typeof extension.signEvent !== "function") {
|
||||
throw createError(
|
||||
"nostr-extension-missing",
|
||||
"A Nostr extension with signEvent support is required."
|
||||
);
|
||||
}
|
||||
|
||||
const actorHex = decodeNpubToHex(actorNpub);
|
||||
if (!actorHex) {
|
||||
throw createError("invalid npub", "Unable to decode the actor npub.");
|
||||
}
|
||||
|
||||
const sanitizedUpdates = {};
|
||||
const actorNormalized = normalizeNpub(actorNpub);
|
||||
|
||||
if (Array.isArray(updates.editors)) {
|
||||
sanitizedUpdates.editors = sanitizeNpubList(updates.editors).filter((npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(updates.whitelist)) {
|
||||
sanitizedUpdates.whitelist = sanitizeNpubList(updates.whitelist);
|
||||
}
|
||||
|
||||
if (Array.isArray(updates.blacklist)) {
|
||||
const whitelistSet = new Set(
|
||||
(sanitizedUpdates.whitelist || []).map(normalizeNpub)
|
||||
);
|
||||
const editorGuard = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
|
||||
...((sanitizedUpdates.editors || []).map(normalizeNpub) || []),
|
||||
]);
|
||||
|
||||
if (actorNormalized) {
|
||||
editorGuard.add(actorNormalized);
|
||||
}
|
||||
|
||||
sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter(
|
||||
(npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (editorGuard.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(sanitizedUpdates).filter(([, value]) =>
|
||||
Array.isArray(value)
|
||||
);
|
||||
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [listKey, npubs] of entries) {
|
||||
const event = buildListEvent(listKey, npubs, actorHex);
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await extension.signEvent(event);
|
||||
} catch (error) {
|
||||
throw createError("signature-failed", "Failed to sign admin list event.", error);
|
||||
}
|
||||
|
||||
const relays = ensureNostrReady();
|
||||
const publishResults = await Promise.all(
|
||||
relays.map((url) => publishToRelay(url, signedEvent, listKey))
|
||||
);
|
||||
|
||||
if (!publishResults.some((result) => result.success)) {
|
||||
throw createError(
|
||||
"publish-failed",
|
||||
`Failed to publish ${listKey} list to any relay.`
|
||||
);
|
||||
}
|
||||
|
||||
clearLegacyStorageFor(listKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAdminState() {
|
||||
if (ADMIN_LIST_MODE !== "nostr" && isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.`
|
||||
);
|
||||
}
|
||||
|
||||
const state = await loadNostrState();
|
||||
if (!hasAnyEntries(state)) {
|
||||
const legacy = loadLegacyAdminState();
|
||||
if (legacy && isDevMode) {
|
||||
console.warn(
|
||||
"[adminListStore] Ignoring legacy admin lists because remote mode is enforced. Publish them to Nostr to retain access.",
|
||||
legacy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function persistAdminState(actorNpub, updates) {
|
||||
if (ADMIN_LIST_MODE !== "nostr" && isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.`
|
||||
);
|
||||
}
|
||||
|
||||
return persistNostrState(actorNpub, updates);
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// js/analytics.js
|
||||
import { ANALYTICS_CONFIG } from "./analyticsConfig.js";
|
||||
|
||||
const SCRIPT_ATTR = "data-bitvid-analytics";
|
||||
const SCRIPT_IDENTIFIER = "umami";
|
||||
const pendingCalls = [];
|
||||
let flushTimerId = null;
|
||||
let scriptLoadedOnce = false;
|
||||
|
||||
function isBrowserEnvironment() {
|
||||
return typeof window !== "undefined" && typeof document !== "undefined";
|
||||
}
|
||||
|
||||
function flushPendingCalls() {
|
||||
if (!isBrowserEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const umami = window.umami;
|
||||
if (!umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (pendingCalls.length > 0) {
|
||||
const { method, args } = pendingCalls.shift();
|
||||
const fn = typeof umami[method] === "function" ? umami[method] : null;
|
||||
if (!fn) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fn.apply(umami, args);
|
||||
} catch (err) {
|
||||
console.warn("[analytics] Failed to call", method, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (!isBrowserEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||
flushTimerId = window.setInterval(() => {
|
||||
if (window.umami && typeof window.umami.track === "function") {
|
||||
window.clearInterval(flushTimerId);
|
||||
flushTimerId = null;
|
||||
flushPendingCalls();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
export function ensureAnalyticsLoaded(doc = typeof document !== "undefined" ? document : null) {
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let script = doc.querySelector(`script[${SCRIPT_ATTR}]`);
|
||||
if (script) {
|
||||
if (!scriptLoadedOnce) {
|
||||
// If a server rendered script already exists, make sure we flush asap.
|
||||
scriptLoadedOnce = true;
|
||||
flushPendingCalls();
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
script = doc.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = ANALYTICS_CONFIG.scriptSrc;
|
||||
script.setAttribute(SCRIPT_ATTR, SCRIPT_IDENTIFIER);
|
||||
script.dataset.websiteId = ANALYTICS_CONFIG.websiteId;
|
||||
// Allow Umami to handle automatic pageview tracking by default.
|
||||
script.addEventListener("load", () => {
|
||||
scriptLoadedOnce = true;
|
||||
flushPendingCalls();
|
||||
});
|
||||
doc.head.appendChild(script);
|
||||
|
||||
scheduleFlush();
|
||||
|
||||
return script;
|
||||
}
|
||||
|
||||
function queueCall(method, args) {
|
||||
pendingCalls.push({ method, args });
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
function invokeUmami(method, args) {
|
||||
if (!isBrowserEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureAnalyticsLoaded();
|
||||
|
||||
const umami = window.umami;
|
||||
if (umami && typeof umami[method] === "function") {
|
||||
try {
|
||||
umami[method].apply(umami, args);
|
||||
} catch (err) {
|
||||
console.warn("[analytics] Failed to call", method, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
queueCall(method, args);
|
||||
}
|
||||
|
||||
export function trackPageView(path, referrer) {
|
||||
if (!isBrowserEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath =
|
||||
typeof path === "string" && path.length > 0
|
||||
? path
|
||||
: `${window.location.pathname}${window.location.hash || ""}`;
|
||||
|
||||
const resolvedReferrer =
|
||||
typeof referrer === "string" ? referrer : document.referrer || "";
|
||||
|
||||
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||
let absoluteUrl = resolvedPath;
|
||||
try {
|
||||
absoluteUrl = new URL(resolvedPath, window.location.origin).toString();
|
||||
} catch (err) {
|
||||
// Fall back to the provided path if it cannot be resolved.
|
||||
}
|
||||
|
||||
let absoluteReferrer = resolvedReferrer;
|
||||
if (resolvedReferrer) {
|
||||
try {
|
||||
absoluteReferrer = new URL(resolvedReferrer, window.location.origin).toString();
|
||||
} catch (err) {
|
||||
// Keep original referrer if it cannot be normalized.
|
||||
}
|
||||
}
|
||||
|
||||
invokeUmami("track", [
|
||||
(basePayload = {}) => ({
|
||||
...basePayload,
|
||||
url: absoluteUrl || basePayload.url,
|
||||
referrer: absoluteReferrer || basePayload.referrer,
|
||||
}),
|
||||
"pageview",
|
||||
]);
|
||||
}
|
||||
|
||||
export function trackVideoView({
|
||||
videoId,
|
||||
title,
|
||||
source,
|
||||
hasMagnet,
|
||||
hasUrl,
|
||||
} = {}) {
|
||||
if (!isBrowserEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
|
||||
if (videoId) {
|
||||
payload.videoId = String(videoId);
|
||||
}
|
||||
|
||||
if (title) {
|
||||
payload.title = String(title);
|
||||
}
|
||||
|
||||
if (source) {
|
||||
payload.source = String(source);
|
||||
}
|
||||
|
||||
if (typeof hasMagnet === "boolean") {
|
||||
payload.hasMagnet = hasMagnet;
|
||||
}
|
||||
|
||||
if (typeof hasUrl === "boolean") {
|
||||
payload.hasUrl = hasUrl;
|
||||
}
|
||||
|
||||
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||
invokeUmami("track", [ANALYTICS_CONFIG.videoViewEventName, payload]);
|
||||
}
|
||||
|
||||
// Immediately queue the analytics script so page views are captured early.
|
||||
if (isBrowserEnvironment()) {
|
||||
ensureAnalyticsLoaded();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// js/analyticsConfig.js
|
||||
// Central configuration for site-wide analytics tracking.
|
||||
|
||||
export const ANALYTICS_CONFIG = Object.freeze({
|
||||
/**
|
||||
* Hosted Umami script that powers analytics.
|
||||
* Update this value if the tracker is relocated.
|
||||
*/
|
||||
scriptSrc: "https://umami.malin.onl/script.js",
|
||||
/**
|
||||
* The Umami website identifier for this deployment.
|
||||
*/
|
||||
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
|
||||
/**
|
||||
* Event name used when recording individual video views.
|
||||
*/
|
||||
videoViewEventName: "video_view",
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Buffer } from "https://esm.sh/[email protected]?bundle";
|
||||
|
||||
const globalScope = typeof globalThis !== "undefined" ? globalThis : window;
|
||||
|
||||
if (globalScope && !globalScope.Buffer) {
|
||||
globalScope.Buffer = Buffer;
|
||||
}
|
||||
|
||||
if (globalScope && !globalScope.global) {
|
||||
globalScope.global = globalScope;
|
||||
}
|
||||
|
||||
if (globalScope && !globalScope.process) {
|
||||
globalScope.process = { env: {} };
|
||||
}
|
||||
|
||||
export { Buffer };
|
||||
+510
-81
@@ -1,10 +1,212 @@
|
||||
// js/channelProfile.js
|
||||
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
} from "./nostr.js";
|
||||
import { app } from "./app.js";
|
||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||
import { isWhitelistEnabled } from "./config.js";
|
||||
import { attachHealthBadges } from "./gridHealth.js";
|
||||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
|
||||
let currentChannelHex = null;
|
||||
let currentChannelNpub = null;
|
||||
|
||||
let cachedZapButton = null;
|
||||
let cachedChannelShareButton = null;
|
||||
let cachedChannelMenu = null;
|
||||
|
||||
function getChannelZapButton() {
|
||||
if (cachedZapButton && !document.body.contains(cachedZapButton)) {
|
||||
cachedZapButton = null;
|
||||
}
|
||||
if (!cachedZapButton) {
|
||||
cachedZapButton = document.getElementById("zapButton");
|
||||
}
|
||||
return cachedZapButton;
|
||||
}
|
||||
|
||||
function setChannelZapVisibility(visible) {
|
||||
const zapButton = getChannelZapButton();
|
||||
if (!zapButton) {
|
||||
return;
|
||||
}
|
||||
const shouldShow = !!visible;
|
||||
zapButton.classList.toggle("hidden", !shouldShow);
|
||||
zapButton.disabled = !shouldShow;
|
||||
zapButton.setAttribute("aria-disabled", (!shouldShow).toString());
|
||||
zapButton.setAttribute("aria-hidden", (!shouldShow).toString());
|
||||
if (shouldShow) {
|
||||
zapButton.removeAttribute("tabindex");
|
||||
} else {
|
||||
zapButton.setAttribute("tabindex", "-1");
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelShareButton() {
|
||||
if (cachedChannelShareButton && !document.body.contains(cachedChannelShareButton)) {
|
||||
cachedChannelShareButton = null;
|
||||
}
|
||||
if (!cachedChannelShareButton) {
|
||||
cachedChannelShareButton = document.getElementById("channelShareBtn");
|
||||
}
|
||||
return cachedChannelShareButton;
|
||||
}
|
||||
|
||||
function getChannelMenuElement() {
|
||||
if (cachedChannelMenu && !document.body.contains(cachedChannelMenu)) {
|
||||
cachedChannelMenu = null;
|
||||
}
|
||||
if (!cachedChannelMenu) {
|
||||
cachedChannelMenu = document.getElementById("moreDropdown-channel-profile");
|
||||
}
|
||||
return cachedChannelMenu;
|
||||
}
|
||||
|
||||
function buildChannelShareUrl() {
|
||||
if (!currentChannelNpub) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const base = typeof app.getShareUrlBase === "function" ? app.getShareUrlBase() : "";
|
||||
if (base) {
|
||||
return `${base}#view=channel-profile&npub=${encodeURIComponent(currentChannelNpub)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
url.hash = `#view=channel-profile&npub=${currentChannelNpub}`;
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
console.warn("Falling back to basic channel share URL:", error);
|
||||
const origin = window.location?.origin || "";
|
||||
const pathname = window.location?.pathname || "";
|
||||
if (!origin && !pathname) {
|
||||
return "";
|
||||
}
|
||||
return `${origin}${pathname}#view=channel-profile&npub=${currentChannelNpub}`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncChannelShareButtonState() {
|
||||
const shareBtn = getChannelShareButton();
|
||||
if (!shareBtn) {
|
||||
return;
|
||||
}
|
||||
const hasNpub = typeof currentChannelNpub === "string" && currentChannelNpub;
|
||||
shareBtn.disabled = !hasNpub;
|
||||
shareBtn.setAttribute("aria-disabled", (!hasNpub).toString());
|
||||
shareBtn.classList.toggle("opacity-50", !hasNpub);
|
||||
shareBtn.classList.toggle("cursor-not-allowed", !hasNpub);
|
||||
}
|
||||
|
||||
function setupChannelShareButton() {
|
||||
const shareBtn = getChannelShareButton();
|
||||
if (!shareBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncChannelShareButtonState();
|
||||
|
||||
if (shareBtn.dataset.initialized === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
shareBtn.addEventListener("click", () => {
|
||||
const shareUrl = buildChannelShareUrl();
|
||||
if (!shareUrl) {
|
||||
app.showError("Could not generate channel link.");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => app.showSuccess("Channel link copied to clipboard!"))
|
||||
.catch(() => app.showError("Failed to copy the link."));
|
||||
});
|
||||
|
||||
shareBtn.dataset.initialized = "true";
|
||||
}
|
||||
|
||||
function setupChannelMoreMenu() {
|
||||
const container = document.querySelector(".channel-profile-container");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
app.attachMoreMenuHandlers(container);
|
||||
}
|
||||
|
||||
async function updateChannelMenuState() {
|
||||
const menu = getChannelMenuElement();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh moderation lists for channel menu:", error);
|
||||
}
|
||||
|
||||
const canBlacklist =
|
||||
typeof app.canCurrentUserManageBlacklist === "function"
|
||||
? app.canCurrentUserManageBlacklist()
|
||||
: false;
|
||||
|
||||
const buttons = menu.querySelectorAll("button[data-action]");
|
||||
buttons.forEach((button) => {
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = button.dataset.action || "";
|
||||
if (action === "copy-npub") {
|
||||
if (currentChannelNpub) {
|
||||
button.dataset.npub = currentChannelNpub;
|
||||
button.classList.remove("hidden");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
delete button.dataset.npub;
|
||||
button.classList.add("hidden");
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "block-author") {
|
||||
if (currentChannelHex) {
|
||||
button.dataset.author = currentChannelHex;
|
||||
button.classList.remove("hidden");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
delete button.dataset.author;
|
||||
button.classList.add("hidden");
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "blacklist-author") {
|
||||
if (canBlacklist && currentChannelHex) {
|
||||
button.dataset.author = currentChannelHex;
|
||||
if (currentChannelNpub) {
|
||||
button.dataset.npub = currentChannelNpub;
|
||||
} else {
|
||||
delete button.dataset.npub;
|
||||
}
|
||||
button.classList.remove("hidden");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
delete button.dataset.author;
|
||||
delete button.dataset.npub;
|
||||
button.classList.add("hidden");
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the channel profile view.
|
||||
@@ -21,6 +223,9 @@ export async function initChannelProfileView() {
|
||||
return;
|
||||
}
|
||||
|
||||
currentChannelHex = null;
|
||||
currentChannelNpub = null;
|
||||
|
||||
// 2) Decode npub => hex pubkey
|
||||
let hexPub;
|
||||
try {
|
||||
@@ -35,6 +240,13 @@ export async function initChannelProfileView() {
|
||||
return;
|
||||
}
|
||||
|
||||
currentChannelHex = hexPub;
|
||||
currentChannelNpub = npub;
|
||||
|
||||
setupChannelShareButton();
|
||||
setupChannelMoreMenu();
|
||||
await updateChannelMenuState();
|
||||
|
||||
// 3) If user is logged in, load subscriptions and show sub/unsub button
|
||||
if (app.pubkey) {
|
||||
await subscriptions.loadSubscriptions(app.pubkey);
|
||||
@@ -44,13 +256,36 @@ export async function initChannelProfileView() {
|
||||
if (btn) btn.classList.add("hidden");
|
||||
}
|
||||
|
||||
setupZapButton();
|
||||
syncChannelShareButtonState();
|
||||
|
||||
// 4) Load user’s profile (banner, avatar, etc.)
|
||||
await loadUserProfile(hexPub);
|
||||
await updateChannelMenuState();
|
||||
syncChannelShareButtonState();
|
||||
|
||||
// 5) Load user’s videos (filtered + rendered like the home feed)
|
||||
await loadUserVideos(hexPub);
|
||||
}
|
||||
|
||||
function setupZapButton() {
|
||||
const zapButton = getChannelZapButton();
|
||||
if (!zapButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChannelZapVisibility(false);
|
||||
|
||||
if (zapButton.dataset.initialized === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
zapButton.addEventListener("click", () => {
|
||||
window.alert("Zaps coming soon.");
|
||||
});
|
||||
zapButton.dataset.initialized = "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Subscribe / Unsubscribe button with an icon,
|
||||
* using color #fe0032 and the subscribe-button-icon.svg on the left.
|
||||
@@ -159,15 +394,27 @@ async function loadUserProfile(pubkey) {
|
||||
|
||||
// Lightning Address
|
||||
const lnEl = document.getElementById("channelLightning");
|
||||
const lightningAddress = (meta.lud16 || meta.lud06 || "").trim();
|
||||
if (lnEl) {
|
||||
lnEl.textContent =
|
||||
meta.lud16 || meta.lud06 || "No lightning address found.";
|
||||
lightningAddress || "No lightning address found.";
|
||||
}
|
||||
setChannelZapVisibility(!!lightningAddress);
|
||||
} else {
|
||||
console.warn("No metadata found for this user.");
|
||||
setChannelZapVisibility(false);
|
||||
const lnEl = document.getElementById("channelLightning");
|
||||
if (lnEl) {
|
||||
lnEl.textContent = "No lightning address found.";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user profile data:", err);
|
||||
setChannelZapVisibility(false);
|
||||
const lnEl = document.getElementById("channelLightning");
|
||||
if (lnEl) {
|
||||
lnEl.textContent = "No lightning address found.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +424,12 @@ async function loadUserProfile(pubkey) {
|
||||
*/
|
||||
async function loadUserVideos(pubkey) {
|
||||
try {
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn("Failed to ensure admin lists were loaded before channel fetch:", error);
|
||||
}
|
||||
|
||||
// 1) Build filter for videos from this pubkey
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
@@ -196,29 +449,24 @@ async function loadUserVideos(pubkey) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Convert to "video" objects
|
||||
let videos = [];
|
||||
for (const evt of events) {
|
||||
const vid = localConvertEventToVideo(evt);
|
||||
if (!vid.invalid && !vid.deleted) {
|
||||
videos.push(vid);
|
||||
}
|
||||
}
|
||||
// 3) Convert to "video" objects and keep everything (including tombstones)
|
||||
const convertedVideos = events
|
||||
.map((evt) => sharedConvertEventToVideo(evt))
|
||||
.filter((vid) => !vid.invalid);
|
||||
|
||||
// 4) Deduplicate older overshadowed versions => newest only
|
||||
videos = dedupeToNewestByRoot(videos);
|
||||
const newestByRoot =
|
||||
app?.dedupeVideosByRoot?.(convertedVideos) ??
|
||||
dedupeToNewestByRoot(convertedVideos);
|
||||
|
||||
// 5) Filter out blacklisted IDs / authors
|
||||
// 5) Filter out tombstones, blacklisted IDs / authors
|
||||
let videos = newestByRoot.filter((video) => !video.deleted);
|
||||
videos = videos.filter((video) => {
|
||||
// Event-level blacklisting
|
||||
if (app.blacklistedEventIds.has(video.id)) return false;
|
||||
|
||||
// Author-level
|
||||
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||
if (initialBlacklist.includes(authorNpub)) return false;
|
||||
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
||||
return false;
|
||||
}
|
||||
if (!accessControl.canAccess(video)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -251,6 +499,10 @@ async function loadUserVideos(pubkey) {
|
||||
video.alreadyDecrypted = true;
|
||||
}
|
||||
|
||||
// Ensure the global videos map is kept up to date so delegated handlers
|
||||
// have the freshest metadata for this event.
|
||||
window.app?.videosMap?.set(video.id, video);
|
||||
|
||||
// Check if user can edit
|
||||
const canEdit = video.pubkey === app.pubkey;
|
||||
let hasOlder = false;
|
||||
@@ -264,6 +516,7 @@ async function loadUserVideos(pubkey) {
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||
hover:bg-red-700 hover:text-white"
|
||||
data-revert-index="${index}"
|
||||
data-revert-event-id="${video.id}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
@@ -298,6 +551,7 @@ async function loadUserVideos(pubkey) {
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100
|
||||
hover:bg-gray-700"
|
||||
data-edit-index="${index}"
|
||||
data-edit-event-id="${video.id}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -306,6 +560,7 @@ async function loadUserVideos(pubkey) {
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||
hover:bg-red-700 hover:text-white"
|
||||
data-delete-all-index="${index}"
|
||||
data-delete-all-event-id="${video.id}"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
@@ -315,6 +570,48 @@ async function loadUserVideos(pubkey) {
|
||||
`;
|
||||
}
|
||||
|
||||
const moreMenu = `
|
||||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-10 h-10 p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
data-more-dropdown="${index}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More options"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-${index}"
|
||||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="open-channel" data-author="${video.pubkey || ""}">
|
||||
Open channel
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||||
Copy link
|
||||
</button>
|
||||
<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="block-author" data-author="${video.pubkey || ""}">
|
||||
Block creator
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardControls = `
|
||||
<div class="flex items-center">
|
||||
${moreMenu}${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fallback thumbnail
|
||||
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||
const safeThumb = video.thumbnail || fallbackThumb;
|
||||
@@ -322,6 +619,7 @@ async function loadUserVideos(pubkey) {
|
||||
|
||||
const cardEl = document.createElement("div");
|
||||
cardEl.classList.add(
|
||||
"video-card",
|
||||
"bg-gray-900",
|
||||
"rounded-lg",
|
||||
"overflow-hidden",
|
||||
@@ -331,8 +629,49 @@ async function loadUserVideos(pubkey) {
|
||||
"duration-300"
|
||||
);
|
||||
|
||||
cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
|
||||
if (typeof video.pubkey === "string" && video.pubkey) {
|
||||
cardEl.dataset.ownerPubkey = video.pubkey;
|
||||
} else if (cardEl.dataset.ownerPubkey) {
|
||||
delete cardEl.dataset.ownerPubkey;
|
||||
}
|
||||
|
||||
const rawMagnet =
|
||||
typeof video.magnet === "string" ? video.magnet : "";
|
||||
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
|
||||
const legacyInfoHash =
|
||||
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
|
||||
const playbackUrl =
|
||||
typeof video.url === "string" ? video.url : "";
|
||||
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
|
||||
const playbackMagnet = trimmedMagnet || legacyInfoHash || "";
|
||||
const magnetProvided = playbackMagnet.length > 0;
|
||||
const magnetSupported = app.isMagnetUriSupported(playbackMagnet);
|
||||
const showUnsupportedTorrentBadge =
|
||||
!trimmedUrl && magnetProvided && !magnetSupported;
|
||||
const urlBadgeHtml = trimmedUrl
|
||||
? app.getUrlHealthPlaceholderMarkup({ includeMargin: false })
|
||||
: "";
|
||||
const torrentHealthBadgeHtml =
|
||||
magnetSupported && magnetProvided
|
||||
? app.getTorrentHealthBadgeMarkup({ includeMargin: false })
|
||||
: "";
|
||||
const connectionBadgesHtml =
|
||||
urlBadgeHtml || torrentHealthBadgeHtml
|
||||
? `
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
${urlBadgeHtml}${torrentHealthBadgeHtml}
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
cardEl.innerHTML = `
|
||||
<div class="cursor-pointer relative group">
|
||||
<div
|
||||
class="cursor-pointer relative group"
|
||||
data-video-id="${video.id}"
|
||||
data-play-url=""
|
||||
data-play-magnet=""
|
||||
>
|
||||
<div class="ratio-16-9">
|
||||
<img
|
||||
src="${fallbackThumb}"
|
||||
@@ -341,36 +680,128 @@ async function loadUserVideos(pubkey) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||
data-video-id="${video.id}"
|
||||
data-play-url=""
|
||||
data-play-magnet=""
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
${cardControls}
|
||||
</div>
|
||||
${gearMenu}
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clicking the card => open the video modal
|
||||
cardEl.addEventListener("click", () => {
|
||||
app.playVideoByEventId(video.id);
|
||||
if (showUnsupportedTorrentBadge) {
|
||||
cardEl.dataset.torrentSupported = "false";
|
||||
} else if (magnetProvided && magnetSupported) {
|
||||
cardEl.dataset.torrentSupported = "true";
|
||||
} else if (cardEl.dataset.torrentSupported) {
|
||||
delete cardEl.dataset.torrentSupported;
|
||||
}
|
||||
|
||||
if (trimmedUrl) {
|
||||
cardEl.dataset.urlHealthState = "checking";
|
||||
if (cardEl.dataset.urlHealthReason) {
|
||||
delete cardEl.dataset.urlHealthReason;
|
||||
}
|
||||
cardEl.dataset.urlHealthEventId = video.id || "";
|
||||
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
cardEl.dataset.urlHealthState = "offline";
|
||||
cardEl.dataset.urlHealthReason = "missing-source";
|
||||
if (cardEl.dataset.urlHealthEventId) {
|
||||
delete cardEl.dataset.urlHealthEventId;
|
||||
}
|
||||
if (cardEl.dataset.urlHealthUrl) {
|
||||
delete cardEl.dataset.urlHealthUrl;
|
||||
}
|
||||
}
|
||||
if (magnetProvided && magnetSupported) {
|
||||
cardEl.dataset.streamHealthState = "checking";
|
||||
if (cardEl.dataset.streamHealthReason) {
|
||||
delete cardEl.dataset.streamHealthReason;
|
||||
}
|
||||
} else {
|
||||
cardEl.dataset.streamHealthState = "unhealthy";
|
||||
cardEl.dataset.streamHealthReason = magnetProvided
|
||||
? "unsupported"
|
||||
: "missing-source";
|
||||
}
|
||||
|
||||
if (magnetProvided) {
|
||||
cardEl.dataset.magnet = playbackMagnet;
|
||||
} else if (cardEl.dataset.magnet) {
|
||||
delete cardEl.dataset.magnet;
|
||||
}
|
||||
|
||||
// Leave the data-play-* attributes empty in the template markup so the raw
|
||||
// URL/magnet strings can be assigned after parsing without HTML entity
|
||||
// escaping, keeping this renderer consistent with app.js. The stored URL is
|
||||
// encoded so it stays intact within data-* attributes, and the click
|
||||
// handler decodes it while leaving magnets untouched until
|
||||
// safeDecodeMagnet() runs.
|
||||
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
|
||||
interactiveEls.forEach((el) => {
|
||||
if (!el.dataset) return;
|
||||
|
||||
if (trimmedUrl) {
|
||||
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
delete el.dataset.playUrl;
|
||||
}
|
||||
|
||||
el.dataset.playMagnet = playbackMagnet || "";
|
||||
if (magnetProvided) {
|
||||
el.dataset.torrentSupported = magnetSupported ? "true" : "false";
|
||||
} else if (el.dataset.torrentSupported) {
|
||||
delete el.dataset.torrentSupported;
|
||||
}
|
||||
});
|
||||
|
||||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||
if (badgeEl) {
|
||||
if (trimmedUrl) {
|
||||
badgeEl.dataset.urlHealthEventId = video.id || "";
|
||||
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
if (badgeEl.dataset.urlHealthEventId) {
|
||||
delete badgeEl.dataset.urlHealthEventId;
|
||||
}
|
||||
if (badgeEl.dataset.urlHealthUrl) {
|
||||
delete badgeEl.dataset.urlHealthUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment.appendChild(cardEl);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
attachHealthBadges(container);
|
||||
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
|
||||
const video = app.videosMap.get(eventId) || { id: eventId };
|
||||
app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||
});
|
||||
|
||||
window.app.attachVideoListHandler();
|
||||
|
||||
// Lazy-load images
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||
|
||||
app.attachMoreMenuHandlers(container);
|
||||
|
||||
// Gear menu toggles
|
||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||
gearButtons.forEach((btn) => {
|
||||
@@ -389,10 +820,15 @@ async function loadUserVideos(pubkey) {
|
||||
editBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-edit-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleEditVideo(idx);
|
||||
const eventId = btn.getAttribute("data-edit-event-id") || "";
|
||||
app.handleEditVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,10 +837,15 @@ async function loadUserVideos(pubkey) {
|
||||
revertBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-revert-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleRevertVideo(idx);
|
||||
const eventId = btn.getAttribute("data-revert-event-id") || "";
|
||||
app.handleRevertVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,10 +854,15 @@ async function loadUserVideos(pubkey) {
|
||||
deleteAllBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
app.handleFullDeleteVideo(idx);
|
||||
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
|
||||
app.handleFullDeleteVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -424,6 +870,26 @@ async function loadUserVideos(pubkey) {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("bitvid:access-control-updated", () => {
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
if (hashParams.get("view") !== "channel-profile") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChannelHex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeNpub = hashParams.get("npub");
|
||||
if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadUserVideos(currentChannelHex).catch((error) => {
|
||||
console.error("Failed to refresh channel videos after admin update:", error);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal placeholder for private video decryption.
|
||||
*/
|
||||
@@ -446,43 +912,6 @@ function dedupeToNewestByRoot(videos) {
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw event => "video" object.
|
||||
*/
|
||||
function localConvertEventToVideo(event) {
|
||||
try {
|
||||
const content = JSON.parse(event.content || "{}");
|
||||
const isSupportedVersion = content.version >= 2;
|
||||
const hasRequiredFields = !!(content.title && content.magnet);
|
||||
|
||||
if (!isSupportedVersion) {
|
||||
return { id: event.id, invalid: true, reason: "version <2" };
|
||||
}
|
||||
if (!hasRequiredFields) {
|
||||
return { id: event.id, invalid: true, reason: "missing title/magnet" };
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
videoRootId: content.videoRootId || event.id,
|
||||
version: content.version,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title ?? "",
|
||||
magnet: content.magnet ?? "",
|
||||
thumbnail: content.thumbnail ?? "",
|
||||
description: content.description ?? "",
|
||||
mode: content.mode ?? "live",
|
||||
deleted: content.deleted === true,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
invalid: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: event.id, invalid: true, reason: "json parse error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic escaping to avoid XSS.
|
||||
*/
|
||||
|
||||
+70
-1
@@ -1,4 +1,73 @@
|
||||
// js/config.js
|
||||
|
||||
import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
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,
|
||||
} from "../config/instance-config.js";
|
||||
|
||||
export const isDevMode = true; // Set to false for production
|
||||
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Admin governance — production defaults rely on remote Nostr lists
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const ADMIN_LIST_MODE = "nostr";
|
||||
export { ADMIN_SUPER_NPUB };
|
||||
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
|
||||
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
|
||||
|
||||
const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY;
|
||||
const DEFAULT_WHITELIST_ENABLED = DEFAULT_WHITELIST_MODE_ENABLED;
|
||||
|
||||
export function getWhitelistMode() {
|
||||
try {
|
||||
const raw = localStorage.getItem(WHITELIST_MODE_KEY);
|
||||
if (raw === null) {
|
||||
return DEFAULT_WHITELIST_ENABLED;
|
||||
}
|
||||
return raw === "true";
|
||||
} catch (error) {
|
||||
console.warn("Failed to read whitelist mode from storage:", error);
|
||||
return DEFAULT_WHITELIST_ENABLED;
|
||||
}
|
||||
}
|
||||
|
||||
export function setWhitelistMode(enabled) {
|
||||
try {
|
||||
localStorage.setItem(WHITELIST_MODE_KEY, enabled ? "true" : "false");
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist whitelist mode to storage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Watch history — roadmap defaults balance retention with relay load
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 };
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
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
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: false,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.fastcast.nz",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://tracker.sloppyta.co:443/announce",
|
||||
]),
|
||||
});
|
||||
|
||||
const globalScope = typeof globalThis === "object" && globalThis ? globalThis : undefined;
|
||||
|
||||
const runtimeFlags = (() => {
|
||||
if (globalScope && typeof globalScope.__BITVID_RUNTIME_FLAGS__ === "object") {
|
||||
return globalScope.__BITVID_RUNTIME_FLAGS__;
|
||||
}
|
||||
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,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
globalScope.__BITVID_RUNTIME_FLAGS__ = initial;
|
||||
}
|
||||
return initial;
|
||||
})();
|
||||
|
||||
function coerceBoolean(value, fallback) {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
if (["false", "0", "off", "no"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (["true", "1", "on", "yes"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function sanitizeTrackerList(candidate) {
|
||||
const input = Array.isArray(candidate) ? candidate : DEFAULT_FLAGS.WSS_TRACKERS;
|
||||
const seen = new Set();
|
||||
const sanitized = [];
|
||||
|
||||
for (const tracker of input) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = tracker.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
sanitized.push(trimmed);
|
||||
}
|
||||
|
||||
if (!sanitized.length) {
|
||||
return [...DEFAULT_FLAGS.WSS_TRACKERS];
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function freezeTrackers(list) {
|
||||
return Object.freeze([...list]);
|
||||
}
|
||||
|
||||
export let URL_FIRST_ENABLED = coerceBoolean(
|
||||
runtimeFlags.URL_FIRST_ENABLED,
|
||||
DEFAULT_FLAGS.URL_FIRST_ENABLED
|
||||
);
|
||||
|
||||
export let ACCEPT_LEGACY_V1 = coerceBoolean(
|
||||
runtimeFlags.ACCEPT_LEGACY_V1,
|
||||
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 WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
);
|
||||
|
||||
Object.defineProperty(runtimeFlags, "URL_FIRST_ENABLED", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return URL_FIRST_ENABLED;
|
||||
},
|
||||
set(next) {
|
||||
URL_FIRST_ENABLED = coerceBoolean(next, DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return ACCEPT_LEGACY_V1;
|
||||
},
|
||||
set(next) {
|
||||
ACCEPT_LEGACY_V1 = coerceBoolean(next, DEFAULT_FLAGS.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, "WSS_TRACKERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return [...WSS_TRACKERS];
|
||||
},
|
||||
set(next) {
|
||||
WSS_TRACKERS = freezeTrackers(sanitizeTrackerList(next));
|
||||
},
|
||||
});
|
||||
|
||||
// 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.WSS_TRACKERS = WSS_TRACKERS;
|
||||
|
||||
export function setUrlFirstEnabled(next) {
|
||||
runtimeFlags.URL_FIRST_ENABLED = next;
|
||||
return URL_FIRST_ENABLED;
|
||||
}
|
||||
|
||||
export function setAcceptLegacyV1(next) {
|
||||
runtimeFlags.ACCEPT_LEGACY_V1 = 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+18
-6
@@ -2,17 +2,29 @@
|
||||
|
||||
class DisclaimerModal {
|
||||
constructor() {
|
||||
// Initialize elements when the disclaimer HTML is in the DOM.
|
||||
this.init();
|
||||
this.modal = null;
|
||||
this.acceptButton = null;
|
||||
this.acceptHandler = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.modal = document.getElementById("disclaimerModal");
|
||||
this.acceptButton = document.getElementById("acceptDisclaimer");
|
||||
if (this.acceptButton) {
|
||||
this.acceptButton.addEventListener("click", () => {
|
||||
const nextAcceptButton = document.getElementById("acceptDisclaimer");
|
||||
|
||||
if (!this.acceptHandler) {
|
||||
this.acceptHandler = () => {
|
||||
this.hide();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (this.acceptButton && this.acceptHandler) {
|
||||
this.acceptButton.removeEventListener("click", this.acceptHandler);
|
||||
}
|
||||
|
||||
this.acceptButton = nextAcceptButton || null;
|
||||
|
||||
if (this.acceptButton && this.acceptHandler) {
|
||||
this.acceptButton.addEventListener("click", this.acceptHandler);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,609 @@
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { infoHashFromMagnet } from "./magnets.js";
|
||||
import { TorrentClient, torrentClient } from "./webtorrent.js";
|
||||
|
||||
const ROOT_MARGIN = "200px 0px";
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function getCacheEntry(key) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const entry = probeCache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (now() - entry.ts > PROBE_CACHE_TTL_MS) {
|
||||
probeCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function setCacheEntry(key, value) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
probeCache.set(key, { ts: now(), value });
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!Number.isFinite(ts)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new Date(ts).toLocaleTimeString([], { hour12: false });
|
||||
} catch (err) {
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTooltip({ peers = 0, checkedAt, reason } = {}) {
|
||||
const parts = [];
|
||||
if (Number.isFinite(peers)) {
|
||||
parts.push(`Peers: ${Math.max(0, peers)}`);
|
||||
}
|
||||
if (Number.isFinite(checkedAt)) {
|
||||
const formatted = formatTime(checkedAt);
|
||||
if (formatted) {
|
||||
parts.push(`Checked ${formatted}`);
|
||||
}
|
||||
}
|
||||
if (typeof reason === "string" && reason && reason !== "peer") {
|
||||
let normalizedReason;
|
||||
if (reason === "timeout") {
|
||||
normalizedReason = "Timed out";
|
||||
} else if (reason === "no-trackers") {
|
||||
normalizedReason = "No WSS trackers";
|
||||
} else if (reason === "invalid") {
|
||||
normalizedReason = "Invalid magnet";
|
||||
} else {
|
||||
normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1);
|
||||
}
|
||||
parts.push(normalizedReason);
|
||||
}
|
||||
if (!parts.length) {
|
||||
return "WebTorrent status unknown";
|
||||
}
|
||||
return `WebTorrent • ${parts.join(" • ")}`;
|
||||
}
|
||||
|
||||
function normalizeResult(result) {
|
||||
const fallback = {
|
||||
healthy: false,
|
||||
peers: 0,
|
||||
reason: "error",
|
||||
appendedTrackers: false,
|
||||
hasProbeTrackers: false,
|
||||
usedTrackers: Array.isArray(TorrentClient.PROBE_TRACKERS)
|
||||
? [...TorrentClient.PROBE_TRACKERS]
|
||||
: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
if (!result || typeof result !== "object") {
|
||||
return { ...fallback, checkedAt: now() };
|
||||
}
|
||||
const peers = Number.isFinite(result.peers)
|
||||
? Math.max(0, Number(result.peers))
|
||||
: 0;
|
||||
const healthy = Boolean(result.healthy) && peers > 0;
|
||||
const reason = typeof result.reason === "string" ? result.reason : "error";
|
||||
return {
|
||||
healthy,
|
||||
peers: healthy ? Math.max(1, peers) : peers,
|
||||
reason,
|
||||
appendedTrackers: Boolean(result.appendedTrackers),
|
||||
hasProbeTrackers:
|
||||
typeof result.hasProbeTrackers === "boolean"
|
||||
? result.hasProbeTrackers
|
||||
: healthy || Boolean(result.appendedTrackers),
|
||||
usedTrackers: Array.isArray(result.usedTrackers)
|
||||
? result.usedTrackers.slice()
|
||||
: fallback.usedTrackers,
|
||||
durationMs: Number.isFinite(result.durationMs)
|
||||
? Math.max(0, Number(result.durationMs))
|
||||
: 0,
|
||||
checkedAt: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function queueProbe(magnet, cacheKey, priority = 0) {
|
||||
if (!magnet) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const cached = getCacheEntry(cacheKey);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
if (probeInflight.has(cacheKey)) {
|
||||
return probeInflight.get(cacheKey);
|
||||
}
|
||||
|
||||
const job = probeQueue
|
||||
.run(
|
||||
() =>
|
||||
torrentClient
|
||||
.probePeers(magnet, {
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
maxWebConns: 2,
|
||||
polls: PROBE_POLL_COUNT,
|
||||
})
|
||||
.catch((err) => ({
|
||||
healthy: false,
|
||||
peers: 0,
|
||||
reason: "error",
|
||||
error: err,
|
||||
appendedTrackers: false,
|
||||
hasProbeTrackers: false,
|
||||
})),
|
||||
priority
|
||||
)
|
||||
.then((result) => {
|
||||
const normalized = normalizeResult(result);
|
||||
if (cacheKey) {
|
||||
setCacheEntry(cacheKey, normalized);
|
||||
}
|
||||
return normalized;
|
||||
})
|
||||
.finally(() => {
|
||||
if (cacheKey) {
|
||||
probeInflight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
|
||||
if (cacheKey) {
|
||||
probeInflight.set(cacheKey, job);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
const PROBE_CONCURRENCY = 96;
|
||||
const PROBE_POLL_COUNT = 3;
|
||||
const PRIORITY_BASELINE = 1_000_000;
|
||||
|
||||
class ProbeQueue {
|
||||
constructor(max = 2) {
|
||||
this.max = Math.max(1, Number(max) || 1);
|
||||
this.running = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
run(task, priority = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedPriority = Number.isFinite(priority) ? priority : 0;
|
||||
const job = {
|
||||
task,
|
||||
resolve,
|
||||
reject,
|
||||
priority: normalizedPriority,
|
||||
};
|
||||
this.enqueue(job);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
enqueue(job) {
|
||||
if (!this.queue.length) {
|
||||
this.queue.push(job);
|
||||
return;
|
||||
}
|
||||
const index = this.queue.findIndex(
|
||||
(existing) => existing.priority < job.priority
|
||||
);
|
||||
if (index === -1) {
|
||||
this.queue.push(job);
|
||||
} else {
|
||||
this.queue.splice(index, 0, job);
|
||||
}
|
||||
}
|
||||
|
||||
drain() {
|
||||
while (this.running < this.max && this.queue.length > 0) {
|
||||
const job = this.queue.shift();
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
this.running += 1;
|
||||
let finished = false;
|
||||
const finalize = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
this.running -= 1;
|
||||
this.drain();
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = job.task();
|
||||
} catch (err) {
|
||||
job.reject(err);
|
||||
finalize();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve(result)
|
||||
.then((value) => {
|
||||
job.resolve(value);
|
||||
})
|
||||
.catch((err) => {
|
||||
job.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
|
||||
const probeCache = new Map();
|
||||
const probeInflight = new Map();
|
||||
|
||||
function getViewportCenter() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const width = Number(window.innerWidth) || 0;
|
||||
const height = Number(window.innerHeight) || 0;
|
||||
if (width <= 0 && height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: width > 0 ? width / 2 : 0,
|
||||
y: height > 0 ? height / 2 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function prioritizeEntries(entries, viewportCenter) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const filtered = entries
|
||||
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
|
||||
.map((entry) => {
|
||||
const rect = getIntersectionRect(entry);
|
||||
if (!rect) {
|
||||
return null;
|
||||
}
|
||||
const ratio =
|
||||
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const verticalDistance = viewportCenter
|
||||
? Math.abs(centerY - viewportCenter.y)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return { entry, ratio, centerY, verticalDistance };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!viewportCenter) {
|
||||
return filtered
|
||||
.sort((a, b) => {
|
||||
if (b.ratio !== a.ratio) {
|
||||
return b.ratio - a.ratio;
|
||||
}
|
||||
return a.centerY - b.centerY;
|
||||
})
|
||||
.map((item, index) => ({
|
||||
entry: item.entry,
|
||||
priority: PRIORITY_BASELINE - index,
|
||||
}));
|
||||
}
|
||||
|
||||
const ordered = filtered
|
||||
.slice()
|
||||
.sort((a, b) => a.centerY - b.centerY);
|
||||
|
||||
let centerIndex = 0;
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
const candidate = ordered[i];
|
||||
if (candidate.verticalDistance < minDistance) {
|
||||
minDistance = candidate.verticalDistance;
|
||||
centerIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const prioritized = [];
|
||||
const pushCandidate = (candidate) => {
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
prioritized.push(candidate);
|
||||
};
|
||||
|
||||
pushCandidate(ordered[centerIndex]);
|
||||
|
||||
let left = centerIndex - 1;
|
||||
let right = centerIndex + 1;
|
||||
while (left >= 0 || right < ordered.length) {
|
||||
const leftCandidate = left >= 0 ? ordered[left] : null;
|
||||
const rightCandidate = right < ordered.length ? ordered[right] : null;
|
||||
|
||||
if (leftCandidate && rightCandidate) {
|
||||
const leftDistance = leftCandidate.verticalDistance;
|
||||
const rightDistance = rightCandidate.verticalDistance;
|
||||
const distanceDelta = Math.abs(leftDistance - rightDistance);
|
||||
if (distanceDelta <= 0.5) {
|
||||
if (rightCandidate.ratio > leftCandidate.ratio) {
|
||||
pushCandidate(rightCandidate);
|
||||
right += 1;
|
||||
} else {
|
||||
pushCandidate(leftCandidate);
|
||||
left -= 1;
|
||||
}
|
||||
} else if (leftDistance < rightDistance) {
|
||||
pushCandidate(leftCandidate);
|
||||
left -= 1;
|
||||
} else {
|
||||
pushCandidate(rightCandidate);
|
||||
right += 1;
|
||||
}
|
||||
} else if (rightCandidate) {
|
||||
pushCandidate(rightCandidate);
|
||||
right += 1;
|
||||
} else if (leftCandidate) {
|
||||
pushCandidate(leftCandidate);
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return prioritized.map((candidate, index) => ({
|
||||
entry: candidate.entry,
|
||||
priority: PRIORITY_BASELINE - index,
|
||||
}));
|
||||
}
|
||||
|
||||
function getIntersectionRect(entry) {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const rect = entry.intersectionRect;
|
||||
if (rect && rect.width > 0 && rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
const fallback = entry.boundingClientRect;
|
||||
if (fallback && fallback.width > 0 && fallback.height > 0) {
|
||||
return fallback;
|
||||
}
|
||||
return rect || fallback || null;
|
||||
}
|
||||
|
||||
function setBadge(card, state, details) {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedState =
|
||||
typeof state === "string" && state ? state : "unknown";
|
||||
const peersValue =
|
||||
details && Number.isFinite(details.peers)
|
||||
? Math.max(0, Number(details.peers))
|
||||
: 0;
|
||||
const hasPeerCount = details ? Number.isFinite(details.peers) : false;
|
||||
const peersTextValue = hasPeerCount ? String(peersValue) : "";
|
||||
|
||||
card.dataset.streamHealthState = normalizedState;
|
||||
if (hasPeerCount) {
|
||||
card.dataset.streamHealthPeers = peersTextValue;
|
||||
} else if (card.dataset.streamHealthPeers) {
|
||||
delete card.dataset.streamHealthPeers;
|
||||
}
|
||||
|
||||
if (details && typeof details.reason === "string" && details.reason) {
|
||||
card.dataset.streamHealthReason = details.reason;
|
||||
} else if (card.dataset.streamHealthReason) {
|
||||
delete card.dataset.streamHealthReason;
|
||||
}
|
||||
|
||||
const badge = card.querySelector(".torrent-health-badge");
|
||||
if (!badge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hadMargin = badge.classList.contains("mt-3");
|
||||
|
||||
const baseClasses = [
|
||||
"torrent-health-badge",
|
||||
"text-xs",
|
||||
"font-semibold",
|
||||
"px-2",
|
||||
"py-1",
|
||||
"rounded",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"gap-1",
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
];
|
||||
if (hadMargin) {
|
||||
baseClasses.unshift("mt-3");
|
||||
}
|
||||
badge.className = baseClasses.join(" ");
|
||||
|
||||
const map = {
|
||||
healthy: {
|
||||
icon: "🟢",
|
||||
aria: "WebTorrent peers available",
|
||||
classes: ["bg-green-900", "text-green-200"],
|
||||
role: "status",
|
||||
},
|
||||
unhealthy: {
|
||||
icon: "🔴",
|
||||
aria: "WebTorrent peers unavailable",
|
||||
classes: ["bg-red-900", "text-red-200"],
|
||||
role: "alert",
|
||||
},
|
||||
checking: {
|
||||
icon: "⏳",
|
||||
aria: "Checking WebTorrent peers",
|
||||
classes: ["bg-gray-800", "text-gray-300"],
|
||||
role: "status",
|
||||
},
|
||||
unknown: {
|
||||
icon: "⚪",
|
||||
aria: "WebTorrent status unknown",
|
||||
classes: ["bg-gray-800", "text-gray-300"],
|
||||
role: "status",
|
||||
},
|
||||
};
|
||||
|
||||
const entry = map[normalizedState] || map.unknown;
|
||||
entry.classes.forEach((cls) => badge.classList.add(cls));
|
||||
|
||||
const peersText =
|
||||
normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : "";
|
||||
|
||||
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
|
||||
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
|
||||
const tooltip =
|
||||
normalizedState === "checking" || normalizedState === "unknown"
|
||||
? entry.aria
|
||||
: buildTooltip({
|
||||
peers: peersValue,
|
||||
checkedAt: details?.checkedAt,
|
||||
reason: details?.reason,
|
||||
});
|
||||
badge.setAttribute("aria-label", tooltip);
|
||||
badge.setAttribute("title", tooltip);
|
||||
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
|
||||
badge.setAttribute("role", entry.role);
|
||||
badge.dataset.streamHealthState = normalizedState;
|
||||
if (hasPeerCount) {
|
||||
badge.dataset.streamHealthPeers = peersTextValue;
|
||||
} else if (badge.dataset.streamHealthPeers) {
|
||||
delete badge.dataset.streamHealthPeers;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const magnet = card.dataset.magnet || "";
|
||||
if (!magnet) {
|
||||
setBadge(card, "unhealthy", { reason: "missing-source" });
|
||||
return;
|
||||
}
|
||||
|
||||
const infoHash = infoHashFromMagnet(magnet);
|
||||
if (!infoHash) {
|
||||
setBadge(card, "unhealthy", { reason: "invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(pendingByCard instanceof WeakMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingByCard.has(card)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getCacheEntry(infoHash);
|
||||
if (cached) {
|
||||
const cachedState = cached.healthy
|
||||
? "healthy"
|
||||
: cached.hasProbeTrackers
|
||||
? "unhealthy"
|
||||
: "unknown";
|
||||
setBadge(card, cachedState, cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setBadge(card, "checking");
|
||||
|
||||
const probePromise = queueProbe(magnet, infoHash, priority);
|
||||
|
||||
pendingByCard.set(card, probePromise);
|
||||
|
||||
probePromise
|
||||
.then((result) => {
|
||||
pendingByCard.delete(card);
|
||||
if (!card.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
setBadge(card, "unknown");
|
||||
return;
|
||||
}
|
||||
if (!result.hasProbeTrackers) {
|
||||
setBadge(card, "unknown", result);
|
||||
return;
|
||||
}
|
||||
if (result.healthy) {
|
||||
setBadge(card, "healthy", result);
|
||||
return;
|
||||
}
|
||||
setBadge(card, "unhealthy", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[gridHealth] probe failed", err);
|
||||
pendingByCard.delete(card);
|
||||
if (!card.isConnected) {
|
||||
return;
|
||||
}
|
||||
setBadge(card, "unhealthy");
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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" });
|
||||
}
|
||||
},
|
||||
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 attachHealthBadges(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
gridCardObserver.observe(container);
|
||||
}
|
||||
|
||||
export function refreshHealthBadges(container) {
|
||||
gridCardObserver.refresh(container);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import PQueue from "https://esm.sh/[email protected]";
|
||||
import { trackerPing } from "./trackerPing.js";
|
||||
import { infoHashFromMagnet } from "./magnets.js";
|
||||
import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js";
|
||||
|
||||
const queue = new PQueue({ concurrency: CONCURRENCY });
|
||||
const cache = new Map();
|
||||
const inflight = new Map();
|
||||
|
||||
export function getDefaultHealth() {
|
||||
return {
|
||||
ok: false,
|
||||
seeders: 0,
|
||||
leechers: 0,
|
||||
responded: false,
|
||||
from: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getHealthCached(infoHash) {
|
||||
if (!infoHash) {
|
||||
return null;
|
||||
}
|
||||
const entry = cache.get(infoHash);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() - entry.ts > HEALTH_TTL_MS) {
|
||||
cache.delete(infoHash);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
export function setHealthCache(infoHash, value) {
|
||||
if (!infoHash) {
|
||||
return;
|
||||
}
|
||||
cache.set(infoHash, { ts: Date.now(), value });
|
||||
}
|
||||
|
||||
export function queueHealthCheck(magnet, onResult) {
|
||||
const infoHash = infoHashFromMagnet(magnet);
|
||||
if (!infoHash) {
|
||||
const fallback = getDefaultHealth();
|
||||
if (typeof onResult === "function") {
|
||||
onResult(fallback);
|
||||
}
|
||||
return Promise.resolve(fallback);
|
||||
}
|
||||
|
||||
const cached = getHealthCached(infoHash);
|
||||
if (cached) {
|
||||
if (typeof onResult === "function") {
|
||||
onResult(cached);
|
||||
}
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
if (inflight.has(infoHash)) {
|
||||
const pending = inflight.get(infoHash);
|
||||
if (typeof onResult === "function") {
|
||||
pending.then(onResult).catch(() => {});
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
const jobPromise = queue
|
||||
.add(async () => {
|
||||
try {
|
||||
const health = await trackerPing(magnet);
|
||||
setHealthCache(infoHash, health);
|
||||
return health;
|
||||
} catch (err) {
|
||||
console.warn("trackerPing failed", err);
|
||||
return getDefaultHealth();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(infoHash);
|
||||
});
|
||||
|
||||
inflight.set(infoHash, jobPromise);
|
||||
if (typeof onResult === "function") {
|
||||
jobPromise.then(onResult).catch(() => {});
|
||||
}
|
||||
return jobPromise;
|
||||
}
|
||||
|
||||
export function purgeHealthCache() {
|
||||
const now = Date.now();
|
||||
Array.from(cache.keys()).forEach((infoHash) => {
|
||||
const entry = cache.get(infoHash);
|
||||
if (!entry || now - entry.ts > HEALTH_TTL_MS) {
|
||||
cache.delete(infoHash);
|
||||
}
|
||||
});
|
||||
}
|
||||
+1962
File diff suppressed because it is too large.
Load diff
+118
-26
@@ -1,5 +1,64 @@
|
||||
// js/index.js
|
||||
|
||||
import "./bufferPolyfill.js";
|
||||
import { trackPageView } from "./analytics.js";
|
||||
|
||||
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
|
||||
|
||||
//
|
||||
// Centralized animation cleanup
|
||||
// ------------------------------
|
||||
// We attach a single capture-phase listener for animation events so that any
|
||||
// chrome element or thumbnail using our fade-in helpers can automatically shed
|
||||
// the temporary classes/data attributes that trigger the transition. This is
|
||||
// critical because these DOM nodes frequently persist while the surrounding
|
||||
// lists re-render; if the classes stick around, future layout shuffles would
|
||||
// replay the fade and make the UI flicker. Clearing the hooks immediately after
|
||||
// the first animation keeps the graceful entrance without introducing future
|
||||
// flashes.
|
||||
const handleFadeInAnimationComplete = (event) => {
|
||||
const { animationName, target } = event;
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationName === INTERFACE_FADE_IN_ANIMATION) {
|
||||
if (!target.classList.contains("fade-in")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the transient fade-in class so future renders keep the element
|
||||
// visible. The delays piggyback on `fade-in`, so we strip those too.
|
||||
target.classList.remove("fade-in");
|
||||
Array.from(target.classList).forEach((className) => {
|
||||
if (className.startsWith("fade-in-delay-")) {
|
||||
target.classList.remove(className);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationName !== VIDEO_THUMBNAIL_FADE_IN_ANIMATION) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
target.dataset &&
|
||||
target.dataset.videoThumbnail === "true" &&
|
||||
target.dataset.thumbnailLoaded === "true"
|
||||
) {
|
||||
// Thumbnails use the same pattern: once the fade finishes we clear the flag
|
||||
// so a later `load` event (or template reuse) does not restart the
|
||||
// animation.
|
||||
delete target.dataset.thumbnailLoaded;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("animationend", handleFadeInAnimationComplete, true);
|
||||
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
|
||||
|
||||
// 1) Load modals (login, application, etc.)
|
||||
async function loadModal(url) {
|
||||
try {
|
||||
@@ -8,9 +67,14 @@ async function loadModal(url) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
|
||||
const sanitizedHtml = html.replace(
|
||||
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
|
||||
""
|
||||
);
|
||||
document
|
||||
.getElementById("modalContainer")
|
||||
.insertAdjacentHTML("beforeend", html);
|
||||
.insertAdjacentHTML("beforeend", sanitizedHtml);
|
||||
console.log(url, "loaded");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -171,6 +235,12 @@ Promise.all([
|
||||
|
||||
// Also run once on initial load
|
||||
handleHashChange();
|
||||
|
||||
return import("./disclaimer.js");
|
||||
})
|
||||
.then(({ default: disclaimerModal }) => {
|
||||
disclaimerModal.init();
|
||||
disclaimerModal.show();
|
||||
});
|
||||
|
||||
/* -------------------------------------------
|
||||
@@ -187,6 +257,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();
|
||||
}
|
||||
|
||||
@@ -292,40 +366,58 @@ function handleQueryParams() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle #view=... in the hash and load the correct partial view.
|
||||
*/
|
||||
function handleHashChange() {
|
||||
async function waitForAppInitialization() {
|
||||
const maybePromise = window.appReady;
|
||||
|
||||
if (!maybePromise || typeof maybePromise.then !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await maybePromise;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Proceeding with hash handling despite app initialization failure:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHashChange() {
|
||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||
|
||||
await waitForAppInitialization();
|
||||
|
||||
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 {
|
||||
const { loadView, viewInitRegistry } = await import("./viewManager.js");
|
||||
|
||||
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 (!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") {
|
||||
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") {
|
||||
initFn();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle hash change:", error);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+6
-21
@@ -1,25 +1,10 @@
|
||||
// js/lists.js
|
||||
|
||||
// Whitelist of npubs that can access the video upload functions
|
||||
const npubs = [
|
||||
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid
|
||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN
|
||||
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network
|
||||
"npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", // Chain Reaction
|
||||
"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx", // ODELL
|
||||
"npub1qxduthz4p8v5zsux524df569pt7lj0d36dyqadnta2val64dtnhsr50h64", // NosToons
|
||||
"npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney
|
||||
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
|
||||
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
|
||||
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
|
||||
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
|
||||
];
|
||||
|
||||
console.log("DEBUG: lists.js loaded, npubs:", npubs);
|
||||
|
||||
// Blacklist of npubs that events will not be displayed in the bitvid official client
|
||||
export const initialWhitelist = npubs;
|
||||
export const initialBlacklist = [""];
|
||||
// Operators must manage admin lists through the Admin panel UI or remote
|
||||
// adapters. These legacy constants are intentionally empty so new installs do
|
||||
// not ship with hard-coded accounts.
|
||||
export const ADMIN_INITIAL_WHITELIST = [];
|
||||
export const ADMIN_INITIAL_BLACKLIST = [];
|
||||
|
||||
// Block specific events with the nevent
|
||||
export const initialEventBlacklist = [""];
|
||||
export const ADMIN_INITIAL_EVENT_BLACKLIST = [];
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
buildMagnetUri,
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
formatAbsoluteUrl,
|
||||
normalizeMagnetInput,
|
||||
resolveAppProtocol,
|
||||
extractMagnetHints as sharedExtractMagnetHints,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
export const extractMagnetHints = sharedExtractMagnetHints;
|
||||
|
||||
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!isMagnet) {
|
||||
return canonicalValue;
|
||||
}
|
||||
|
||||
ensureTrackers(params);
|
||||
|
||||
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
|
||||
if (normalizedXs) {
|
||||
ensureTorrentHint(params, normalizedXs, { requireHttp: false });
|
||||
}
|
||||
|
||||
const rawWs = typeof ws === "string" ? ws.trim() : "";
|
||||
if (rawWs) {
|
||||
const formattedWs = formatAbsoluteUrl(rawWs);
|
||||
const allowHttpSeed = resolveAppProtocol() === "http:";
|
||||
ensureWebSeeds(params, formattedWs, {
|
||||
allowHttp: allowHttpSeed,
|
||||
allowUnparsed: true,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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;
|
||||
|
||||
export function normalizeAndAugmentMagnet(
|
||||
rawValue,
|
||||
{
|
||||
webSeed,
|
||||
torrentUrl,
|
||||
xs,
|
||||
extraTrackers = [],
|
||||
logger,
|
||||
appProtocol,
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return { magnet: "", didChange: false };
|
||||
}
|
||||
|
||||
if (!isMagnet) {
|
||||
const magnet = canonicalValue;
|
||||
return {
|
||||
magnet,
|
||||
didChange: didMutate || magnet !== initial,
|
||||
};
|
||||
}
|
||||
|
||||
let didChange = didMutate;
|
||||
|
||||
if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
|
||||
? torrentUrl
|
||||
: typeof xs === "string"
|
||||
? xs.trim()
|
||||
: "";
|
||||
|
||||
if (torrentHint) {
|
||||
if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) {
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
const seedInputs = Array.isArray(webSeed)
|
||||
? webSeed
|
||||
: typeof webSeed === "string"
|
||||
? [webSeed]
|
||||
: [];
|
||||
|
||||
const resolvedProtocol = resolveAppProtocol(appProtocol);
|
||||
|
||||
if (
|
||||
ensureWebSeeds(params, seedInputs, {
|
||||
allowHttp: resolvedProtocol === "http:",
|
||||
allowUnparsed: false,
|
||||
logger,
|
||||
})
|
||||
) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment);
|
||||
|
||||
return {
|
||||
magnet: finalMagnet,
|
||||
didChange: didChange || finalMagnet !== initial,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import parseMagnet from "https://esm.sh/[email protected]";
|
||||
|
||||
function normalizeInfoHash(candidate) {
|
||||
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function infoHashFromMagnet(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return null;
|
||||
}
|
||||
const direct = normalizeInfoHash(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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+4924
-122
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,780 @@
|
||||
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",
|
||||
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.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 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,102 @@
|
||||
// js/playbackUtils.js
|
||||
|
||||
import {
|
||||
normalizeAndAugmentMagnet,
|
||||
safeDecodeMagnet,
|
||||
} from "./magnetUtils.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const MAGNET_URI = /^magnet:\?/i;
|
||||
|
||||
/**
|
||||
* Normalizes torrent related playback inputs into a canonical magnet payload.
|
||||
*
|
||||
* The function first trims and safely decodes the incoming `magnet` string so
|
||||
* that URL encoded magnets become plain text before processing. Bare info hash
|
||||
* strings are tolerated: whenever the caller supplies an info hash (either via
|
||||
* the `infoHash` field or a magnet that looks like one) it gets promoted to a
|
||||
* full magnet URI so downstream WebTorrent code can consume it directly.
|
||||
*
|
||||
* When the normalized output differs from the original magnet input (for
|
||||
* example due to whitespace/encoding fixes or tracker augmentation) the
|
||||
* original candidate is exposed through `fallbackMagnet`. Callers can surface
|
||||
* that value if a later refactor breaks magnet normalization.
|
||||
*
|
||||
* The returned flags communicate provenance: `provided` indicates that some
|
||||
* torrent-related input was supplied, while `usedInfoHash` is only `true` when
|
||||
* the normalized magnet was derived from an info hash instead of an already
|
||||
* well-formed magnet URI.
|
||||
*
|
||||
* Web seeds coming from the `url` parameter are forwarded to
|
||||
* `normalizeAndAugmentMagnet` via the `webSeed` option. Refactors must preserve
|
||||
* that flow (even if the parameter name changes) so hosted URLs continue to
|
||||
* populate `ws=` hints automatically.
|
||||
*/
|
||||
export function deriveTorrentPlaybackConfig({
|
||||
magnet = "",
|
||||
infoHash = "",
|
||||
url = "",
|
||||
logger,
|
||||
appProtocol,
|
||||
} = {}) {
|
||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||||
const magnetCandidate = decodedMagnet || trimmedMagnet;
|
||||
const trimmedInfoHash =
|
||||
typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
|
||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
|
||||
const magnetIsUri = MAGNET_URI.test(magnetCandidate);
|
||||
const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(magnetCandidate);
|
||||
const resolvedInfoHash = trimmedInfoHash || (magnetLooksLikeInfoHash
|
||||
? magnetCandidate.toLowerCase()
|
||||
: "");
|
||||
|
||||
const normalizationInput = magnetIsUri ? magnetCandidate : resolvedInfoHash;
|
||||
const provided = Boolean(trimmedMagnet || trimmedInfoHash);
|
||||
const decodeChanged = magnetCandidate !== trimmedMagnet;
|
||||
|
||||
if (!normalizationInput) {
|
||||
return {
|
||||
magnet: "",
|
||||
fallbackMagnet: "",
|
||||
provided,
|
||||
usedInfoHash: false,
|
||||
originalInput: "",
|
||||
didMutate: false,
|
||||
infoHash: resolvedInfoHash,
|
||||
};
|
||||
}
|
||||
|
||||
const normalization = normalizeAndAugmentMagnet(normalizationInput, {
|
||||
webSeed: sanitizedUrl ? [sanitizedUrl] : [],
|
||||
logger,
|
||||
appProtocol,
|
||||
});
|
||||
|
||||
let normalizedMagnet = normalization.magnet;
|
||||
if (!normalizedMagnet || !MAGNET_URI.test(normalizedMagnet)) {
|
||||
if (magnetIsUri) {
|
||||
normalizedMagnet = normalizationInput;
|
||||
} else if (resolvedInfoHash) {
|
||||
normalizedMagnet = `magnet:?xt=urn:btih:${resolvedInfoHash}`;
|
||||
} else {
|
||||
normalizedMagnet = "";
|
||||
}
|
||||
}
|
||||
|
||||
const usedInfoHash = !magnetIsUri && Boolean(resolvedInfoHash);
|
||||
const fallbackMagnet = magnetIsUri && normalization.didChange
|
||||
? normalizationInput
|
||||
: "";
|
||||
|
||||
return {
|
||||
magnet: normalizedMagnet,
|
||||
fallbackMagnet,
|
||||
provided,
|
||||
usedInfoHash,
|
||||
originalInput: normalizationInput,
|
||||
didMutate: normalization.didChange || usedInfoHash || decodeChanged,
|
||||
infoHash: resolvedInfoHash,
|
||||
};
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
import { buildR2Key, buildPublicUrl } from "./r2.js";
|
||||
import {
|
||||
sanitizeBucketName,
|
||||
ensureBucket,
|
||||
putCors,
|
||||
attachCustomDomainAndWait,
|
||||
setManagedDomain,
|
||||
} from "./storage/r2-mgmt.js";
|
||||
import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
|
||||
|
||||
const STORAGE_KEY = "bitvid:quickR2Settings";
|
||||
const STATUS_CLASSNAMES = [
|
||||
"text-gray-400",
|
||||
"text-green-400",
|
||||
"text-red-400",
|
||||
"text-yellow-400",
|
||||
];
|
||||
const STATUS_TONES = {
|
||||
info: "text-gray-400",
|
||||
success: "text-green-400",
|
||||
error: "text-red-400",
|
||||
warning: "text-yellow-400",
|
||||
};
|
||||
|
||||
function sanitizeDomain(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
function loadSavedSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.warn("Failed to read quick R2 settings:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.warn("Failed to store quick R2 settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedSettings() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear quick R2 settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, message, tone = "info") {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
STATUS_CLASSNAMES.forEach((cls) => el.classList.remove(cls));
|
||||
const className = STATUS_TONES[tone] || STATUS_TONES.info;
|
||||
if (className) {
|
||||
el.classList.add(className);
|
||||
}
|
||||
el.textContent = message || "";
|
||||
}
|
||||
|
||||
function setUploadingState(elements, uploading) {
|
||||
const disabled = Boolean(uploading);
|
||||
elements.forEach((el) => {
|
||||
if (el) {
|
||||
el.disabled = disabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function 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://")) {
|
||||
origins.add(origin.replace("http://", "https://"));
|
||||
}
|
||||
}
|
||||
origins.add("http://localhost:8000");
|
||||
origins.add("http://127.0.0.1:8000");
|
||||
return Array.from(origins);
|
||||
}
|
||||
|
||||
function buildQuickKey(title, file, npub) {
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const baseName = (title || file?.name || "video")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safeBase = baseName || "video";
|
||||
const originalName = typeof file?.name === "string" ? file.name : "";
|
||||
const extMatch = originalName.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : "mp4";
|
||||
const sanitizedNpub = String(npub || "user").replace(/[^a-z0-9]/gi, "");
|
||||
return `videos/${datePart}/${sanitizedNpub || "user"}-${safeBase}.${ext}`;
|
||||
}
|
||||
|
||||
export function initQuickR2Upload(app) {
|
||||
const section = document.getElementById("quickR2Section");
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = {
|
||||
accountId: document.getElementById("quickR2AccountId"),
|
||||
accessKeyId: document.getElementById("quickR2AccessKeyId"),
|
||||
secretAccessKey: document.getElementById("quickR2SecretAccessKey"),
|
||||
apiToken: document.getElementById("quickR2ApiToken"),
|
||||
customDomain: document.getElementById("quickR2CustomDomain"),
|
||||
zoneId: document.getElementById("quickR2ZoneId"),
|
||||
remember: document.getElementById("quickR2Remember"),
|
||||
allowManaged: document.getElementById("quickR2AllowManaged"),
|
||||
file: document.getElementById("quickR2File"),
|
||||
uploadButton: document.getElementById("quickR2UploadButton"),
|
||||
status: document.getElementById("quickR2Status"),
|
||||
hostedUrl: document.getElementById("uploadUrl"),
|
||||
titleInput: document.getElementById("uploadTitle"),
|
||||
form: document.getElementById("uploadForm"),
|
||||
};
|
||||
|
||||
const saved = loadSavedSettings();
|
||||
if (saved && saved.remember) {
|
||||
if (elements.accountId) elements.accountId.value = saved.accountId || "";
|
||||
if (elements.accessKeyId)
|
||||
elements.accessKeyId.value = saved.accessKeyId || "";
|
||||
if (elements.secretAccessKey)
|
||||
elements.secretAccessKey.value = saved.secretAccessKey || "";
|
||||
if (elements.apiToken) elements.apiToken.value = saved.apiToken || "";
|
||||
if (elements.customDomain)
|
||||
elements.customDomain.value = saved.customDomain || "";
|
||||
if (elements.zoneId) elements.zoneId.value = saved.zoneId || "";
|
||||
if (elements.allowManaged)
|
||||
elements.allowManaged.checked = Boolean(saved.allowManaged ?? true);
|
||||
if (elements.remember) elements.remember.checked = true;
|
||||
}
|
||||
|
||||
function maybePersist() {
|
||||
if (!elements.remember) {
|
||||
return;
|
||||
}
|
||||
if (elements.remember.checked) {
|
||||
persistSettings({
|
||||
remember: true,
|
||||
accountId: elements.accountId?.value?.trim() || "",
|
||||
accessKeyId: elements.accessKeyId?.value?.trim() || "",
|
||||
secretAccessKey: elements.secretAccessKey?.value?.trim() || "",
|
||||
apiToken: elements.apiToken?.value?.trim() || "",
|
||||
customDomain: elements.customDomain?.value?.trim() || "",
|
||||
zoneId: elements.zoneId?.value?.trim() || "",
|
||||
allowManaged: Boolean(elements.allowManaged?.checked ?? true),
|
||||
});
|
||||
} else {
|
||||
clearSavedSettings();
|
||||
}
|
||||
}
|
||||
|
||||
elements.remember?.addEventListener("change", () => {
|
||||
if (!elements.remember.checked) {
|
||||
clearSavedSettings();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpload() {
|
||||
const file = elements.file?.files?.[0] || null;
|
||||
if (!file) {
|
||||
setStatus(elements.status, "Choose a file to upload.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = elements.accountId?.value?.trim();
|
||||
const accessKeyId = elements.accessKeyId?.value?.trim();
|
||||
const secretAccessKey = elements.secretAccessKey?.value?.trim();
|
||||
const apiToken = elements.apiToken?.value?.trim();
|
||||
const customDomain = sanitizeDomain(
|
||||
elements.customDomain?.value || ""
|
||||
);
|
||||
const zoneId = elements.zoneId?.value?.trim();
|
||||
const allowManaged = Boolean(elements.allowManaged?.checked ?? true);
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
setStatus(
|
||||
elements.status,
|
||||
"Account ID and S3 keys are required for Cloudflare R2.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const npub =
|
||||
typeof app?.safeEncodeNpub === "function" ? app.safeEncodeNpub(app.pubkey) : null;
|
||||
if (!npub) {
|
||||
setStatus(
|
||||
elements.status,
|
||||
"Unable to derive your npub. Connect a Nostr key first.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
maybePersist();
|
||||
|
||||
const bucket = sanitizeBucketName(npub);
|
||||
const inputsToDisable = [
|
||||
elements.accountId,
|
||||
elements.accessKeyId,
|
||||
elements.secretAccessKey,
|
||||
elements.apiToken,
|
||||
elements.customDomain,
|
||||
elements.zoneId,
|
||||
elements.remember,
|
||||
elements.allowManaged,
|
||||
elements.file,
|
||||
elements.uploadButton,
|
||||
];
|
||||
|
||||
setUploadingState(inputsToDisable, true);
|
||||
setStatus(elements.status, `Preparing bucket ${bucket}…`, "info");
|
||||
|
||||
try {
|
||||
if (apiToken) {
|
||||
try {
|
||||
await ensureBucket({ accountId, bucket, token: apiToken });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
origins: getCorsOrigins(),
|
||||
});
|
||||
} catch (corsErr) {
|
||||
console.warn("Quick R2 CORS update failed:", corsErr);
|
||||
}
|
||||
}
|
||||
|
||||
let publicBaseUrl = "";
|
||||
let usedManagedFallback = false;
|
||||
|
||||
if (customDomain) {
|
||||
if (apiToken && zoneId) {
|
||||
try {
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Attaching ${customDomain}…`,
|
||||
"info"
|
||||
);
|
||||
const custom = await attachCustomDomainAndWait({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
zoneId,
|
||||
domain: customDomain,
|
||||
pollInterval: 1500,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
if (custom?.active && custom?.url) {
|
||||
publicBaseUrl = custom.url;
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Quick R2 managed domain disable failed:", disableErr);
|
||||
}
|
||||
} else {
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Custom domain pending (${custom?.status || "unknown"}).`,
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Quick R2 custom domain error:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message
|
||||
? `Custom domain setup failed: ${err.message}`
|
||||
: "Custom domain setup failed.",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
publicBaseUrl = `https://${customDomain}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl && apiToken && allowManaged) {
|
||||
try {
|
||||
setStatus(elements.status, "Enabling managed r2.dev domain…", "info");
|
||||
const managed = await setManagedDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token: apiToken,
|
||||
enabled: true,
|
||||
});
|
||||
if (managed?.url) {
|
||||
publicBaseUrl = managed.url;
|
||||
usedManagedFallback = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Quick R2 managed domain enable failed:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message
|
||||
? `Managed domain failed: ${err.message}`
|
||||
: "Managed domain failed.",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
throw new Error(
|
||||
"No public domain configured. Provide a custom domain or allow the managed r2.dev fallback."
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
||||
const title = elements.titleInput?.value?.trim() || "";
|
||||
const key = buildQuickKey(title, file, npub) || buildR2Key(npub, file);
|
||||
const hostedUrl = buildPublicUrl(publicBaseUrl, key);
|
||||
|
||||
let lastPercent = -1;
|
||||
setStatus(elements.status, `Uploading… 0%`, usedManagedFallback ? "warning" : "info");
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
bucket,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type,
|
||||
onProgress: (fraction) => {
|
||||
if (typeof fraction !== "number" || !isFinite(fraction)) {
|
||||
return;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Math.round(fraction * 100)));
|
||||
if (percent !== lastPercent) {
|
||||
lastPercent = percent;
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Uploading… ${percent}%`,
|
||||
usedManagedFallback ? "warning" : "info"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (elements.hostedUrl) {
|
||||
elements.hostedUrl.value = hostedUrl;
|
||||
}
|
||||
|
||||
setStatus(
|
||||
elements.status,
|
||||
`Upload complete. Hosted URL set to ${hostedUrl}.`,
|
||||
"success"
|
||||
);
|
||||
|
||||
if (elements.form && typeof elements.form.requestSubmit === "function") {
|
||||
elements.form.requestSubmit();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Quick R2 upload failed:", err);
|
||||
setStatus(
|
||||
elements.status,
|
||||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setUploadingState(inputsToDisable, false);
|
||||
}
|
||||
}
|
||||
|
||||
elements.uploadButton?.addEventListener("click", () => {
|
||||
handleUpload();
|
||||
});
|
||||
}
|
||||
|
||||
export default initQuickR2Upload;
|
||||
@@ -0,0 +1,258 @@
|
||||
const DB_NAME = "bitvidSettings";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "kv";
|
||||
const SETTINGS_KEY = "r2Settings";
|
||||
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings";
|
||||
|
||||
function sanitizeBaseDomain(domain) {
|
||||
if (!domain) {
|
||||
return "";
|
||||
}
|
||||
let value = String(domain).trim().toLowerCase();
|
||||
value = value.replace(/^https?:\/\//, "");
|
||||
value = value.replace(/\/.*$/, "");
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeBucketEntry(entry) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const bucket = String(entry.bucket || "").toLowerCase();
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
const publicBaseUrl = String(entry.publicBaseUrl || "");
|
||||
const domainType = entry.domainType === "custom" ? "custom" : "managed";
|
||||
const lastUpdated = Number.isFinite(entry.lastUpdated)
|
||||
? entry.lastUpdated
|
||||
: Date.now();
|
||||
return { bucket, publicBaseUrl, domainType, lastUpdated };
|
||||
}
|
||||
|
||||
function normalizeSettings(raw) {
|
||||
const base = raw && typeof raw === "object" ? raw : {};
|
||||
const buckets = {};
|
||||
if (base.buckets && typeof base.buckets === "object") {
|
||||
for (const [npub, value] of Object.entries(base.buckets)) {
|
||||
const normalizedEntry = normalizeBucketEntry(value);
|
||||
if (normalizedEntry) {
|
||||
buckets[String(npub)] = normalizedEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: String(base.accountId || ""),
|
||||
accessKeyId: String(base.accessKeyId || ""),
|
||||
secretAccessKey: String(base.secretAccessKey || ""),
|
||||
apiToken: String(base.apiToken || ""),
|
||||
zoneId: String(base.zoneId || ""),
|
||||
baseDomain: sanitizeBaseDomain(base.baseDomain || ""),
|
||||
buckets,
|
||||
};
|
||||
}
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (err) {
|
||||
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"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadR2Settings() {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(SETTINGS_KEY);
|
||||
req.onsuccess = () => {
|
||||
resolve(normalizeSettings(req.result));
|
||||
};
|
||||
req.onerror = () => {
|
||||
reject(req.error || new Error("Failed to load settings"));
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to open IndexedDB for settings, falling back:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
if (raw) {
|
||||
return normalizeSettings(JSON.parse(raw));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to read fallback settings:", err);
|
||||
}
|
||||
|
||||
return normalizeSettings(null);
|
||||
}
|
||||
|
||||
export async function saveR2Settings(settings) {
|
||||
const normalized = normalizeSettings(settings);
|
||||
|
||||
let db = null;
|
||||
try {
|
||||
db = await openSettingsDb();
|
||||
} catch (err) {
|
||||
console.warn("Unable to open IndexedDB, continuing with fallback:", err);
|
||||
}
|
||||
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(normalized, SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () =>
|
||||
reject(req.error || new Error("Failed to save R2 settings"));
|
||||
});
|
||||
} else if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE_FALLBACK_KEY,
|
||||
JSON.stringify(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function clearR2Settings() {
|
||||
let cleared = false;
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error || new Error("Failed to clear"));
|
||||
});
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear IndexedDB settings:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear fallback settings:", err);
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
export function mergeBucketEntry(settings, npub, entry) {
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return settings;
|
||||
}
|
||||
const normalizedEntry = normalizeBucketEntry(entry);
|
||||
if (!normalizedEntry) {
|
||||
return settings;
|
||||
}
|
||||
return {
|
||||
...settings,
|
||||
buckets: {
|
||||
...(settings.buckets || {}),
|
||||
[npub]: normalizedEntry,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function guessExtension(file) {
|
||||
if (!file) {
|
||||
return "mp4";
|
||||
}
|
||||
|
||||
const name = typeof file.name === "string" ? file.name : "";
|
||||
const dotIndex = name.lastIndexOf(".");
|
||||
if (dotIndex > -1 && dotIndex < name.length - 1) {
|
||||
return name.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const type = typeof file.type === "string" ? file.type : "";
|
||||
switch (type) {
|
||||
case "video/webm":
|
||||
return "webm";
|
||||
case "application/vnd.apple.mpegurl":
|
||||
return "m3u8";
|
||||
case "video/mp2t":
|
||||
return "ts";
|
||||
case "video/quicktime":
|
||||
return "mov";
|
||||
case "video/x-matroska":
|
||||
return "mkv";
|
||||
default:
|
||||
return "mp4";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildR2Key(npub, file) {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const safeNpub = String(npub || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
const baseName = typeof file?.name === "string" ? file.name : "video";
|
||||
const withoutExt = baseName.replace(/\.[^/.]+$/, "");
|
||||
const slug = withoutExt
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safeSlug = slug || "video";
|
||||
const ext = guessExtension(file);
|
||||
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
|
||||
}
|
||||
|
||||
export function buildPublicUrl(baseUrl, key) {
|
||||
if (!baseUrl) {
|
||||
return "";
|
||||
}
|
||||
const sanitizedBase = String(baseUrl).replace(/\/$/, "");
|
||||
const encodedKey = key
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
return `${sanitizedBase}/${encodedKey}`;
|
||||
}
|
||||
|
||||
export { sanitizeBaseDomain };
|
||||
@@ -0,0 +1,565 @@
|
||||
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"];
|
||||
|
||||
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 events = [];
|
||||
|
||||
await Promise.all(
|
||||
targetRelays.map(async (relayUrl) => {
|
||||
try {
|
||||
const result = await nostrClient.pool.list([relayUrl], [filter]);
|
||||
if (Array.isArray(result) && result.length) {
|
||||
events.push(...result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[relayManager] Failed to fetch relay list from ${relayUrl}:`, error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!events.length) {
|
||||
this.lastEvent = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", events: [] };
|
||||
}
|
||||
|
||||
events.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
const newest = events[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 };
|
||||
}
|
||||
|
||||
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,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,293 @@
|
||||
// js/storage/r2-mgmt.js
|
||||
function computeSlugHash(input) {
|
||||
const text = String(input || "");
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash = Math.imul(31, hash) + text.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function sanitizeBucketName(npub) {
|
||||
const raw = String(npub || "");
|
||||
let value = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|[-]+$/g, "");
|
||||
|
||||
if (!value) {
|
||||
value = "bitvid-user";
|
||||
}
|
||||
|
||||
if (value.length > 32) {
|
||||
const prefix = value.slice(0, 20).replace(/-+$/g, "");
|
||||
const hash = computeSlugHash(raw).slice(0, 8);
|
||||
value = [prefix, hash].filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
value = value.replace(/-+/g, "-").replace(/^-+|[-]+$/g, "");
|
||||
|
||||
if (!/^[a-z0-9]/.test(value)) {
|
||||
value = `bv-${value}`;
|
||||
}
|
||||
|
||||
if (!/[a-z0-9]$/.test(value)) {
|
||||
value = `${value}0`;
|
||||
}
|
||||
|
||||
if (value.length > 63) {
|
||||
value = value.slice(0, 63).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
while (value.length < 3) {
|
||||
value += "0";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function deriveShortSubdomain(npub) {
|
||||
const bucketSlug = sanitizeBucketName(npub);
|
||||
const hash = computeSlugHash(npub).slice(0, 6);
|
||||
|
||||
const prefix = bucketSlug
|
||||
.replace(/^bv-/, "")
|
||||
.slice(0, 18)
|
||||
.replace(/-+$/g, "");
|
||||
|
||||
let candidate = [prefix, hash].filter(Boolean).join("-");
|
||||
candidate = candidate
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|[-]+$/g, "");
|
||||
|
||||
if (!candidate) {
|
||||
candidate = "user";
|
||||
}
|
||||
|
||||
if (candidate.length > 32) {
|
||||
candidate = candidate.slice(0, 32).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9]/.test(candidate)) {
|
||||
candidate = `u${candidate}`;
|
||||
}
|
||||
|
||||
if (!/[a-z0-9]$/.test(candidate)) {
|
||||
candidate = `${candidate}0`;
|
||||
}
|
||||
|
||||
while (candidate.length < 3) {
|
||||
candidate += "0";
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function buildError(error, fallbackMessage) {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
const err = new Error(fallbackMessage || "Cloudflare request failed");
|
||||
err.original = error;
|
||||
return err;
|
||||
}
|
||||
|
||||
// --- Cloudflare API helpers (Bearer token) ---
|
||||
async function cfFetch(path, { token, method = "GET", body, headers = {} }) {
|
||||
if (!token) {
|
||||
throw new Error("Cloudflare API token is required");
|
||||
}
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (parseErr) {
|
||||
if (!res.ok) {
|
||||
const err = new Error(text || `Cloudflare API error ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
throw parseErr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok || data?.success === false) {
|
||||
const errorMessage =
|
||||
data?.errors?.[0]?.message ||
|
||||
data?.message ||
|
||||
text ||
|
||||
`Cloudflare API error ${res.status}`;
|
||||
const err = new Error(errorMessage);
|
||||
err.status = res.status;
|
||||
err.response = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 1) Create bucket (idempotent-ish: treat 409 as success)
|
||||
export async function ensureBucket({ accountId, bucket, token }) {
|
||||
try {
|
||||
await cfFetch(`/accounts/${accountId}/r2/buckets`, {
|
||||
token,
|
||||
method: "POST",
|
||||
body: { name: bucket },
|
||||
});
|
||||
} catch (error) {
|
||||
const err = buildError(error);
|
||||
if (err.status === 409 || /already exists/i.test(err.message || "")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Set CORS so browser PUT/GETs work from your app origins
|
||||
export async function putCors({ accountId, bucket, token, origins }) {
|
||||
const filteredOrigins = (origins || []).filter(Boolean);
|
||||
if (filteredOrigins.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rules = [
|
||||
{
|
||||
id: "bitvid-default",
|
||||
allowed: {
|
||||
origins: filteredOrigins,
|
||||
methods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
|
||||
headers: ["*"],
|
||||
},
|
||||
expose_headers: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
|
||||
max_age_seconds: 3600,
|
||||
},
|
||||
];
|
||||
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
|
||||
token,
|
||||
method: "PUT",
|
||||
body: { rules },
|
||||
});
|
||||
}
|
||||
|
||||
function extractStatus(payload) {
|
||||
return (
|
||||
payload?.result?.status ||
|
||||
payload?.result?.result?.status ||
|
||||
payload?.status ||
|
||||
payload?.result?.statusText ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function pollCustomDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token,
|
||||
domain,
|
||||
pollInterval = 2500,
|
||||
timeoutMs = 120000,
|
||||
}) {
|
||||
const path = `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(
|
||||
domain
|
||||
)}`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastPayload = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
lastPayload = await cfFetch(path, { token, method: "GET" }).catch((err) => {
|
||||
if (err?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!lastPayload) {
|
||||
break;
|
||||
}
|
||||
|
||||
const status = extractStatus(lastPayload);
|
||||
if (status === "active") {
|
||||
return { status, payload: lastPayload };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return {
|
||||
status: extractStatus(lastPayload) || "unknown",
|
||||
payload: lastPayload,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
|
||||
export async function attachCustomDomainAndWait({
|
||||
accountId,
|
||||
bucket,
|
||||
token,
|
||||
zoneId,
|
||||
domain,
|
||||
pollInterval,
|
||||
timeoutMs,
|
||||
}) {
|
||||
let shouldPoll = true;
|
||||
try {
|
||||
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, {
|
||||
token,
|
||||
method: "POST",
|
||||
body: { domain, zoneId, enabled: true },
|
||||
});
|
||||
} catch (error) {
|
||||
const err = buildError(error);
|
||||
if (err.status === 409 || /already exists/i.test(err.message || "")) {
|
||||
shouldPoll = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let status = "unknown";
|
||||
if (shouldPoll) {
|
||||
const pollResult = await pollCustomDomain({
|
||||
accountId,
|
||||
bucket,
|
||||
token,
|
||||
domain,
|
||||
pollInterval,
|
||||
timeoutMs,
|
||||
});
|
||||
status = pollResult.status;
|
||||
}
|
||||
|
||||
const url = `https://${domain}`;
|
||||
return {
|
||||
url,
|
||||
status,
|
||||
active: status === "active",
|
||||
};
|
||||
}
|
||||
|
||||
// 4) Toggle managed r2.dev domain for the bucket
|
||||
export async function setManagedDomain({ accountId, bucket, token, enabled }) {
|
||||
const { result } = await cfFetch(
|
||||
`/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
|
||||
{ token, method: "PUT", body: { enabled: Boolean(enabled) } }
|
||||
);
|
||||
const domain = result?.domain || result?.result?.domain || "";
|
||||
return {
|
||||
enabled: Boolean(result?.enabled ?? enabled),
|
||||
url: domain ? `https://${domain}` : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// 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";
|
||||
|
||||
function computeCacheControl(key) {
|
||||
if (!key) {
|
||||
return "public, max-age=3600";
|
||||
}
|
||||
|
||||
const lower = String(key).toLowerCase();
|
||||
if (/(?:\.)(m3u8|mpd)$/.test(lower)) {
|
||||
return "public, max-age=60, must-revalidate";
|
||||
}
|
||||
|
||||
if (
|
||||
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=3600";
|
||||
}
|
||||
|
||||
export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error("Missing Cloudflare R2 credentials");
|
||||
}
|
||||
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBucketExists({ s3, bucket }) {
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
return;
|
||||
} catch (error) {
|
||||
const status = error?.$metadata?.httpStatusCode || 0;
|
||||
const code = error?.name || error?.Code || "";
|
||||
if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
} catch (error) {
|
||||
const status = error?.$metadata?.httpStatusCode || 0;
|
||||
const code = error?.name || error?.Code || "";
|
||||
if (
|
||||
status === 409 ||
|
||||
code === "BucketAlreadyOwnedByYou" ||
|
||||
code === "BucketAlreadyExists"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBucketCors({ s3, bucket, origins }) {
|
||||
if (!s3) {
|
||||
throw new Error("S3 client is required to configure CORS");
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error("Bucket name is required to configure CORS");
|
||||
}
|
||||
|
||||
const allowedOrigins = (origins || []).filter(Boolean);
|
||||
if (allowedOrigins.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureBucketExists({ s3, bucket });
|
||||
|
||||
const command = new PutBucketCorsCommand({
|
||||
Bucket: bucket,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedHeaders: ["*"],
|
||||
AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
|
||||
AllowedOrigins: allowedOrigins,
|
||||
ExposeHeaders: [
|
||||
"ETag",
|
||||
"Content-Length",
|
||||
"Content-Range",
|
||||
"Accept-Ranges",
|
||||
],
|
||||
MaxAgeSeconds: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
}
|
||||
|
||||
export async function multipartUpload({
|
||||
s3,
|
||||
bucket,
|
||||
key,
|
||||
file,
|
||||
contentType,
|
||||
onProgress,
|
||||
}) {
|
||||
if (!s3) {
|
||||
throw new Error("S3 client is required");
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error("Bucket is required");
|
||||
}
|
||||
if (!key) {
|
||||
throw new Error("Object key is required");
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error("File is required");
|
||||
}
|
||||
|
||||
const createCommand = new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType || file.type || "application/octet-stream",
|
||||
CacheControl: computeCacheControl(key),
|
||||
});
|
||||
|
||||
createCommand.middlewareStack.add(
|
||||
(next) => async (args) => {
|
||||
if (args?.request?.headers) {
|
||||
args.request.headers["cf-create-bucket-if-missing"] = "true";
|
||||
}
|
||||
return next(args);
|
||||
},
|
||||
{ step: "build" }
|
||||
);
|
||||
|
||||
const { UploadId } = await s3.send(createCommand);
|
||||
|
||||
if (!UploadId) {
|
||||
throw new Error("Failed to start multipart upload");
|
||||
}
|
||||
|
||||
const MIN_PART = 5 * 1024 * 1024;
|
||||
const maxParts = 10000;
|
||||
const calculated = Math.ceil(file.size / maxParts);
|
||||
const partSize = Math.max(MIN_PART, calculated);
|
||||
const total = file.size;
|
||||
const parts = [];
|
||||
let uploadedBytes = 0;
|
||||
let partNumber = 1;
|
||||
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(0);
|
||||
}
|
||||
|
||||
try {
|
||||
while (uploadedBytes < total) {
|
||||
const start = uploadedBytes;
|
||||
const end = Math.min(start + partSize, total);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const { ETag } = await s3.send(
|
||||
new UploadPartCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: chunk,
|
||||
})
|
||||
);
|
||||
|
||||
parts.push({ ETag, PartNumber: partNumber });
|
||||
uploadedBytes = end;
|
||||
partNumber += 1;
|
||||
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(uploadedBytes / total);
|
||||
}
|
||||
}
|
||||
|
||||
await s3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId,
|
||||
MultipartUpload: { Parts: parts },
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
await s3
|
||||
.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId,
|
||||
})
|
||||
)
|
||||
.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+375
-63
@@ -1,5 +1,90 @@
|
||||
// js/subscriptions.js
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
} from "./nostr.js";
|
||||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||
import {
|
||||
buildSubscriptionListEvent,
|
||||
SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
} from "./nostrEventSchemas.js";
|
||||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
let attachHealthBadgesLoader = null;
|
||||
let attachHealthBadgesErrorLogged = false;
|
||||
|
||||
function scheduleAttachHealthBadges(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attachHealthBadgesLoader) {
|
||||
attachHealthBadgesLoader = import("./gridHealth.js")
|
||||
.then((module) => module?.attachHealthBadges || null)
|
||||
.catch((error) => {
|
||||
if (!attachHealthBadgesErrorLogged) {
|
||||
attachHealthBadgesErrorLogged = true;
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[SubscriptionsManager] Failed to load grid health helpers:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
attachHealthBadgesLoader
|
||||
.then((fn) => {
|
||||
if (typeof fn === "function") {
|
||||
try {
|
||||
fn(container);
|
||||
} catch (error) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[SubscriptionsManager] attachHealthBadges threw:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Errors are already logged above; no-op here to keep Promise chains quiet.
|
||||
});
|
||||
}
|
||||
|
||||
function getAbsoluteShareUrl(nevent) {
|
||||
if (!nevent) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (window.app?.buildShareUrlFromNevent) {
|
||||
const candidate = window.app.buildShareUrlFromNevent(nevent);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const origin = window.location?.origin || "";
|
||||
const pathname = window.location?.pathname || "";
|
||||
let base = origin || pathname ? `${origin}${pathname}` : "";
|
||||
if (!base) {
|
||||
const href = window.location?.href || "";
|
||||
base = href ? href.split(/[?#]/)[0] : "";
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
return `?v=${encodeURIComponent(nevent)}`;
|
||||
}
|
||||
|
||||
return `${base}?v=${encodeURIComponent(nevent)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the user's subscription list (kind=30002) *privately*,
|
||||
@@ -26,7 +111,7 @@ class SubscriptionsManager {
|
||||
const filter = {
|
||||
kinds: [30002],
|
||||
authors: [userPubkey],
|
||||
"#d": ["subscriptions"],
|
||||
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
@@ -120,6 +205,14 @@ class SubscriptionsManager {
|
||||
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
||||
const plainStr = JSON.stringify(plainObj);
|
||||
|
||||
/*
|
||||
* The subscription list is stored as a NIP-04 message to self, so both
|
||||
* encryption and decryption intentionally use the user's own pubkey.
|
||||
* Extensions are expected to support this encrypt-to-self flow; altering
|
||||
* the target would break loadSubscriptions, which decrypts with the same
|
||||
* pubkey. Any future sharing model (e.g., sharing with another user) will
|
||||
* need a parallel read path and should not overwrite this behavior.
|
||||
*/
|
||||
let cipherText = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
||||
@@ -128,33 +221,68 @@ class SubscriptionsManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const evt = {
|
||||
kind: 30002,
|
||||
const evt = buildSubscriptionListEvent({
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["d", "subscriptions"]],
|
||||
content: cipherText,
|
||||
};
|
||||
});
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(evt);
|
||||
await Promise.all(
|
||||
nostrClient.relays.map(async (relay) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([relay], signedEvent);
|
||||
} catch (e) {
|
||||
signedEvent = await window.nostr.signEvent(evt);
|
||||
} catch (signErr) {
|
||||
console.error("Failed to sign subscription list:", signErr);
|
||||
throw signErr;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "subscription list",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[SubscriptionsManager] Failed to publish to ${relay}`,
|
||||
e
|
||||
`[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.subsEventId = signedEvent.id;
|
||||
console.log("Subscription list published, event id:", signedEvent.id);
|
||||
} catch (signErr) {
|
||||
console.error("Failed to sign/publish subscription list:", signErr);
|
||||
);
|
||||
}
|
||||
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(
|
||||
`[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.subsEventId = signedEvent.id;
|
||||
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
|
||||
console.log(
|
||||
"Subscription list published, event id:",
|
||||
signedEvent.id,
|
||||
"accepted relays:",
|
||||
acceptedUrls
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +312,8 @@ class SubscriptionsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…");
|
||||
|
||||
// Gather all videos
|
||||
const videos = await this.fetchSubscribedVideos(channelHexes);
|
||||
this.renderSameGridStyle(videos, containerId);
|
||||
@@ -234,7 +364,27 @@ class SubscriptionsManager {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!videos.length) {
|
||||
const safeVideos = Array.isArray(videos) ? videos : [];
|
||||
const dedupedVideos =
|
||||
window.app?.dedupeVideosByRoot?.(safeVideos) ??
|
||||
this.dedupeToNewestByRoot(safeVideos);
|
||||
|
||||
const filteredVideos = dedupedVideos.filter((video) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
window.app?.isAuthorBlocked &&
|
||||
window.app.isAuthorBlocked(video.pubkey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filteredVideos.length) {
|
||||
container.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||
No videos available yet.
|
||||
@@ -243,25 +393,27 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
videos.sort((a, b) => b.created_at - a.created_at);
|
||||
filteredVideos.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||||
const fragment = document.createDocumentFragment();
|
||||
// Only declare localAuthorSet once
|
||||
const localAuthorSet = new Set();
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
filteredVideos.forEach((video, index) => {
|
||||
if (!video.id || !video.title) {
|
||||
console.error("Missing ID or title:", video);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the global videos map up to date so delegated playback handlers
|
||||
// can reuse the already fetched metadata for this event.
|
||||
window.app?.videosMap?.set(video.id, video);
|
||||
|
||||
localAuthorSet.add(video.pubkey);
|
||||
|
||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
||||
nevent
|
||||
)}`;
|
||||
const shareUrl = getAbsoluteShareUrl(nevent);
|
||||
const canEdit = window.app?.pubkey === video.pubkey;
|
||||
|
||||
const highlightClass =
|
||||
@@ -283,6 +435,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-revert-index="${index}"
|
||||
data-revert-event-id="${video.id}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
@@ -311,6 +464,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-edit-index="${index}"
|
||||
data-edit-event-id="${video.id}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -318,6 +472,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-delete-all-index="${index}"
|
||||
data-delete-all-event-id="${video.id}"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
@@ -327,13 +482,87 @@ class SubscriptionsManager {
|
||||
`
|
||||
: "";
|
||||
|
||||
const moreMenu = `
|
||||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-10 h-10 p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
data-more-dropdown="${index}"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More options"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-${index}"
|
||||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="open-channel" data-author="${video.pubkey || ""}">
|
||||
Open channel
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||||
Copy link
|
||||
</button>
|
||||
<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="block-author" data-author="${video.pubkey || ""}">
|
||||
Block creator
|
||||
</button>
|
||||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardControls = `
|
||||
<div class="flex items-center">
|
||||
${moreMenu}${gearMenu}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
||||
const playbackUrl =
|
||||
typeof video.url === "string" ? video.url : "";
|
||||
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
|
||||
const trimmedMagnet =
|
||||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
const legacyInfoHash =
|
||||
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
|
||||
const magnetCandidate = trimmedMagnet || legacyInfoHash;
|
||||
const playbackMagnet = magnetCandidate;
|
||||
const magnetProvided = magnetCandidate.length > 0;
|
||||
const magnetSupported =
|
||||
window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false;
|
||||
const urlBadgeHtml = trimmedUrl
|
||||
? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ??
|
||||
""
|
||||
: "";
|
||||
const torrentHealthBadgeHtml =
|
||||
magnetProvided && magnetSupported
|
||||
? window.app?.getTorrentHealthBadgeMarkup?.({
|
||||
includeMargin: false,
|
||||
}) ?? ""
|
||||
: "";
|
||||
const connectionBadgesHtml =
|
||||
urlBadgeHtml || torrentHealthBadgeHtml
|
||||
? `
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
${urlBadgeHtml}${torrentHealthBadgeHtml}
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
const cardHtml = `
|
||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||
<a
|
||||
href="${shareUrl}"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
data-video-id="${video.id}"
|
||||
data-play-url=""
|
||||
data-play-magnet=""
|
||||
class="block cursor-pointer relative group"
|
||||
>
|
||||
<div class="ratio-16-9">
|
||||
@@ -347,7 +576,9 @@ class SubscriptionsManager {
|
||||
<div class="p-4">
|
||||
<h3
|
||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
data-video-id="${video.id}"
|
||||
data-play-url=""
|
||||
data-play-magnet=""
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
@@ -373,8 +604,9 @@ class SubscriptionsManager {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${gearMenu}
|
||||
${cardControls}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -382,15 +614,104 @@ class SubscriptionsManager {
|
||||
const t = document.createElement("template");
|
||||
t.innerHTML = cardHtml.trim();
|
||||
const cardEl = t.content.firstElementChild;
|
||||
if (cardEl) {
|
||||
cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
|
||||
if (typeof video.pubkey === "string" && video.pubkey) {
|
||||
cardEl.dataset.ownerPubkey = video.pubkey;
|
||||
} else if (cardEl.dataset.ownerPubkey) {
|
||||
delete cardEl.dataset.ownerPubkey;
|
||||
}
|
||||
|
||||
if (trimmedUrl) {
|
||||
cardEl.dataset.urlHealthState = "checking";
|
||||
if (cardEl.dataset.urlHealthReason) {
|
||||
delete cardEl.dataset.urlHealthReason;
|
||||
}
|
||||
cardEl.dataset.urlHealthEventId = video.id || "";
|
||||
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
cardEl.dataset.urlHealthState = "offline";
|
||||
cardEl.dataset.urlHealthReason = "missing-source";
|
||||
if (cardEl.dataset.urlHealthEventId) {
|
||||
delete cardEl.dataset.urlHealthEventId;
|
||||
}
|
||||
if (cardEl.dataset.urlHealthUrl) {
|
||||
delete cardEl.dataset.urlHealthUrl;
|
||||
}
|
||||
}
|
||||
if (magnetProvided && magnetSupported) {
|
||||
cardEl.dataset.streamHealthState = "checking";
|
||||
if (cardEl.dataset.streamHealthReason) {
|
||||
delete cardEl.dataset.streamHealthReason;
|
||||
}
|
||||
} else {
|
||||
cardEl.dataset.streamHealthState = "unhealthy";
|
||||
cardEl.dataset.streamHealthReason = magnetProvided
|
||||
? "unsupported"
|
||||
: "missing-source";
|
||||
}
|
||||
|
||||
// Leave the data-play-* attributes empty in the literal markup so we can
|
||||
// assign the raw URL/magnet strings post-parsing without HTML entity
|
||||
// escaping, mirroring the approach in app.js. The URL is encoded so that
|
||||
// special characters survive storage in data-* attributes; the click
|
||||
// handler decodes it right before playback while keeping the magnet raw.
|
||||
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
|
||||
interactiveEls.forEach((el) => {
|
||||
if (!el.dataset) return;
|
||||
|
||||
if (trimmedUrl) {
|
||||
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
delete el.dataset.playUrl;
|
||||
}
|
||||
|
||||
el.dataset.playMagnet = playbackMagnet || "";
|
||||
});
|
||||
|
||||
if (magnetProvided) {
|
||||
cardEl.dataset.magnet = playbackMagnet;
|
||||
} else if (cardEl.dataset.magnet) {
|
||||
delete cardEl.dataset.magnet;
|
||||
}
|
||||
|
||||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||
if (badgeEl) {
|
||||
if (trimmedUrl) {
|
||||
badgeEl.dataset.urlHealthEventId = video.id || "";
|
||||
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||
} else {
|
||||
if (badgeEl.dataset.urlHealthEventId) {
|
||||
delete badgeEl.dataset.urlHealthEventId;
|
||||
}
|
||||
if (badgeEl.dataset.urlHealthUrl) {
|
||||
delete badgeEl.dataset.urlHealthUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment.appendChild(cardEl);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
scheduleAttachHealthBadges(container);
|
||||
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
|
||||
if (!window.app?.handleUrlHealthBadge) {
|
||||
return;
|
||||
}
|
||||
const video =
|
||||
window.app.videosMap?.get?.(eventId) || { id: eventId };
|
||||
window.app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||
});
|
||||
|
||||
window.app?.attachVideoListHandler?.();
|
||||
|
||||
// Lazy-load
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
|
||||
|
||||
window.app?.attachMoreMenuHandlers?.(container);
|
||||
|
||||
// Gear menus
|
||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||
gearButtons.forEach((btn) => {
|
||||
@@ -407,10 +728,15 @@ class SubscriptionsManager {
|
||||
editButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-edit-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-edit-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
window.app?.handleEditVideo(idx);
|
||||
const eventId = btn.getAttribute("data-edit-event-id") || "";
|
||||
window.app?.handleEditVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,10 +745,15 @@ class SubscriptionsManager {
|
||||
revertButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-revert-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-revert-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dropdown) dropdown.classList.add("hidden");
|
||||
window.app?.handleRevertVideo(idx);
|
||||
const eventId = btn.getAttribute("data-revert-event-id") || "";
|
||||
window.app?.handleRevertVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -433,10 +764,15 @@ class SubscriptionsManager {
|
||||
deleteAllButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-delete-all-index");
|
||||
const dd = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dd = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
if (dd) dd.classList.add("hidden");
|
||||
window.app?.handleFullDeleteVideo(idx);
|
||||
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
|
||||
window.app?.handleFullDeleteVideo({
|
||||
eventId,
|
||||
index: Number.isNaN(idx) ? null : idx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,31 +816,7 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
convertEventToVideo(evt) {
|
||||
try {
|
||||
const content = JSON.parse(evt.content || "{}");
|
||||
const hasFields = !!(content.title && content.magnet);
|
||||
const versionOk = content.version >= 2;
|
||||
if (!versionOk || !hasFields) {
|
||||
return { id: evt.id, invalid: true };
|
||||
}
|
||||
return {
|
||||
id: evt.id,
|
||||
pubkey: evt.pubkey,
|
||||
created_at: evt.created_at,
|
||||
videoRootId: content.videoRootId || evt.id,
|
||||
version: content.version,
|
||||
deleted: content.deleted === true,
|
||||
isPrivate: content.isPrivate === true,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
tags: evt.tags || [],
|
||||
invalid: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: evt.id, invalid: true };
|
||||
}
|
||||
return sharedConvertEventToVideo(evt);
|
||||
}
|
||||
|
||||
dedupeToNewestByRoot(videos) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
|
||||
|
||||
export const TRACKER_TIMEOUT_MS = 3000;
|
||||
export const TRACKER_PER_MAGNET = 3;
|
||||
export const HEALTH_TTL_MS = 2 * 60 * 1000;
|
||||
export const CONCURRENCY = 3;
|
||||
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
export function resolveTrackerList({ magnetTrackers } = {}) {
|
||||
const combined = [];
|
||||
const seen = new Set();
|
||||
|
||||
const pushUnique = (url) => {
|
||||
if (typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmed)) {
|
||||
return;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
combined.push(trimmed);
|
||||
};
|
||||
|
||||
if (Array.isArray(magnetTrackers)) {
|
||||
magnetTrackers.forEach(pushUnique);
|
||||
}
|
||||
|
||||
DEFAULT_WSS_TRACKERS.forEach(pushUnique);
|
||||
|
||||
return combined.slice(0, TRACKER_PER_MAGNET);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import "./bufferPolyfill.js";
|
||||
import Client from "https://esm.sh/[email protected]/client?bundle";
|
||||
import { infoHashFromMagnet, trackersFromMagnet } from "./magnets.js";
|
||||
import {
|
||||
resolveTrackerList,
|
||||
TRACKER_TIMEOUT_MS,
|
||||
TRACKER_ERROR_COOLDOWN_MS,
|
||||
} from "./trackerConfig.js";
|
||||
|
||||
const trackerState = new Map();
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function randomPeerId() {
|
||||
const bytes = new Uint8Array(20);
|
||||
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getDefaultHealth() {
|
||||
return {
|
||||
ok: false,
|
||||
seeders: 0,
|
||||
leechers: 0,
|
||||
responded: false,
|
||||
from: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getTrackerEntry(url) {
|
||||
const existing = trackerState.get(url);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const entry = {
|
||||
consecutiveErrors: 0,
|
||||
lastErrorAt: 0,
|
||||
cooldownUntil: 0,
|
||||
};
|
||||
trackerState.set(url, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function markTrackerSuccess(url) {
|
||||
const entry = getTrackerEntry(url);
|
||||
entry.consecutiveErrors = 0;
|
||||
entry.cooldownUntil = 0;
|
||||
}
|
||||
|
||||
function markTrackerError(url) {
|
||||
const entry = getTrackerEntry(url);
|
||||
const nowTs = now();
|
||||
if (entry.lastErrorAt && nowTs - entry.lastErrorAt < TRACKER_ERROR_COOLDOWN_MS) {
|
||||
entry.consecutiveErrors += 1;
|
||||
} else {
|
||||
entry.consecutiveErrors = 1;
|
||||
}
|
||||
entry.lastErrorAt = nowTs;
|
||||
if (entry.consecutiveErrors >= 2) {
|
||||
entry.cooldownUntil = nowTs + TRACKER_ERROR_COOLDOWN_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function isTrackerUsable(url) {
|
||||
const entry = getTrackerEntry(url);
|
||||
return entry.cooldownUntil === 0 || entry.cooldownUntil <= now();
|
||||
}
|
||||
|
||||
export async function trackerPing(magnet, trackers) {
|
||||
const infoHash = infoHashFromMagnet(magnet);
|
||||
if (!infoHash) {
|
||||
return getDefaultHealth();
|
||||
}
|
||||
|
||||
const magnetTrackers = trackers || trackersFromMagnet(magnet);
|
||||
const announceList = resolveTrackerList({ magnetTrackers });
|
||||
const usable = announceList.filter(isTrackerUsable);
|
||||
const announces = usable.length ? usable : announceList;
|
||||
|
||||
if (!announces.length) {
|
||||
return getDefaultHealth();
|
||||
}
|
||||
|
||||
const peerId = randomPeerId();
|
||||
const result = getDefaultHealth();
|
||||
const clients = new Set();
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const finalize = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
clients.forEach((client) => {
|
||||
try {
|
||||
client.destroy();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
clients.clear();
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (Number.isFinite(TRACKER_TIMEOUT_MS) && TRACKER_TIMEOUT_MS > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
finalize();
|
||||
resolve(result);
|
||||
}, TRACKER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
let remaining = announces.length;
|
||||
|
||||
const handleComplete = (client, url) => {
|
||||
if (clients.has(client)) {
|
||||
clients.delete(client);
|
||||
}
|
||||
if (url && result.from.includes(url) && result.ok) {
|
||||
markTrackerSuccess(url);
|
||||
}
|
||||
remaining -= 1;
|
||||
if (remaining <= 0 && !settled) {
|
||||
finalize();
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResult = (url, data) => {
|
||||
result.responded = true;
|
||||
if (url && !result.from.includes(url)) {
|
||||
result.from.push(url);
|
||||
}
|
||||
const seeders = Number.isFinite(data?.complete)
|
||||
? Number(data.complete)
|
||||
: 0;
|
||||
const leechers = Number.isFinite(data?.incomplete)
|
||||
? Number(data.incomplete)
|
||||
: 0;
|
||||
if (seeders > result.seeders) {
|
||||
result.seeders = seeders;
|
||||
}
|
||||
if (leechers > result.leechers) {
|
||||
result.leechers = leechers;
|
||||
}
|
||||
if (seeders > 0) {
|
||||
result.ok = true;
|
||||
}
|
||||
};
|
||||
|
||||
announces.forEach((url) => {
|
||||
let client;
|
||||
try {
|
||||
client = new Client({
|
||||
infoHash,
|
||||
peerId,
|
||||
announce: [url],
|
||||
});
|
||||
} catch (err) {
|
||||
markTrackerError(url);
|
||||
remaining -= 1;
|
||||
if (remaining <= 0 && !settled) {
|
||||
finalize();
|
||||
resolve(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clients.add(client);
|
||||
|
||||
const cleanupAndResolve = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
finalize();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
client.once("update", (data) => {
|
||||
handleResult(url, data);
|
||||
markTrackerSuccess(url);
|
||||
if (result.ok) {
|
||||
cleanupAndResolve();
|
||||
return;
|
||||
}
|
||||
handleComplete(client, url);
|
||||
});
|
||||
|
||||
client.once("error", () => {
|
||||
markTrackerError(url);
|
||||
handleComplete(client, url);
|
||||
});
|
||||
|
||||
client.once("warning", () => {
|
||||
handleComplete(client, url);
|
||||
});
|
||||
|
||||
try {
|
||||
client.start();
|
||||
} catch (err) {
|
||||
markTrackerError(url);
|
||||
handleComplete(client, url);
|
||||
}
|
||||
});
|
||||
|
||||
if (announces.length === 0) {
|
||||
finalize();
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrackerStateSnapshot() {
|
||||
const snapshot = {};
|
||||
trackerState.forEach((value, key) => {
|
||||
snapshot[key] = { ...value };
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// js/tracking.js
|
||||
// Centralized configuration and loader for the site's analytics script.
|
||||
// Update `bitvidTrackingConfig` to change the tracking provider or settings.
|
||||
window.bitvidTrackingConfig = window.bitvidTrackingConfig || {
|
||||
src: "https://umami.malin.onl/script.js",
|
||||
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
|
||||
};
|
||||
|
||||
(function loadTrackingScript(config) {
|
||||
if (!config || !config.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(`script[src="${config.src}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = config.src;
|
||||
script.defer = true;
|
||||
|
||||
if (config.websiteId) {
|
||||
script.setAttribute("data-website-id", config.websiteId);
|
||||
}
|
||||
|
||||
const additionalAttributes = config.attributes || {};
|
||||
Object.entries(additionalAttributes).forEach(([key, value]) => {
|
||||
if (key === "defer") {
|
||||
script.defer = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === false || value === null || typeof value === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
script.setAttribute(key, value);
|
||||
});
|
||||
|
||||
document.head.appendChild(script);
|
||||
})(window.bitvidTrackingConfig);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
|
||||
|
||||
const ROOT_MARGIN = "0px";
|
||||
const THRESHOLD = 0.25;
|
||||
|
||||
const urlCardObserver = createCardObserver({
|
||||
rootMargin: ROOT_MARGIN,
|
||||
threshold: THRESHOLD,
|
||||
isCardVisible: (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const badgeEl = card.querySelector("[data-url-health-state]");
|
||||
if (!(badgeEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedUrl =
|
||||
card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || "";
|
||||
const eventId =
|
||||
card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || "";
|
||||
|
||||
if (!encodedUrl || !eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState =
|
||||
card.dataset.urlHealthState || badgeEl.dataset.urlHealthState;
|
||||
if (currentState && currentState !== "checking") {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = safeDecodeURIComponent(encodedUrl);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.onCheck({ card, badgeEl, url, eventId });
|
||||
} catch (err) {
|
||||
console.warn("[urlHealthObserver] onCheck handler failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function attachUrlHealthBadges(container, onCheck) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = urlCardObserver.observe(container);
|
||||
if (state && typeof onCheck === "function") {
|
||||
state.onCheck = onCheck;
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshUrlHealthBadges(container) {
|
||||
urlCardObserver.refresh(container);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// js/userBlocks.js
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
function normalizeHex(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class UserBlockListManager {
|
||||
constructor() {
|
||||
this.blockedPubkeys = new Set();
|
||||
this.blockEventId = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.blockedPubkeys.clear();
|
||||
this.blockEventId = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
getBlockedPubkeys() {
|
||||
return Array.from(this.blockedPubkeys);
|
||||
}
|
||||
|
||||
isBlocked(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.blockedPubkeys.has(normalized);
|
||||
}
|
||||
|
||||
async ensureLoaded(userPubkey) {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(userPubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadBlocks(normalized);
|
||||
}
|
||||
|
||||
async loadBlocks(userPubkey) {
|
||||
const normalized = normalizeHex(userPubkey);
|
||||
if (!normalized) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.nip04?.decrypt) {
|
||||
console.warn(
|
||||
"[UserBlockList] nip04.decrypt is unavailable; treating block list as empty."
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [30002],
|
||||
authors: [normalized],
|
||||
"#d": [BLOCK_LIST_IDENTIFIER],
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!events.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;
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[UserBlockList] loadBlocks failed:", error);
|
||||
this.blockedPubkeys.clear();
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async addBlock(targetPubkey, userPubkey) {
|
||||
const actorHex = normalizeHex(userPubkey);
|
||||
if (!actorHex) {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
}
|
||||
|
||||
const targetHex = normalizeHex(targetPubkey);
|
||||
if (!targetHex) {
|
||||
const err = new Error("Invalid target pubkey.");
|
||||
err.code = "invalid";
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (actorHex === targetHex) {
|
||||
const err = new Error("Cannot block yourself.");
|
||||
err.code = "self";
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.ensureLoaded(actorHex);
|
||||
|
||||
if (this.blockedPubkeys.has(targetHex)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const snapshot = new Set(this.blockedPubkeys);
|
||||
this.blockedPubkeys.add(targetHex);
|
||||
|
||||
try {
|
||||
await this.publishBlockList(actorHex);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
this.blockedPubkeys = snapshot;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeBlock(targetPubkey, userPubkey) {
|
||||
const actorHex = normalizeHex(userPubkey);
|
||||
if (!actorHex) {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
}
|
||||
|
||||
const targetHex = normalizeHex(targetPubkey);
|
||||
if (!targetHex) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
await this.ensureLoaded(actorHex);
|
||||
|
||||
if (!this.blockedPubkeys.has(targetHex)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const snapshot = new Set(this.blockedPubkeys);
|
||||
this.blockedPubkeys.delete(targetHex);
|
||||
|
||||
try {
|
||||
await this.publishBlockList(actorHex);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
this.blockedPubkeys = snapshot;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async publishBlockList(userPubkey) {
|
||||
if (!window?.nostr?.nip04?.encrypt) {
|
||||
const err = new Error(
|
||||
"NIP-04 encryption is required to update the block list."
|
||||
);
|
||||
err.code = "nip04-missing";
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof window.nostr.signEvent !== "function") {
|
||||
const err = new Error("Nostr extension missing signEvent support.");
|
||||
err.code = "nip04-missing";
|
||||
throw err;
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(userPubkey);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys).filter(
|
||||
(candidate) => candidate && candidate !== normalized
|
||||
),
|
||||
};
|
||||
const plaintext = JSON.stringify(payload);
|
||||
|
||||
let cipherText = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(normalized, plaintext);
|
||||
} catch (error) {
|
||||
const err = new Error("Failed to encrypt block list.");
|
||||
err.code = "nip04-missing";
|
||||
throw err;
|
||||
}
|
||||
|
||||
const event = buildBlockListEvent({
|
||||
pubkey: normalized,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: cipherText,
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const userBlocks = new UserBlockListManager();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.userBlocks = userBlocks;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function safeDecodeURIComponent(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeDecodeURIComponentLoose(value, { trim = true } = {}) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const working = trim ? value.trim() : value;
|
||||
if (trim && !working) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(working);
|
||||
} catch (err) {
|
||||
return working;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// js/videoEventUtils.js
|
||||
|
||||
/**
|
||||
* Extracts normalized fields from a Bitvid video event while
|
||||
* tolerating legacy payloads that may omit version >= 2 metadata.
|
||||
*/
|
||||
const MAGNET_URI_PATTERN = /^magnet:\?/i;
|
||||
const HEX_INFO_HASH_PATTERN = /\b[0-9a-f]{40}\b/gi;
|
||||
const LEGACY_MAGNET_PATTERN = /magnet:\?xt=urn:btih:[0-9a-z]{40,}[^\s"'<>]*/i;
|
||||
|
||||
function safeTrim(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function pickFirstString(values = []) {
|
||||
if (!Array.isArray(values)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = safeTrim(value);
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function deriveTitleFromEvent({
|
||||
parsedContent = {},
|
||||
tags = [],
|
||||
primaryTitle = "",
|
||||
} = {}) {
|
||||
const initial = safeTrim(primaryTitle);
|
||||
if (initial) {
|
||||
return initial;
|
||||
}
|
||||
|
||||
const directCandidates = pickFirstString([
|
||||
parsedContent?.title,
|
||||
parsedContent?.name,
|
||||
parsedContent?.filename,
|
||||
parsedContent?.fileName,
|
||||
parsedContent?.subject,
|
||||
parsedContent?.caption,
|
||||
]);
|
||||
if (directCandidates) {
|
||||
return directCandidates;
|
||||
}
|
||||
|
||||
const metaTitle = safeTrim(parsedContent?.meta?.title);
|
||||
if (metaTitle) {
|
||||
return metaTitle;
|
||||
}
|
||||
|
||||
if (Array.isArray(tags)) {
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const key = safeTrim(tag[0]).toLowerCase();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
if (["title", "subject", "caption", "name"].includes(key)) {
|
||||
const candidate = safeTrim(tag[1]);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractInfoHashesFromString(value, pushInfoHash) {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = trimmed.match(HEX_INFO_HASH_PATTERN);
|
||||
if (!matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const normalized = match.toLowerCase();
|
||||
pushInfoHash(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function traverseForInfoHashes(value, pushInfoHash) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
extractInfoHashesFromString(value, pushInfoHash);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
traverseForInfoHashes(item, pushInfoHash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const nested of Object.values(value)) {
|
||||
traverseForInfoHashes(nested, pushInfoHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVideoEventPayload(event = {}) {
|
||||
const rawContent = typeof event.content === "string" ? event.content : "";
|
||||
|
||||
let parsedContent = {};
|
||||
let parseError = null;
|
||||
if (rawContent) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawContent);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
parsedContent = parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
parseError = err;
|
||||
parsedContent = {};
|
||||
}
|
||||
}
|
||||
|
||||
const title = typeof parsedContent.title === "string"
|
||||
? parsedContent.title.trim()
|
||||
: "";
|
||||
const thumbnail = typeof parsedContent.thumbnail === "string"
|
||||
? parsedContent.thumbnail.trim()
|
||||
: "";
|
||||
|
||||
const magnetCandidates = [];
|
||||
const infoHashCandidates = [];
|
||||
const urlCandidates = [];
|
||||
|
||||
const pushUnique = (arr, value) => {
|
||||
if (!value || typeof value !== "string") return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || arr.includes(trimmed)) return;
|
||||
arr.push(trimmed);
|
||||
};
|
||||
|
||||
const pushInfoHash = (candidate) => {
|
||||
if (!candidate) return;
|
||||
const normalized = candidate.toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/.test(normalized)) {
|
||||
return;
|
||||
}
|
||||
if (infoHashCandidates.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
infoHashCandidates.push(normalized);
|
||||
};
|
||||
|
||||
const collectMagnetOrInfoHash = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (MAGNET_URI_PATTERN.test(trimmed)) {
|
||||
pushUnique(magnetCandidates, trimmed);
|
||||
}
|
||||
|
||||
extractInfoHashesFromString(trimmed, pushInfoHash);
|
||||
|
||||
const urnMatch = trimmed.match(/^urn:btih:([0-9a-z]+)$/i);
|
||||
if (urnMatch) {
|
||||
pushInfoHash(urnMatch[1]);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof parsedContent.magnet === "string") {
|
||||
collectMagnetOrInfoHash(parsedContent.magnet);
|
||||
}
|
||||
if (typeof parsedContent.url === "string") {
|
||||
const parsedUrl = parsedContent.url.trim();
|
||||
if (parsedUrl && parsedUrl !== thumbnail) {
|
||||
// Keep this guard so we don't duplicate thumbnail URLs in the playable URL list.
|
||||
pushUnique(urlCandidates, parsedUrl);
|
||||
}
|
||||
// Legacy Bitvid events sometimes embedded magnets/info-hashes in the URL field, so treat it accordingly.
|
||||
collectMagnetOrInfoHash(parsedUrl);
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
const urlTagKeys = new Set(["r", "url", "u"]);
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) continue;
|
||||
const key = typeof tag[0] === "string" ? tag[0] : "";
|
||||
const value = typeof tag[1] === "string" ? tag[1] : "";
|
||||
if (!value) continue;
|
||||
|
||||
if (value.toLowerCase().startsWith("magnet:")) {
|
||||
collectMagnetOrInfoHash(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (urlTagKeys.has(key) && /^https?:\/\//i.test(value)) {
|
||||
pushUnique(urlCandidates, value);
|
||||
}
|
||||
|
||||
collectMagnetOrInfoHash(value);
|
||||
}
|
||||
|
||||
const magnetMatch = rawContent.match(/magnet:\?xt=urn:[^"'\s<>]+/i);
|
||||
if (magnetMatch) {
|
||||
collectMagnetOrInfoHash(magnetMatch[0]);
|
||||
}
|
||||
|
||||
extractInfoHashesFromString(rawContent, pushInfoHash);
|
||||
traverseForInfoHashes(parsedContent, pushInfoHash);
|
||||
|
||||
const magnet = magnetCandidates.find(Boolean) || "";
|
||||
const infoHash = infoHashCandidates.find(Boolean) || "";
|
||||
const url =
|
||||
urlCandidates.find(
|
||||
(candidate) => candidate && !candidate.toLowerCase().startsWith("magnet:")
|
||||
) || "";
|
||||
|
||||
const rawVersion = parsedContent.version;
|
||||
let version = 0;
|
||||
if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) {
|
||||
version = rawVersion;
|
||||
} else if (typeof rawVersion === "string") {
|
||||
const parsedVersion = Number(rawVersion);
|
||||
if (!Number.isNaN(parsedVersion)) {
|
||||
version = parsedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parsedContent,
|
||||
parseError,
|
||||
title,
|
||||
url,
|
||||
magnet,
|
||||
infoHash,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function magnetFromText(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const match = value.match(LEGACY_MAGNET_PATTERN);
|
||||
return match ? match[0] : "";
|
||||
}
|
||||
|
||||
export function findLegacyMagnetInEvent(event = {}) {
|
||||
const fromContent = magnetFromText(event?.content);
|
||||
if (fromContent) {
|
||||
return fromContent.trim();
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const key = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (key === "magnet" && value) {
|
||||
return value;
|
||||
}
|
||||
const fromTag = magnetFromText(value);
|
||||
if (fromTag) {
|
||||
return fromTag.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* @module viewCounter
|
||||
* @description Public API for tracking and formatting BitVid video view totals.
|
||||
*
|
||||
* The module exposes lifecycle helpers:
|
||||
* - {@link initViewCounter} to prime the counter with the active Nostr client.
|
||||
* - {@link subscribeToVideoViewCount} / {@link unsubscribeFromVideoViewCount}
|
||||
* to observe live totals for an `a` or `e` pointer.
|
||||
* - {@link ingestLocalViewEvent} so playback flows can optimistically update
|
||||
* counts immediately after publishing a view event.
|
||||
* - {@link formatViewCount} for rendering localized view totals in the UI.
|
||||
*/
|
||||
|
||||
import {
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
} from "./config.js";
|
||||
import {
|
||||
countVideoViewEvents,
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
} from "./nostr.js";
|
||||
|
||||
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
|
||||
const STORAGE_DEBOUNCE_MS = 500;
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/** @type {Map<string, ViewCounterState>} */
|
||||
const pointerStates = new Map();
|
||||
/** @type {Map<string, PointerListeners>} */
|
||||
const pointerListeners = new Map();
|
||||
let persistTimer = null;
|
||||
let nextTokenId = 1;
|
||||
let nostrClientRef = null;
|
||||
|
||||
restoreCacheSnapshot();
|
||||
|
||||
/**
|
||||
* @typedef {Object} ViewCounterState
|
||||
* @property {number} total
|
||||
* @property {Map<string, number>} dedupeBuckets
|
||||
* @property {number} lastSyncedAt
|
||||
* @property {"idle"|"hydrating"|"live"} status
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PointerListeners
|
||||
* @property {{ type: string, value: string, relay?: string | null }} pointer
|
||||
* @property {Map<string, Function>} handlers
|
||||
* @property {(() => void) | null} liveUnsub
|
||||
* @property {Promise<void> | null} hydrationPromise
|
||||
* @property {Object | null} options
|
||||
*/
|
||||
|
||||
/**
|
||||
* Persist the in-memory cache to localStorage after a short debounce.
|
||||
*/
|
||||
function schedulePersist() {
|
||||
if (!supportsStorage()) {
|
||||
return;
|
||||
}
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
persistCacheSnapshot();
|
||||
}, STORAGE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw pointer input into the canonical key and normalized descriptor.
|
||||
*
|
||||
* @param {string | { type?: string, value?: string, relay?: string | null } | { tag?: any }} input
|
||||
* @returns {{ key: string, pointer: { type: string, value: string, relay?: string | null } }}
|
||||
*/
|
||||
function canonicalizePointer(input) {
|
||||
const normalized = normalizePointer(input);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid pointer supplied for view counting.");
|
||||
}
|
||||
|
||||
const type = normalized.type === "a" ? "a" : "e";
|
||||
const value = typeof normalized.value === "string" ? normalized.value.trim() : "";
|
||||
if (!value) {
|
||||
throw new Error("Invalid pointer supplied for view counting.");
|
||||
}
|
||||
|
||||
const key = `${type}:${value}`;
|
||||
const descriptor = { type, value };
|
||||
if (normalized.relay) {
|
||||
descriptor.relay = normalized.relay;
|
||||
}
|
||||
return { key, pointer: descriptor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the pointer normalization logic used inside the Nostr client.
|
||||
*
|
||||
* @param {any} pointer
|
||||
* @returns {{ type: string, value: string, relay?: string | null } | null}
|
||||
*/
|
||||
function normalizePointer(pointer) {
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(pointer)) {
|
||||
return normalizePointerTag(pointer);
|
||||
}
|
||||
|
||||
if (typeof pointer === "object") {
|
||||
if (typeof pointer.type === "string" && typeof pointer.value === "string") {
|
||||
return clonePointer(pointer);
|
||||
}
|
||||
if (Array.isArray(pointer.tag)) {
|
||||
return normalizePointerTag(pointer.tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof pointer !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pointer.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("naddr") || trimmed.startsWith("nevent")) {
|
||||
const decoder = typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null;
|
||||
if (typeof decoder === "function") {
|
||||
try {
|
||||
const decoded = decoder(trimmed);
|
||||
if (decoded?.type === "naddr" && decoded.data) {
|
||||
const { kind, pubkey, identifier, relays } = decoded.data;
|
||||
if (
|
||||
typeof kind === "number" &&
|
||||
typeof pubkey === "string" &&
|
||||
typeof identifier === "string"
|
||||
) {
|
||||
const relay = Array.isArray(relays) && relays.length && typeof relays[0] === "string"
|
||||
? relays[0]
|
||||
: null;
|
||||
return {
|
||||
type: "a",
|
||||
value: `${kind}:${pubkey}:${identifier}`,
|
||||
relay,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (decoded?.type === "nevent" && decoded.data) {
|
||||
const { id, relays } = decoded.data;
|
||||
if (typeof id === "string" && id.trim()) {
|
||||
const relay = Array.isArray(relays) && relays.length && typeof relays[0] === "string"
|
||||
? relays[0]
|
||||
: null;
|
||||
return {
|
||||
type: "e",
|
||||
value: id.trim(),
|
||||
relay,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to decode NIP-19 pointer:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const type = trimmed.includes(":") ? "a" : "e";
|
||||
return { type, value: trimmed, relay: null };
|
||||
}
|
||||
|
||||
function clonePointer(pointer) {
|
||||
const type = pointer.type === "a" ? "a" : "e";
|
||||
const value = typeof pointer.value === "string" ? pointer.value.trim() : "";
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const relay = typeof pointer.relay === "string" ? pointer.relay : null;
|
||||
return relay ? { type, value, relay } : { type, value };
|
||||
}
|
||||
|
||||
function normalizePointerTag(tag) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const [label, value, relay] = tag;
|
||||
if (label === "a" && typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const relayValue = typeof relay === "string" ? relay : null;
|
||||
return relayValue ? { type: "a", value: normalized, relay: relayValue } : { type: "a", value: normalized };
|
||||
}
|
||||
if (label === "e" && typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const relayValue = typeof relay === "string" ? relay : null;
|
||||
return relayValue ? { type: "e", value: normalized, relay: relayValue } : { type: "e", value: normalized };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsStorage() {
|
||||
try {
|
||||
return typeof localStorage !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCacheSnapshot() {
|
||||
if (!supportsStorage()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(VIEW_COUNTER_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || payload.version !== 1) {
|
||||
return;
|
||||
}
|
||||
const savedAt = Number(payload.savedAt) || 0;
|
||||
const now = Date.now();
|
||||
if (!savedAt || now - savedAt > VIEW_COUNT_CACHE_TTL_MS) {
|
||||
localStorage.removeItem(VIEW_COUNTER_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
||||
for (const entry of entries) {
|
||||
if (!Array.isArray(entry) || entry.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
const [key, state] = entry;
|
||||
if (typeof key !== "string" || !state || typeof state !== "object") {
|
||||
continue;
|
||||
}
|
||||
const total = Number.isFinite(state.total) ? Number(state.total) : 0;
|
||||
const lastSyncedAt = Number.isFinite(state.lastSyncedAt) ? Number(state.lastSyncedAt) : 0;
|
||||
const status = typeof state.status === "string" ? state.status : "idle";
|
||||
const dedupeBuckets = new Map();
|
||||
if (Array.isArray(state.dedupeBuckets)) {
|
||||
for (const bucket of state.dedupeBuckets) {
|
||||
if (!Array.isArray(bucket) || bucket.length < 1) {
|
||||
continue;
|
||||
}
|
||||
const [bucketKey, bucketValue] = bucket;
|
||||
if (typeof bucketKey === "string") {
|
||||
dedupeBuckets.set(
|
||||
bucketKey,
|
||||
Number.isFinite(bucketValue) ? Number(bucketValue) : 0
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (state.dedupeBuckets && typeof state.dedupeBuckets === "object") {
|
||||
for (const bucketKey of Object.keys(state.dedupeBuckets)) {
|
||||
const bucketValue = state.dedupeBuckets[bucketKey];
|
||||
if (typeof bucketKey === "string") {
|
||||
dedupeBuckets.set(
|
||||
bucketKey,
|
||||
Number.isFinite(bucketValue) ? Number(bucketValue) : 0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pointerStates.set(key, {
|
||||
total,
|
||||
dedupeBuckets,
|
||||
lastSyncedAt,
|
||||
status: status === "hydrating" || status === "live" ? "idle" : status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to restore cached view counts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function persistCacheSnapshot() {
|
||||
if (!supportsStorage()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entries = [];
|
||||
for (const [key, state] of pointerStates.entries()) {
|
||||
entries.push([
|
||||
key,
|
||||
{
|
||||
total: state.total,
|
||||
dedupeBuckets: Array.from(state.dedupeBuckets.entries()),
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
},
|
||||
]);
|
||||
}
|
||||
const payload = {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
entries,
|
||||
};
|
||||
localStorage.setItem(VIEW_COUNTER_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to persist view count cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePointerState(key) {
|
||||
let state = pointerStates.get(key);
|
||||
if (!state) {
|
||||
state = {
|
||||
total: 0,
|
||||
dedupeBuckets: new Map(),
|
||||
lastSyncedAt: 0,
|
||||
status: "idle",
|
||||
};
|
||||
pointerStates.set(key, state);
|
||||
} else if (!(state.dedupeBuckets instanceof Map)) {
|
||||
state.dedupeBuckets = new Map(
|
||||
state.dedupeBuckets ? Object.entries(state.dedupeBuckets) : []
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function mergePointerDescriptor(existing, next) {
|
||||
if (!existing) {
|
||||
return next;
|
||||
}
|
||||
if (!next) {
|
||||
return existing;
|
||||
}
|
||||
if (!existing.relay && next.relay) {
|
||||
return { ...existing, relay: next.relay };
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
function ensurePointerListeners(key, pointer) {
|
||||
let listeners = pointerListeners.get(key);
|
||||
if (!listeners) {
|
||||
listeners = {
|
||||
pointer,
|
||||
handlers: new Map(),
|
||||
liveUnsub: null,
|
||||
hydrationPromise: null,
|
||||
options: null,
|
||||
};
|
||||
pointerListeners.set(key, listeners);
|
||||
} else if (!listeners.pointer) {
|
||||
listeners.pointer = pointer;
|
||||
} else {
|
||||
listeners.pointer = mergePointerDescriptor(listeners.pointer, pointer);
|
||||
}
|
||||
return listeners;
|
||||
}
|
||||
|
||||
function notifyHandlers(key) {
|
||||
const listeners = pointerListeners.get(key);
|
||||
if (!listeners || !listeners.handlers.size) {
|
||||
return;
|
||||
}
|
||||
const state = ensurePointerState(key);
|
||||
const snapshot = {
|
||||
total: state.total,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
};
|
||||
for (const handler of listeners.handlers.values()) {
|
||||
try {
|
||||
handler(snapshot);
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] View handler threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPointerStatus(key, status) {
|
||||
const state = ensurePointerState(key);
|
||||
if (state.status === status) {
|
||||
return;
|
||||
}
|
||||
state.status = status;
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
}
|
||||
|
||||
function applyEventToState(key, event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
const state = ensurePointerState(key);
|
||||
const bucketKey = deriveBucketKey(event);
|
||||
const finalKey = bucketKey || deriveFallbackKey(event);
|
||||
if (!finalKey) {
|
||||
return false;
|
||||
}
|
||||
const seenBefore = state.dedupeBuckets.has(finalKey);
|
||||
const createdAt = Number.isFinite(event.created_at)
|
||||
? Number(event.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
state.dedupeBuckets.set(finalKey, createdAt);
|
||||
if (seenBefore) {
|
||||
return false;
|
||||
}
|
||||
state.total += 1;
|
||||
state.lastSyncedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
function deriveBucketKey(event) {
|
||||
const pubkey = typeof event?.pubkey === "string" ? event.pubkey : null;
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
const createdAt = Number.isFinite(event?.created_at)
|
||||
? Number(event.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const windowSize = Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS);
|
||||
const bucket = Math.floor(createdAt / windowSize);
|
||||
return `${pubkey}:${bucket}`;
|
||||
}
|
||||
|
||||
function deriveFallbackKey(event) {
|
||||
if (typeof event?.id === "string" && event.id) {
|
||||
return `event:${event.id}`;
|
||||
}
|
||||
if (typeof event?.sig === "string" && event.sig) {
|
||||
return `sig:${event.sig}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeListenerOptions(existing, next) {
|
||||
if (!existing) {
|
||||
return next || null;
|
||||
}
|
||||
if (!next) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
...next,
|
||||
};
|
||||
}
|
||||
|
||||
async function hydratePointer(key, listeners) {
|
||||
const pointer = listeners.pointer;
|
||||
const options = listeners.options || {};
|
||||
setPointerStatus(key, "hydrating");
|
||||
const sinceSeconds = Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
|
||||
const listPromise = listVideoViewEvents(pointer, {
|
||||
since: sinceSeconds,
|
||||
relays: options.relays,
|
||||
});
|
||||
const countPromise = countVideoViewEvents(pointer, {
|
||||
relays: options.relays,
|
||||
signal: options.signal,
|
||||
});
|
||||
let listResult = [];
|
||||
let countResult = null;
|
||||
try {
|
||||
listResult = await listPromise;
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to hydrate view events via list:", error);
|
||||
}
|
||||
try {
|
||||
countResult = await countPromise;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn("[viewCounter] View count hydration aborted:", error);
|
||||
} else {
|
||||
console.warn("[viewCounter] Failed to count view events:", error);
|
||||
}
|
||||
}
|
||||
let mutated = false;
|
||||
if (Array.isArray(listResult)) {
|
||||
for (const event of listResult) {
|
||||
mutated = applyEventToState(key, event) || mutated;
|
||||
}
|
||||
}
|
||||
const bestCount = Number.isFinite(countResult?.best?.count)
|
||||
? Number(countResult.best.count)
|
||||
: Number.isFinite(countResult?.total)
|
||||
? Number(countResult.total)
|
||||
: null;
|
||||
|
||||
if (bestCount !== null) {
|
||||
const state = ensurePointerState(key);
|
||||
const shouldUpdate =
|
||||
!countResult?.fallback || bestCount >= state.total || state.total === 0;
|
||||
if (shouldUpdate && state.total !== bestCount) {
|
||||
state.total = bestCount;
|
||||
state.lastSyncedAt = Date.now();
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) {
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
} else {
|
||||
// Even if nothing changed we still update last synced timestamp.
|
||||
const state = ensurePointerState(key);
|
||||
state.lastSyncedAt = Date.now();
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureHydration(key, listeners) {
|
||||
if (!listeners.hydrationPromise) {
|
||||
listeners.hydrationPromise = hydratePointer(key, listeners).finally(() => {
|
||||
listeners.hydrationPromise = null;
|
||||
if (listeners.liveUnsub) {
|
||||
setPointerStatus(key, "live");
|
||||
} else {
|
||||
setPointerStatus(key, "idle");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLiveSubscription(key, listeners) {
|
||||
if (listeners.liveUnsub || !listeners.handlers.size) {
|
||||
return;
|
||||
}
|
||||
const pointer = listeners.pointer;
|
||||
const options = listeners.options || {};
|
||||
try {
|
||||
const since = Math.floor(Date.now() / 1000) - Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS);
|
||||
const unsubscribe = subscribeVideoViewEvents(pointer, {
|
||||
relays: options.relays,
|
||||
since,
|
||||
onEvent: (event) => {
|
||||
const changed = applyEventToState(key, event);
|
||||
if (changed) {
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
}
|
||||
},
|
||||
});
|
||||
listeners.liveUnsub = () => {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to unsubscribe from view events:", error);
|
||||
}
|
||||
listeners.liveUnsub = null;
|
||||
if (!listeners.hydrationPromise) {
|
||||
setPointerStatus(key, "idle");
|
||||
}
|
||||
};
|
||||
setPointerStatus(key, "live");
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to open live view subscription:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function initViewCounter({ nostrClient } = {}) {
|
||||
nostrClientRef = nostrClient || null;
|
||||
}
|
||||
|
||||
export function subscribeToVideoViewCount(pointerInput, handler, options = {}) {
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("A handler function is required to subscribe to view counts.");
|
||||
}
|
||||
const { key, pointer } = canonicalizePointer(pointerInput);
|
||||
const listeners = ensurePointerListeners(key, pointer);
|
||||
listeners.options = mergeListenerOptions(listeners.options, options);
|
||||
const token = `token-${nextTokenId++}`;
|
||||
listeners.handlers.set(token, handler);
|
||||
const state = ensurePointerState(key);
|
||||
try {
|
||||
handler({
|
||||
total: state.total,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
status: state.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Initial handler invocation threw:", error);
|
||||
}
|
||||
ensureHydration(key, listeners);
|
||||
ensureLiveSubscription(key, listeners);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function unsubscribeFromVideoViewCount(pointerInput, token) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const { key } = canonicalizePointer(pointerInput);
|
||||
const listeners = pointerListeners.get(key);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
listeners.handlers.delete(token);
|
||||
if (!listeners.handlers.size && listeners.liveUnsub) {
|
||||
listeners.liveUnsub();
|
||||
}
|
||||
}
|
||||
|
||||
export function ingestLocalViewEvent({ event, pointer }) {
|
||||
if (!event || !pointer) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { key } = canonicalizePointer(pointer);
|
||||
const changed = applyEventToState(key, event);
|
||||
if (changed) {
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[viewCounter] Failed to ingest local view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const numberFormatter = typeof Intl !== "undefined" && Intl.NumberFormat
|
||||
? new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
||||
: null;
|
||||
const compactFormatter = typeof Intl !== "undefined" && Intl.NumberFormat
|
||||
? new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
|
||||
: null;
|
||||
|
||||
export function formatViewCount(total) {
|
||||
const value = Number.isFinite(total) ? Number(total) : 0;
|
||||
if (value < 1000) {
|
||||
if (numberFormatter) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (compactFormatter) {
|
||||
return compactFormatter.format(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -2,11 +2,17 @@
|
||||
import { initChannelProfileView } from "./channelProfile.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
|
||||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||
|
||||
/**
|
||||
* Load a partial view by URL into the #viewContainer.
|
||||
*/
|
||||
export async function loadView(viewUrl) {
|
||||
try {
|
||||
if (window.app && typeof window.app.prepareForViewLoad === "function") {
|
||||
window.app.prepareForViewLoad();
|
||||
}
|
||||
|
||||
const res = await fetch(viewUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load view: ${res.status}`);
|
||||
@@ -23,6 +29,10 @@ export async function loadView(viewUrl) {
|
||||
// Copy and execute any inline scripts
|
||||
const scriptTags = doc.querySelectorAll("script");
|
||||
scriptTags.forEach((oldScript) => {
|
||||
const src = oldScript.getAttribute("src") || "";
|
||||
if (src && TRACKING_SCRIPT_PATTERN.test(src)) {
|
||||
return;
|
||||
}
|
||||
const newScript = document.createElement("script");
|
||||
Array.from(oldScript.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
@@ -44,6 +54,9 @@ export const viewInitRegistry = {
|
||||
"most-recent-videos": () => {
|
||||
if (window.app && window.app.loadVideos) {
|
||||
window.app.videoList = document.getElementById("videoList");
|
||||
if (window.app.attachVideoListHandler) {
|
||||
window.app.attachVideoListHandler();
|
||||
}
|
||||
window.app.loadVideos();
|
||||
}
|
||||
// Force profile updates after the new view is in place.
|
||||
@@ -54,6 +67,16 @@ export const viewInitRegistry = {
|
||||
explore: () => {
|
||||
console.log("Explore view loaded.");
|
||||
},
|
||||
history: async () => {
|
||||
try {
|
||||
const module = await import("./historyView.js");
|
||||
if (typeof module.initHistoryView === "function") {
|
||||
await module.initHistoryView();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize history view:", error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscriptions view:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
// js/watchHistorySort.js
|
||||
|
||||
export function toNumber(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function getWatchedAtScore(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN);
|
||||
if (Number.isFinite(watchedAt)) {
|
||||
return watchedAt;
|
||||
}
|
||||
|
||||
const createdAtSeconds = toNumber(video?.created_at, Number.NaN);
|
||||
if (Number.isFinite(createdAtSeconds)) {
|
||||
return createdAtSeconds * 1000;
|
||||
}
|
||||
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function compareByWatchedAtDesc(a, b) {
|
||||
const aScore = getWatchedAtScore(a);
|
||||
const bScore = getWatchedAtScore(b);
|
||||
|
||||
if (aScore !== bScore) {
|
||||
return bScore - aScore;
|
||||
}
|
||||
|
||||
const aCreated = toNumber(a?.created_at, Number.NEGATIVE_INFINITY);
|
||||
const bCreated = toNumber(b?.created_at, Number.NEGATIVE_INFINITY);
|
||||
|
||||
if (aCreated !== bCreated) {
|
||||
return bCreated - aCreated;
|
||||
}
|
||||
|
||||
const aId = typeof a?.id === "string" ? a.id : "";
|
||||
const bId = typeof b?.id === "string" ? b.id : "";
|
||||
|
||||
return aId.localeCompare(bId);
|
||||
}
|
||||
+558
-22
@@ -1,12 +1,134 @@
|
||||
//js/webtorrent.js
|
||||
|
||||
import WebTorrent from "./webtorrent.min.js";
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
|
||||
|
||||
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
|
||||
|
||||
function normalizeTrackerList(trackers) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
if (!Array.isArray(trackers)) {
|
||||
return normalized;
|
||||
}
|
||||
trackers.forEach((tracker) => {
|
||||
if (typeof tracker !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = tracker.trim();
|
||||
if (!trimmed || !/^wss:\/\//i.test(trimmed)) {
|
||||
return;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
return;
|
||||
}
|
||||
seen.add(lower);
|
||||
normalized.push(trimmed);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function appendProbeTrackers(magnetURI, trackers) {
|
||||
if (typeof magnetURI !== "string") {
|
||||
return { magnet: "", appended: false, hasProbeTrackers: false };
|
||||
}
|
||||
|
||||
const trimmedMagnet = magnetURI.trim();
|
||||
if (!trimmedMagnet) {
|
||||
return { magnet: "", appended: false, hasProbeTrackers: false };
|
||||
}
|
||||
|
||||
const probeTrackers = normalizeTrackerList(trackers);
|
||||
if (!probeTrackers.length) {
|
||||
return {
|
||||
magnet: trimmedMagnet,
|
||||
appended: false,
|
||||
hasProbeTrackers: false,
|
||||
};
|
||||
}
|
||||
|
||||
const trackerSet = new Set();
|
||||
const [withoutFragment, fragment = ""] = trimmedMagnet.split("#", 2);
|
||||
const [, queryPart = ""] = withoutFragment.split("?", 2);
|
||||
|
||||
if (queryPart) {
|
||||
queryPart
|
||||
.split("&")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((segment) => {
|
||||
const [rawKey, rawValue = ""] = segment.split("=", 2);
|
||||
if (!rawKey || rawKey.trim().toLowerCase() !== "tr") {
|
||||
return;
|
||||
}
|
||||
const decoded = safeDecodeURIComponent(rawValue).trim().toLowerCase();
|
||||
if (decoded) {
|
||||
trackerSet.add(decoded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedProbe = probeTrackers.map((url) => url.toLowerCase());
|
||||
const hadProbeTracker = normalizedProbe.some((url) => trackerSet.has(url));
|
||||
|
||||
const toAppend = [];
|
||||
probeTrackers.forEach((tracker, index) => {
|
||||
const normalizedTracker = normalizedProbe[index];
|
||||
if (trackerSet.has(normalizedTracker)) {
|
||||
return;
|
||||
}
|
||||
trackerSet.add(normalizedTracker);
|
||||
toAppend.push(`tr=${encodeURIComponent(tracker)}`);
|
||||
});
|
||||
|
||||
if (!toAppend.length) {
|
||||
return {
|
||||
magnet: trimmedMagnet,
|
||||
appended: false,
|
||||
hasProbeTrackers: hadProbeTracker,
|
||||
};
|
||||
}
|
||||
|
||||
const separator = queryPart ? "&" : "?";
|
||||
const augmented = `${withoutFragment}${separator}${toAppend.join("&")}`;
|
||||
const finalMagnet = fragment
|
||||
? `${augmented}#${fragment}`
|
||||
: augmented;
|
||||
|
||||
return {
|
||||
magnet: finalMagnet,
|
||||
appended: true,
|
||||
hasProbeTrackers: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumber(value, fallback = 0) {
|
||||
const coerced = Number(value);
|
||||
if (Number.isFinite(coerced)) {
|
||||
return coerced;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toError(err) {
|
||||
if (err instanceof Error) {
|
||||
return err;
|
||||
}
|
||||
try {
|
||||
return new Error(String(err));
|
||||
} catch (stringifyError) {
|
||||
return new Error("Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
export class TorrentClient {
|
||||
constructor() {
|
||||
// Reusable objects and flags
|
||||
this.client = null;
|
||||
this.currentTorrent = null;
|
||||
this.probeClient = null;
|
||||
|
||||
// Service worker registration is cached
|
||||
this.swRegistration = null;
|
||||
@@ -16,6 +138,155 @@ export class TorrentClient {
|
||||
this.TIMEOUT_DURATION = 60000;
|
||||
}
|
||||
|
||||
ensureClientForProbe() {
|
||||
if (!this.probeClient) {
|
||||
this.probeClient = new WebTorrent();
|
||||
}
|
||||
return this.probeClient;
|
||||
}
|
||||
|
||||
async probePeers(
|
||||
magnetURI,
|
||||
{ timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {}
|
||||
) {
|
||||
const magnet = typeof magnetURI === "string" ? magnetURI.trim() : "";
|
||||
if (!magnet) {
|
||||
return {
|
||||
healthy: false,
|
||||
peers: 0,
|
||||
reason: "invalid",
|
||||
appendedTrackers: false,
|
||||
hasProbeTrackers: false,
|
||||
usedTrackers: [...TorrentClient.PROBE_TRACKERS],
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const trackers = normalizeTrackerList(TorrentClient.PROBE_TRACKERS);
|
||||
const { magnet: augmentedMagnet, appended, hasProbeTrackers } =
|
||||
appendProbeTrackers(magnet, trackers);
|
||||
|
||||
if (!hasProbeTrackers) {
|
||||
return {
|
||||
healthy: false,
|
||||
peers: 0,
|
||||
reason: "no-trackers",
|
||||
appendedTrackers: false,
|
||||
hasProbeTrackers: false,
|
||||
usedTrackers: trackers,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const client = this.ensureClientForProbe();
|
||||
const safeTimeout = Math.max(0, normalizeNumber(timeoutMs, 8000));
|
||||
const safePolls = Math.max(1, Math.floor(normalizeNumber(polls, 3)));
|
||||
const safeMaxWebConns = Math.max(1, Math.floor(normalizeNumber(maxWebConns, 2)));
|
||||
const pollInterval = Math.max(
|
||||
250,
|
||||
Math.floor(safeTimeout / Math.max(1, safePolls))
|
||||
);
|
||||
|
||||
const startedAt =
|
||||
typeof performance !== "undefined" && performance?.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let torrent = null;
|
||||
let timeoutId = null;
|
||||
let pollId = null;
|
||||
|
||||
const finalize = (overrides = {}) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
if (pollId) {
|
||||
clearInterval(pollId);
|
||||
pollId = null;
|
||||
}
|
||||
if (torrent) {
|
||||
try {
|
||||
torrent.destroy({ destroyStore: true });
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const endedAt =
|
||||
typeof performance !== "undefined" && performance?.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
resolve({
|
||||
healthy: false,
|
||||
peers: 0,
|
||||
reason: "timeout",
|
||||
appendedTrackers: appended,
|
||||
hasProbeTrackers,
|
||||
usedTrackers: trackers,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
torrent = client.add(augmentedMagnet, {
|
||||
announce: trackers,
|
||||
maxWebConns: safeMaxWebConns,
|
||||
});
|
||||
} catch (err) {
|
||||
finalize({
|
||||
reason: "error",
|
||||
error: toError(err),
|
||||
peers: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settleHealthy = () => {
|
||||
const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1)));
|
||||
finalize({ healthy: true, peers, reason: "peer" });
|
||||
};
|
||||
|
||||
torrent.once("wire", settleHealthy);
|
||||
|
||||
torrent.once("error", (err) => {
|
||||
finalize({
|
||||
healthy: false,
|
||||
reason: "error",
|
||||
error: toError(err),
|
||||
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
|
||||
});
|
||||
});
|
||||
|
||||
if (safeTimeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
finalize({
|
||||
healthy: false,
|
||||
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
|
||||
});
|
||||
}, safeTimeout);
|
||||
}
|
||||
|
||||
pollId = setInterval(() => {
|
||||
if (!torrent || settled) {
|
||||
return;
|
||||
}
|
||||
const peers = Math.max(0, Math.floor(normalizeNumber(torrent.numPeers, 0)));
|
||||
if (peers > 0) {
|
||||
finalize({ healthy: true, peers, reason: "peer" });
|
||||
}
|
||||
}, pollInterval);
|
||||
});
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
@@ -43,6 +314,13 @@ export class TorrentClient {
|
||||
// 2) If we haven’t registered the service worker yet, do it now
|
||||
if (!this.swRegistration) {
|
||||
this.swRegistration = await this.setupServiceWorker();
|
||||
} else {
|
||||
// Even with an existing registration we still wait for control so that a
|
||||
// transient controller drop (e.g. Chrome devtools unregister/reload) does
|
||||
// not resurrect the grey-screen regression mentioned in
|
||||
// waitForActiveController().
|
||||
this.requestClientsClaim(this.swRegistration);
|
||||
await this.waitForActiveController(this.swRegistration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +359,117 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a live service worker is actively controlling the page before we
|
||||
* start WebTorrent streaming.
|
||||
*
|
||||
* This regression has bitten us repeatedly: the first playback attempt after
|
||||
* a cold load would get stuck on a grey frame because `navigator.serviceWorker`
|
||||
* had finished installing but never claimed the page yet. WebTorrent's
|
||||
* service-worker proxy never attached, so `file.streamTo(videoElement)` fed
|
||||
* data into the void. Refreshing the page “fixed” it because the worker became
|
||||
* the controller during navigation. Future refactors **must not** remove this
|
||||
* guard — wait for `controllerchange` whenever `controller` is still null.
|
||||
*/
|
||||
requestClientsClaim(registration = this.swRegistration) {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeWorker = registration?.active;
|
||||
if (!activeWorker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some Chromium builds require an explicit startMessages() call before a
|
||||
// yet-to-claim worker will accept postMessage traffic. Calling it is a
|
||||
// harmless no-op elsewhere.
|
||||
if (navigator.serviceWorker.startMessages) {
|
||||
navigator.serviceWorker.startMessages();
|
||||
}
|
||||
|
||||
activeWorker.postMessage({ type: "ENSURE_CLIENTS_CLAIM" });
|
||||
} catch (err) {
|
||||
this.log("Failed to request clients.claim():", err);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForActiveController(registration = this.swRegistration) {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (navigator.serviceWorker.controller) {
|
||||
return navigator.serviceWorker.controller;
|
||||
}
|
||||
|
||||
this.requestClientsClaim(registration);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId = null;
|
||||
let pollId = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
if (pollId) {
|
||||
clearInterval(pollId);
|
||||
pollId = null;
|
||||
}
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"controllerchange",
|
||||
onControllerChange
|
||||
);
|
||||
};
|
||||
|
||||
const maybeResolve = () => {
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
if (controller) {
|
||||
cleanup();
|
||||
resolve(controller);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onControllerChange = () => {
|
||||
if (maybeResolve()) {
|
||||
return;
|
||||
}
|
||||
// If we received a controllerchange event but still don't have a
|
||||
// controller it usually means the new worker hasn't claimed this page
|
||||
// yet. Ask it again to be safe.
|
||||
this.requestClientsClaim(registration);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Service worker controller claim timeout"));
|
||||
}, this.TIMEOUT_DURATION);
|
||||
|
||||
pollId = setInterval(() => {
|
||||
if (maybeResolve()) {
|
||||
return;
|
||||
}
|
||||
this.requestClientsClaim(registration);
|
||||
}, 500);
|
||||
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
onControllerChange
|
||||
);
|
||||
|
||||
// One last check in case the controller appeared between the earlier
|
||||
// synchronous guard and the promise wiring above.
|
||||
if (!maybeResolve()) {
|
||||
this.requestClientsClaim(registration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setupServiceWorker() {
|
||||
try {
|
||||
const isBraveBrowser = await this.isBrave();
|
||||
@@ -92,7 +481,15 @@ export class TorrentClient {
|
||||
throw new Error("Service Worker not supported or disabled");
|
||||
}
|
||||
|
||||
// Brave-specific logic
|
||||
// Brave-specific logic: Brave Shields has a long-standing bug where
|
||||
// stale service worker registrations linger even after we ship fixes.
|
||||
// When that happens, the outdated worker keeps intercepting requests
|
||||
// and WebTorrent fails to spin up, leaving playback broken until the
|
||||
// user manually nukes the registration. To guarantee a clean slate we
|
||||
// blanket-unregister every worker, then pause briefly so Brave can
|
||||
// finish tearing down the old instance before we register the fresh
|
||||
// one again. That delay is intentional—future tweaks should not shorten
|
||||
// or remove it without understanding the regression risk.
|
||||
if (isBraveBrowser) {
|
||||
this.log("Checking Brave configuration...");
|
||||
if (!navigator.serviceWorker) {
|
||||
@@ -160,6 +557,16 @@ export class TorrentClient {
|
||||
throw new Error("Service worker not active after ready state");
|
||||
}
|
||||
|
||||
// Give the newly activated worker an explicit nudge to claim the page
|
||||
// before we continue. This keeps Chromium's occasionally sluggish claim
|
||||
// hand-offs from derailing the subsequent wait below.
|
||||
this.requestClientsClaim(registration);
|
||||
|
||||
// See waitForActiveController() docstring for why this must remain in
|
||||
// place. We intentionally wait here instead of racing with playback so a
|
||||
// newly installed worker claims the page before WebTorrent spins up.
|
||||
await this.waitForActiveController(registration);
|
||||
|
||||
// Force the SW to check for updates
|
||||
registration.update();
|
||||
this.log("Service worker ready");
|
||||
@@ -171,8 +578,30 @@ export class TorrentClient {
|
||||
}
|
||||
}
|
||||
|
||||
attemptAutoplay(videoElement, context = "webtorrent") {
|
||||
if (!videoElement || typeof videoElement.play !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
videoElement
|
||||
.play()
|
||||
.catch((err) => {
|
||||
this.log(`Autoplay failed (${context} path):`, err);
|
||||
if (videoElement.muted) {
|
||||
return;
|
||||
}
|
||||
this.log(`Retrying with muted autoplay (${context} path).`);
|
||||
videoElement.muted = true;
|
||||
videoElement.play().catch((err2) => {
|
||||
this.log(`Muted autoplay also failed (${context} path):`, err2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Chrome-based browsers
|
||||
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
||||
// Prune demo web seeds/trackers that chronically trip Chromium CORS and
|
||||
// deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
|
||||
torrent.on("warning", (err) => {
|
||||
if (err && typeof err.message === "string") {
|
||||
if (
|
||||
@@ -203,18 +632,20 @@ export class TorrentClient {
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
videoElement.muted = true;
|
||||
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
this.log("Video error:", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
videoElement.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
this.attemptAutoplay(videoElement, "chrome");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
try {
|
||||
file.streamTo(videoElement);
|
||||
@@ -240,18 +671,20 @@ export class TorrentClient {
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
videoElement.muted = true;
|
||||
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
this.log("Video error (Firefox path):", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
videoElement.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
this.attemptAutoplay(videoElement, "firefox");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
try {
|
||||
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
|
||||
@@ -268,11 +701,79 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async destroyWithTimeout(
|
||||
target,
|
||||
{ label = "resource", args = [], timeoutMs = 8000 } = {}
|
||||
) {
|
||||
if (!target || typeof target.destroy !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTimeout = Math.max(
|
||||
1000,
|
||||
Math.min(
|
||||
Number.isFinite(timeoutMs) ? timeoutMs : 8000,
|
||||
this.TIMEOUT_DURATION
|
||||
)
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finalize = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const logError = (error) => {
|
||||
if (error) {
|
||||
this.log(`[cleanup] ${label} destroy error:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.log(
|
||||
`[cleanup] ${label} destroy timed out after ${safeTimeout}ms; forcing resolution.`
|
||||
);
|
||||
finalize();
|
||||
}, safeTimeout);
|
||||
|
||||
const callback = (error) => {
|
||||
logError(error);
|
||||
finalize();
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = target.destroy(...args, callback);
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && typeof result.then === "function") {
|
||||
result
|
||||
.then(() => {
|
||||
finalize();
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error);
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates streaming of a torrent magnet to a <video> element.
|
||||
* Ensures the service worker is set up only once and the client is reused.
|
||||
*/
|
||||
async streamVideo(magnetURI, videoElement) {
|
||||
async streamVideo(magnetURI, videoElement, opts = {}) {
|
||||
try {
|
||||
// 1) Make sure we have a WebTorrent client and a valid SW registration.
|
||||
await this.init();
|
||||
@@ -288,6 +789,16 @@ export class TorrentClient {
|
||||
}
|
||||
|
||||
const isFirefoxBrowser = this.isFirefox();
|
||||
const candidateUrls = Array.isArray(opts?.urlList)
|
||||
? opts.urlList
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter((entry) => /^https?:\/\//i.test(entry))
|
||||
: [];
|
||||
|
||||
const chromeOptions = { strategy: "sequential" };
|
||||
if (candidateUrls.length) {
|
||||
chromeOptions.urlList = candidateUrls;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 3) Add the torrent to the client and handle accordingly.
|
||||
@@ -295,7 +806,7 @@ export class TorrentClient {
|
||||
this.log("Starting torrent download (Firefox path)");
|
||||
this.client.add(
|
||||
magnetURI,
|
||||
{ strategy: "sequential", maxWebConns: 4 },
|
||||
{ ...chromeOptions, maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||
@@ -303,7 +814,7 @@ export class TorrentClient {
|
||||
);
|
||||
} else {
|
||||
this.log("Starting torrent download (Chrome path)");
|
||||
this.client.add(magnetURI, { strategy: "sequential" }, (torrent) => {
|
||||
this.client.add(magnetURI, chromeOptions, (torrent) => {
|
||||
this.log("Torrent added (Chrome path):", torrent.name);
|
||||
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||
});
|
||||
@@ -323,19 +834,44 @@ export class TorrentClient {
|
||||
async cleanup() {
|
||||
try {
|
||||
if (this.currentTorrent) {
|
||||
this.currentTorrent.destroy();
|
||||
try {
|
||||
await this.destroyWithTimeout(this.currentTorrent, {
|
||||
label: "current torrent",
|
||||
args: [{ destroyStore: true }],
|
||||
});
|
||||
} finally {
|
||||
this.currentTorrent = null;
|
||||
}
|
||||
}
|
||||
// Destroy client entirely and set to null so a future streamVideo call starts fresh
|
||||
|
||||
if (this.client) {
|
||||
await this.client.destroy();
|
||||
this.client = null;
|
||||
try {
|
||||
await this.destroyWithTimeout(this.client, {
|
||||
label: "WebTorrent client",
|
||||
});
|
||||
} finally {
|
||||
this.client = null;
|
||||
this.serverCreated = false;
|
||||
}
|
||||
} else {
|
||||
this.serverCreated = false;
|
||||
}
|
||||
|
||||
if (this.probeClient) {
|
||||
try {
|
||||
await this.destroyWithTimeout(this.probeClient, {
|
||||
label: "probe client",
|
||||
});
|
||||
} finally {
|
||||
this.probeClient = null;
|
||||
}
|
||||
}
|
||||
this.currentTorrent = null;
|
||||
this.serverCreated = false;
|
||||
} catch (error) {
|
||||
this.log("Cleanup error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TorrentClient.PROBE_TRACKERS = DEFAULT_PROBE_TRACKERS;
|
||||
|
||||
export const torrentClient = new TorrentClient();
|
||||
Generated
+1672
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "bitvid",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build:css": "npx tailwindcss -c tailwind.config.cjs -i css/tailwind.source.css -o css/tailwind.generated.css --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,9 @@
|
||||
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
|
||||
);
|
||||
}
|
||||
if (event.data && event.data.type === "ENSURE_CLIENTS_CLAIM") {
|
||||
event.waitUntil(clients.claim());
|
||||
}
|
||||
});
|
||||
|
||||
// Immediately install and skip waiting
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./blog.html',
|
||||
'./components/**/*.html',
|
||||
'./views/**/*.html',
|
||||
'./js/**/*.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseVideoEventPayload } from "../js/videoEventUtils.js";
|
||||
import { convertEventToVideo } from "../js/nostr.js";
|
||||
import { deriveTorrentPlaybackConfig } from "../js/playbackUtils.js";
|
||||
|
||||
const LEGACY_INFO_HASH = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
(function testParseDetectsBareInfoHashInJson() {
|
||||
const event = {
|
||||
id: "evt-json",
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
title: "Legacy note",
|
||||
magnet: LEGACY_INFO_HASH,
|
||||
}),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const parsed = parseVideoEventPayload(event);
|
||||
assert.equal(parsed.magnet, "", "Bare info hash should not be treated as a magnet URI");
|
||||
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||
})();
|
||||
|
||||
(function testParseDetectsInfoHashInRawContentAndTags() {
|
||||
const event = {
|
||||
id: "evt-tag",
|
||||
content: JSON.stringify({ title: "tag-sourced" }),
|
||||
tags: [["magnet", LEGACY_INFO_HASH]],
|
||||
};
|
||||
|
||||
const parsed = parseVideoEventPayload(event);
|
||||
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||
})();
|
||||
|
||||
(function testParseDetectsInfoHashInRawString() {
|
||||
const event = {
|
||||
id: "evt-raw",
|
||||
content: `legacy magnet ${LEGACY_INFO_HASH} broken json`,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const parsed = parseVideoEventPayload(event);
|
||||
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||
})();
|
||||
|
||||
(function testConvertFlagsBareInfoHashAsInvalid() {
|
||||
const event = {
|
||||
id: "evt-convert",
|
||||
pubkey: "pk",
|
||||
created_at: 1,
|
||||
tags: [],
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
title: "Legacy conversion",
|
||||
magnet: LEGACY_INFO_HASH,
|
||||
}),
|
||||
};
|
||||
|
||||
const video = convertEventToVideo(event);
|
||||
assert.equal(video.invalid, true, "Bare info-hash payloads should be flagged");
|
||||
assert.equal(video.reason, "missing playable source");
|
||||
})();
|
||||
|
||||
(function testLegacyEventWithoutTitleStillLoads() {
|
||||
const event = {
|
||||
id: "evt-no-title",
|
||||
pubkey: "pk2",
|
||||
created_at: 2,
|
||||
tags: [],
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
magnet: `magnet:?xt=urn:btih:${LEGACY_INFO_HASH}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const video = convertEventToVideo(event);
|
||||
assert.equal(video.invalid, false, "Legacy events without title should fallback");
|
||||
assert.ok(video.title && video.title.length > 0, "Fallback title should be provided");
|
||||
})();
|
||||
|
||||
(function testConvertRecoversMagnetFromMagnetTag() {
|
||||
const magnetUri =
|
||||
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=legacy-tag&tr=wss://tracker.openwebtorrent.com`;
|
||||
const event = {
|
||||
id: "evt-magnet-tag",
|
||||
pubkey: "pk3",
|
||||
created_at: 3,
|
||||
// Deliberately invalid JSON so the converter must rely on scavenging.
|
||||
content: "{not valid json}",
|
||||
tags: [["magnet", magnetUri]],
|
||||
};
|
||||
|
||||
const video = convertEventToVideo(event);
|
||||
assert.equal(video.invalid, false, "Magnet in tag should still be playable");
|
||||
assert.equal(video.magnet, magnetUri);
|
||||
assert.equal(video.rawMagnet, magnetUri);
|
||||
})();
|
||||
|
||||
(function testPlaybackConfigNormalizesInfoHash() {
|
||||
const result = deriveTorrentPlaybackConfig({
|
||||
magnet: "",
|
||||
infoHash: LEGACY_INFO_HASH,
|
||||
url: "",
|
||||
});
|
||||
|
||||
assert.ok(result.magnet.startsWith("magnet:?"));
|
||||
const xtValues = new URL(result.magnet).searchParams.getAll("xt");
|
||||
assert.deepEqual(xtValues, [`urn:btih:${LEGACY_INFO_HASH}`]);
|
||||
assert.equal(result.usedInfoHash, true);
|
||||
assert.equal(result.fallbackMagnet, "");
|
||||
assert.equal(result.provided, true);
|
||||
})();
|
||||
|
||||
(function testPlaybackConfigDecodesEncodedMagnet() {
|
||||
const rawMagnet =
|
||||
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=Legacy+Example`;
|
||||
const encodedMagnet = encodeURIComponent(rawMagnet);
|
||||
const result = deriveTorrentPlaybackConfig({
|
||||
magnet: encodedMagnet,
|
||||
infoHash: "",
|
||||
url: "",
|
||||
});
|
||||
|
||||
assert.ok(result.magnet.startsWith("magnet:?"));
|
||||
const parsed = new URL(result.magnet);
|
||||
assert.equal(
|
||||
parsed.searchParams.get("xt"),
|
||||
`urn:btih:${LEGACY_INFO_HASH}`,
|
||||
"Expected encoded magnet to be normalized"
|
||||
);
|
||||
assert.equal(result.provided, true);
|
||||
assert.equal(result.usedInfoHash, false);
|
||||
})();
|
||||
|
||||
console.log("legacy infohash tests passed");
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { WSS_TRACKERS } from "../js/constants.js";
|
||||
import {
|
||||
normalizeAndAugmentMagnet as normalizeMagnetObject,
|
||||
safeDecodeMagnet,
|
||||
} from "../js/magnetUtils.js";
|
||||
import {
|
||||
extractMagnetHints,
|
||||
normalizeAndAugmentMagnet as normalizeMagnetString,
|
||||
} from "../js/magnet.js";
|
||||
|
||||
function getParamValues(magnet, key) {
|
||||
const parsed = new URL(magnet);
|
||||
return parsed.searchParams.getAll(key);
|
||||
}
|
||||
|
||||
function assertDefaultTrackersPresent(magnet) {
|
||||
const trackers = getParamValues(magnet, "tr");
|
||||
for (const tracker of WSS_TRACKERS) {
|
||||
assert.ok(
|
||||
trackers.includes(tracker),
|
||||
`Expected default tracker ${tracker} to be present`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("safeDecodeMagnet handles encoded values", () => {
|
||||
const rawMagnet =
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example";
|
||||
const encodedMagnet = encodeURIComponent(rawMagnet);
|
||||
assert.equal(
|
||||
safeDecodeMagnet(encodedMagnet),
|
||||
rawMagnet,
|
||||
"Percent-encoded magnets should be decoded"
|
||||
);
|
||||
});
|
||||
|
||||
test("safeDecodeMagnet leaves plain strings untouched", () => {
|
||||
const rawMagnet =
|
||||
"magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
assert.equal(
|
||||
safeDecodeMagnet(rawMagnet),
|
||||
rawMagnet,
|
||||
"Plain magnet strings should pass through"
|
||||
);
|
||||
});
|
||||
|
||||
test("bare hashes normalize consistently across helpers", () => {
|
||||
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||
const objectResult = normalizeMagnetObject(infoHash);
|
||||
const stringResult = normalizeMagnetString(infoHash);
|
||||
|
||||
assert.ok(objectResult.didChange, "Bare hashes should mark didChange true");
|
||||
assert.equal(
|
||||
stringResult,
|
||||
objectResult.magnet,
|
||||
"String helper should match object helper output"
|
||||
);
|
||||
|
||||
const xtValues = getParamValues(stringResult, "xt");
|
||||
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
|
||||
assertDefaultTrackersPresent(stringResult);
|
||||
});
|
||||
|
||||
test("legacy %3A payloads decode across helpers", () => {
|
||||
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
const encoded = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`;
|
||||
const objectResult = normalizeMagnetObject(encoded);
|
||||
const stringResult = normalizeMagnetString(encoded);
|
||||
|
||||
assert.equal(
|
||||
stringResult,
|
||||
objectResult.magnet,
|
||||
"Helpers should agree on encoded xt payload normalization"
|
||||
);
|
||||
|
||||
const xtValues = getParamValues(stringResult, "xt");
|
||||
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
|
||||
});
|
||||
|
||||
test("duplicate trackers and hints stay in sync", () => {
|
||||
const infoHash = "fedcba9876543210fedcba9876543210fedcba98";
|
||||
const baseMagnet =
|
||||
`magnet:?xt=urn:btih:${infoHash}` +
|
||||
"&tr=wss://tracker.openwebtorrent.com" +
|
||||
"&tr=WsS://tracker.openwebtorrent.com/";
|
||||
|
||||
const objectResult = normalizeMagnetObject(baseMagnet, {
|
||||
extraTrackers: [
|
||||
"wss://tracker.fastcast.nz",
|
||||
"http://tracker.invalid",
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
],
|
||||
xs: "https://cdn.example.com/video.torrent",
|
||||
webSeed: "https://cdn.example.com/files/",
|
||||
});
|
||||
|
||||
const stringResult = normalizeMagnetString(baseMagnet, {
|
||||
ws: "https://cdn.example.com/files/", // ensure string helper adds hint
|
||||
xs: "https://cdn.example.com/video.torrent",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
stringResult,
|
||||
objectResult.magnet,
|
||||
"Helpers should emit identical magnets after augmentation"
|
||||
);
|
||||
|
||||
const trackers = getParamValues(stringResult, "tr");
|
||||
const openwebCount = trackers.filter(
|
||||
(value) => value === "wss://tracker.openwebtorrent.com"
|
||||
).length;
|
||||
assert.equal(openwebCount, 1, "Duplicate trackers should be deduped when appended");
|
||||
assert.ok(
|
||||
trackers.includes("wss://tracker.fastcast.nz"),
|
||||
"Extra WSS tracker should be retained"
|
||||
);
|
||||
assert.ok(
|
||||
!trackers.includes("http://tracker.invalid"),
|
||||
"HTTP tracker candidates should be ignored"
|
||||
);
|
||||
|
||||
const webSeeds = getParamValues(stringResult, "ws");
|
||||
assert.deepEqual(webSeeds, ["https://cdn.example.com/files/"]);
|
||||
|
||||
const xsValues = getParamValues(stringResult, "xs");
|
||||
assert.deepEqual(xsValues, ["https://cdn.example.com/video.torrent"]);
|
||||
});
|
||||
|
||||
test("object helper enforces HTTPS web seeds when on HTTPS", () => {
|
||||
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||
const messages = [];
|
||||
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||
webSeed: [
|
||||
"https://cdn.example.com/video.mp4",
|
||||
"http://cdn.example.com/legacy.mp4",
|
||||
],
|
||||
logger: (msg) => messages.push(msg),
|
||||
appProtocol: "https:",
|
||||
});
|
||||
|
||||
const seeds = getParamValues(result.magnet, "ws");
|
||||
assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]);
|
||||
assert.equal(messages.length, 1, "HTTP web seeds should trigger a log message");
|
||||
assert.ok(
|
||||
messages[0].includes("Skipping insecure web seed"),
|
||||
"Expected skip log for HTTP seed"
|
||||
);
|
||||
});
|
||||
|
||||
test("object helper allows HTTP seeds on HTTP origins", () => {
|
||||
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||
webSeed: "http://cdn.example.com/video.mp4",
|
||||
appProtocol: "http:",
|
||||
});
|
||||
|
||||
const seeds = getParamValues(result.magnet, "ws");
|
||||
assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]);
|
||||
});
|
||||
|
||||
test("extractMagnetHints returns first ws/xs pair", () => {
|
||||
const magnet =
|
||||
"magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01" +
|
||||
"&ws=https://cdn.example.com/base/" +
|
||||
"&ws=https://cdn.example.com/duplicate/" +
|
||||
"&xs=https://cdn.example.com/video.torrent";
|
||||
|
||||
const hints = extractMagnetHints(magnet);
|
||||
assert.deepEqual(hints, {
|
||||
ws: "https://cdn.example.com/base/",
|
||||
xs: "https://cdn.example.com/video.torrent",
|
||||
});
|
||||
});
|
||||
|
||||
test("string helper skips insecure ws hints on HTTPS origins", () => {
|
||||
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||
const normalized = normalizeMagnetString(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||
ws: "http://cdn.example.com/video-base/",
|
||||
});
|
||||
const parsed = new URL(normalized);
|
||||
assert.equal(parsed.searchParams.getAll("ws").length, 0);
|
||||
});
|
||||
|
||||
test("object helper reports didChange when output differs", () => {
|
||||
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||
webSeed: "https://cdn.example.com/video.mp4",
|
||||
});
|
||||
assert.ok(result.didChange, "Augmenting with web seeds should mark didChange");
|
||||
});
|
||||
|
||||
test("helpers trim fragments from non-magnet values", () => {
|
||||
const url = "https://example.com/video.mp4#fragment";
|
||||
const objectResult = normalizeMagnetObject(url);
|
||||
const stringResult = normalizeMagnetString(url);
|
||||
assert.equal(objectResult.magnet, "https://example.com/video.mp4");
|
||||
assert.equal(stringResult, "https://example.com/video.mp4");
|
||||
assert.ok(
|
||||
objectResult.didChange,
|
||||
"Dropping the fragment should be reflected in didChange"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// Run with: node tests/nostr-count-fallback.test.mjs
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
if (!globalThis.window.NostrTools) {
|
||||
globalThis.window.NostrTools = {};
|
||||
}
|
||||
|
||||
const { VIEW_COUNT_BACKFILL_MAX_DAYS } = await import("../js/config.js");
|
||||
const { nostrClient } = await import("../js/nostr.js");
|
||||
|
||||
const NostrClient = nostrClient.constructor;
|
||||
|
||||
async function withFrozenTime(timestampSeconds, callback) {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => timestampSeconds * 1000;
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredHorizonDays() {
|
||||
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
|
||||
return Number.isFinite(horizonDaysRaw)
|
||||
? Math.max(0, Math.floor(horizonDaysRaw))
|
||||
: 0;
|
||||
}
|
||||
|
||||
async function testFallbackAddsDefaultSinceWhenPoolMissing() {
|
||||
const client = new NostrClient();
|
||||
client.pool = null;
|
||||
|
||||
const pointer = { type: "e", value: "count-fallback-default" };
|
||||
const calls = [];
|
||||
client.listVideoViewEvents = async (_, options = {}) => {
|
||||
calls.push({ ...options });
|
||||
return [];
|
||||
};
|
||||
|
||||
const horizonDays = getConfiguredHorizonDays();
|
||||
|
||||
const nowSeconds = 1_700_000_000;
|
||||
await withFrozenTime(nowSeconds, async () => {
|
||||
const result = await client.countVideoViewEvents(pointer, {});
|
||||
assert.equal(calls.length, 1, "fallback should issue a single list query");
|
||||
const [{ since }] = calls;
|
||||
if (horizonDays > 0) {
|
||||
const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400);
|
||||
assert.equal(
|
||||
since,
|
||||
expectedSince,
|
||||
"fallback list should clamp history to the configured horizon"
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(calls[0], "since"),
|
||||
false,
|
||||
"without a configured horizon the fallback should avoid forcing a since filter"
|
||||
);
|
||||
}
|
||||
assert.equal(result.fallback, true, "fallback branch should be flagged");
|
||||
});
|
||||
}
|
||||
|
||||
async function testFallbackHonorsCallerOverrides() {
|
||||
const client = new NostrClient();
|
||||
client.pool = null;
|
||||
|
||||
const pointer = { type: "e", value: "count-fallback-overrides" };
|
||||
let receivedOptions = null;
|
||||
client.listVideoViewEvents = async (_, options = {}) => {
|
||||
receivedOptions = { ...options };
|
||||
return [];
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
since: 1_234_567,
|
||||
until: 1_235_000,
|
||||
limit: 42,
|
||||
};
|
||||
|
||||
await client.countVideoViewEvents(pointer, overrides);
|
||||
|
||||
assert.ok(receivedOptions, "fallback should forward overrides to list");
|
||||
assert.equal(
|
||||
receivedOptions.since,
|
||||
Math.floor(overrides.since),
|
||||
"caller supplied since should be preserved"
|
||||
);
|
||||
assert.equal(
|
||||
receivedOptions.until,
|
||||
Math.floor(overrides.until),
|
||||
"caller supplied until should be preserved"
|
||||
);
|
||||
assert.equal(
|
||||
receivedOptions.limit,
|
||||
Math.floor(overrides.limit),
|
||||
"caller supplied limit should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
async function testFallbackAfterCountFailureUsesDefaultWindow() {
|
||||
const client = new NostrClient();
|
||||
client.pool = {};
|
||||
client.countEventsAcrossRelays = async () => {
|
||||
throw new Error("count failed");
|
||||
};
|
||||
|
||||
const pointer = { type: "e", value: "count-fallback-error" };
|
||||
let receivedOptions = null;
|
||||
client.listVideoViewEvents = async (_, options = {}) => {
|
||||
receivedOptions = { ...options };
|
||||
return [];
|
||||
};
|
||||
|
||||
const horizonDays = getConfiguredHorizonDays();
|
||||
|
||||
const nowSeconds = 1_800_000_000;
|
||||
await withFrozenTime(nowSeconds, async () => {
|
||||
await client.countVideoViewEvents(pointer, {});
|
||||
});
|
||||
|
||||
assert.ok(receivedOptions, "fallback should invoke the list helper");
|
||||
if (horizonDays > 0) {
|
||||
const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400);
|
||||
assert.equal(
|
||||
receivedOptions.since,
|
||||
expectedSince,
|
||||
"COUNT fallback should restrict scans to the configured horizon"
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(receivedOptions, "since"),
|
||||
false,
|
||||
"COUNT fallback should avoid forcing a since filter when no horizon is set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testUnsupportedRelaySkipsWarningsAndFallsBack() {
|
||||
const client = new NostrClient();
|
||||
const unsupportedRelay = "wss://unsupported.example";
|
||||
client.relays = [unsupportedRelay];
|
||||
|
||||
let ensureRelayCalls = 0;
|
||||
client.pool = {
|
||||
ensureRelay: async (url) => {
|
||||
ensureRelayCalls += 1;
|
||||
assert.equal(
|
||||
url,
|
||||
unsupportedRelay,
|
||||
"COUNT helper should target the configured relay"
|
||||
);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
let listCalls = 0;
|
||||
client.listVideoViewEvents = async () => {
|
||||
listCalls += 1;
|
||||
return [];
|
||||
};
|
||||
|
||||
const originalWarn = console.warn;
|
||||
const warnCalls = [];
|
||||
console.warn = (...args) => {
|
||||
warnCalls.push(args);
|
||||
};
|
||||
|
||||
try {
|
||||
const pointer = { type: "e", value: "unsupported-relay" };
|
||||
const result = await client.countVideoViewEvents(pointer, {});
|
||||
|
||||
assert.equal(listCalls, 1, "fallback should query list once when COUNT fails");
|
||||
assert.equal(result?.fallback, true, "unsupported relay should trigger fallback");
|
||||
assert.ok(
|
||||
client.countUnsupportedRelays.has(unsupportedRelay),
|
||||
"unsupported relay should be remembered for future requests"
|
||||
);
|
||||
assert.equal(
|
||||
warnCalls.length,
|
||||
0,
|
||||
"unsupported relay error should not emit console warnings"
|
||||
);
|
||||
|
||||
// Subsequent calls should skip the unsupported relay entirely.
|
||||
await client.countVideoViewEvents(pointer, {});
|
||||
assert.equal(
|
||||
ensureRelayCalls,
|
||||
1,
|
||||
"unsupported relays should be skipped after the first failure"
|
||||
);
|
||||
assert.equal(
|
||||
warnCalls.length,
|
||||
0,
|
||||
"no warnings should be emitted for cached unsupported relays"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
|
||||
await testFallbackAddsDefaultSinceWhenPoolMissing();
|
||||
await testFallbackHonorsCallerOverrides();
|
||||
await testFallbackAfterCountFailureUsesDefaultWindow();
|
||||
await testUnsupportedRelaySkipsWarningsAndFallsBack();
|
||||
|
||||
console.log("nostr COUNT fallback tests completed successfully.");
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Nostr Ephemeral DM Tester (Using finalizeEvent)</title>
|
||||
<script src="../js/tracking.js" defer></script>
|
||||
<style>
|
||||
/* Basic styling for the demo page */
|
||||
body {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
const originalWindowNostr = window.nostr;
|
||||
|
||||
window.nostr = {
|
||||
...(originalWindowNostr || {}),
|
||||
signEvent: async (event) => ({
|
||||
...event,
|
||||
id: event?.id || `stub-${Math.random().toString(36).slice(2)}`,
|
||||
sig: "stub-signature",
|
||||
}),
|
||||
nip04: {
|
||||
...(originalWindowNostr?.nip04 || {}),
|
||||
encrypt: async () => "cipher-text",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const { RelayPublishError } = await import("../js/nostrPublish.js");
|
||||
const { nostrClient } = await import("../js/nostr.js");
|
||||
const { subscriptions } = await import("../js/subscriptions.js");
|
||||
const { userBlocks } = await import("../js/userBlocks.js");
|
||||
|
||||
function createRejectingPool(reason = "relay rejected") {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
publish(urls, event) {
|
||||
calls.push({
|
||||
urls: Array.isArray(urls) ? [...urls] : [],
|
||||
event,
|
||||
});
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "failed") {
|
||||
handler(new Error(reason));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotRelayState(client) {
|
||||
return {
|
||||
relays: Array.isArray(client.relays) ? [...client.relays] : [],
|
||||
readRelays: Array.isArray(client.readRelays)
|
||||
? [...client.readRelays]
|
||||
: [],
|
||||
writeRelays: Array.isArray(client.writeRelays)
|
||||
? [...client.writeRelays]
|
||||
: [],
|
||||
pool: client.pool,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreRelayState(client, snapshot) {
|
||||
client.relays = [...snapshot.relays];
|
||||
client.readRelays = [...snapshot.readRelays];
|
||||
client.writeRelays = [...snapshot.writeRelays];
|
||||
client.pool = snapshot.pool;
|
||||
}
|
||||
|
||||
function applyTestRelayState(client, relays, pool) {
|
||||
client.relays = [...relays];
|
||||
client.readRelays = [...relays];
|
||||
client.writeRelays = [...relays];
|
||||
client.pool = pool;
|
||||
}
|
||||
|
||||
async function testPublishVideoRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("video rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
nostrClient.publishVideo(
|
||||
{
|
||||
title: "Test video",
|
||||
magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890",
|
||||
mode: "live",
|
||||
},
|
||||
"f".repeat(64)
|
||||
),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishVideo should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include every relay"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
urls,
|
||||
[...testRelays].sort(),
|
||||
"relayFailures should include the rejecting relay URLs"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishVideo should attempt each relay"
|
||||
);
|
||||
} finally {
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishSubscriptionListRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("subscription rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
const originalSubs = new Set(subscriptions.subscribedPubkeys);
|
||||
const originalSubsEventId = subscriptions.subsEventId;
|
||||
subscriptions.subscribedPubkeys = new Set(["abcdef1234567890"]);
|
||||
subscriptions.subsEventId = null;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
subscriptions.publishSubscriptionList("f".repeat(64)),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishSubscriptionList should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include each relay for subscriptions"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(urls, [...testRelays].sort());
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishSubscriptionList should attempt each relay"
|
||||
);
|
||||
assert.equal(
|
||||
subscriptions.subsEventId,
|
||||
null,
|
||||
"subsEventId should remain unset on failure"
|
||||
);
|
||||
} finally {
|
||||
subscriptions.subscribedPubkeys = originalSubs;
|
||||
subscriptions.subsEventId = originalSubsEventId;
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishBlockListRejectsWhenAllRelaysFail() {
|
||||
const testRelays = [
|
||||
"wss://relay-a.example",
|
||||
"wss://relay-b.example",
|
||||
];
|
||||
const pool = createRejectingPool("block list rejected");
|
||||
const relaySnapshot = snapshotRelayState(nostrClient);
|
||||
applyTestRelayState(nostrClient, testRelays, pool);
|
||||
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]);
|
||||
userBlocks.blockEventId = null;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
userBlocks.publishBlockList("f".repeat(64)),
|
||||
(error) => {
|
||||
assert.ok(
|
||||
error instanceof RelayPublishError,
|
||||
"publishBlockList should reject with RelayPublishError"
|
||||
);
|
||||
assert.equal(
|
||||
error.relayFailures.length,
|
||||
testRelays.length,
|
||||
"relayFailures should include each relay for block list"
|
||||
);
|
||||
const urls = error.relayFailures
|
||||
.map((entry) => entry.url)
|
||||
.sort();
|
||||
assert.deepEqual(urls, [...testRelays].sort());
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pool.calls.length,
|
||||
testRelays.length,
|
||||
"publishBlockList should attempt each relay"
|
||||
);
|
||||
assert.equal(
|
||||
userBlocks.blockEventId,
|
||||
null,
|
||||
"blockEventId should remain unset on failure"
|
||||
);
|
||||
} finally {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
await testPublishVideoRejectsWhenAllRelaysFail();
|
||||
await testPublishSubscriptionListRejectsWhenAllRelaysFail();
|
||||
await testPublishBlockListRejectsWhenAllRelaysFail();
|
||||
|
||||
console.log("nostr publish rejection tests passed");
|
||||
} finally {
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete window.nostr;
|
||||
} else {
|
||||
window.nostr = originalWindowNostr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
safeDecodeURIComponent,
|
||||
safeDecodeURIComponentLoose,
|
||||
} from "../js/utils/safeDecode.js";
|
||||
|
||||
import { safeDecodeMagnet } from "../js/magnetUtils.js";
|
||||
|
||||
test("safeDecodeURIComponent returns original value on malformed sequences", () => {
|
||||
const malformed = "%E0%A4%A";
|
||||
assert.equal(
|
||||
safeDecodeURIComponent(malformed),
|
||||
malformed,
|
||||
"Malformed sequences should return the original input"
|
||||
);
|
||||
});
|
||||
|
||||
test("safeDecode helpers handle double-encoded values when applied repeatedly", () => {
|
||||
const raw = "magnet:?xt=urn:btih:abcdef";
|
||||
const doubleEncoded = encodeURIComponent(encodeURIComponent(raw));
|
||||
|
||||
const firstPass = safeDecodeURIComponent(doubleEncoded);
|
||||
assert.equal(
|
||||
firstPass,
|
||||
encodeURIComponent(raw),
|
||||
"First pass should peel one encoding layer"
|
||||
);
|
||||
|
||||
const secondPass = safeDecodeURIComponent(firstPass);
|
||||
assert.equal(
|
||||
secondPass,
|
||||
raw,
|
||||
"Second pass should reveal the original string"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
safeDecodeMagnet(doubleEncoded),
|
||||
raw,
|
||||
"safeDecodeMagnet should continue to handle double-encoded magnets"
|
||||
);
|
||||
});
|
||||
|
||||
test("safeDecodeURIComponentLoose trims inputs by default", () => {
|
||||
const padded = " magnet:?dn=Example";
|
||||
assert.equal(
|
||||
safeDecodeURIComponentLoose(padded),
|
||||
"magnet:?dn=Example",
|
||||
"Loose decoder should trim whitespace when decoding"
|
||||
);
|
||||
});
|
||||
|
||||
test("safeDecodeURIComponentLoose preserves whitespace when trim is false", () => {
|
||||
const padded = " magnet:?dn=Example";
|
||||
assert.equal(
|
||||
safeDecodeURIComponentLoose(padded, { trim: false }),
|
||||
padded,
|
||||
"Loose decoder should return the original string when trim is disabled and decoding is unnecessary"
|
||||
);
|
||||
});
|
||||
|
||||
test("safeDecodeURIComponent handles empty strings consistently", () => {
|
||||
assert.equal(safeDecodeURIComponent(""), "", "Empty input should stay empty");
|
||||
assert.equal(
|
||||
safeDecodeURIComponentLoose(""),
|
||||
"",
|
||||
"Loose decoder should return empty for empty input"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem(key) {
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(String(key), String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
// Run with: node tests/view-counter.test.mjs
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
WATCH_HISTORY_KIND,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
} = await import("../js/config.js");
|
||||
|
||||
const { buildViewEvent, setNostrEventSchemaOverrides } = await import(
|
||||
"../js/nostrEventSchemas.js"
|
||||
);
|
||||
|
||||
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
|
||||
const CACHE_TTL_TEST_POINTER = { type: "e", value: "view-counter-cache-ttl" };
|
||||
|
||||
// Seed a stale cache entry so we can verify hydration ignores data older than the TTL.
|
||||
const staleSavedAt = Date.now() - (VIEW_COUNT_CACHE_TTL_MS + 60_000);
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
VIEW_COUNTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: staleSavedAt,
|
||||
entries: [
|
||||
[
|
||||
`${CACHE_TTL_TEST_POINTER.type}:${CACHE_TTL_TEST_POINTER.value}`,
|
||||
{
|
||||
total: 42,
|
||||
dedupeBuckets: [["pub-cache-ttl:bucket", staleSavedAt]],
|
||||
lastSyncedAt: staleSavedAt - 5_000,
|
||||
status: "live",
|
||||
},
|
||||
],
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
if (!globalThis.window.NostrTools) {
|
||||
globalThis.window.NostrTools = {};
|
||||
}
|
||||
|
||||
setNostrEventSchemaOverrides({});
|
||||
|
||||
const canonicalViewEvent = buildViewEvent({
|
||||
pubkey: "actor-canonical",
|
||||
created_at: 1000,
|
||||
pointerValue: CACHE_TTL_TEST_POINTER.value,
|
||||
pointerTag: [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value],
|
||||
dedupeTag: "ignore-dedupe",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
canonicalViewEvent.tags,
|
||||
[
|
||||
["t", "view"],
|
||||
[CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value],
|
||||
["d", "ignore-dedupe"],
|
||||
],
|
||||
"view event should include topic, pointer, and dedupe tags when provided"
|
||||
);
|
||||
|
||||
const sessionViewEvent = buildViewEvent({
|
||||
pubkey: "actor-session",
|
||||
created_at: 2000,
|
||||
pointerValue: "kind:1234:actor-session",
|
||||
pointerTag: ["a", "kind:1234:actor-session"],
|
||||
includeSessionTag: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sessionViewEvent.tags,
|
||||
[
|
||||
["t", "view"],
|
||||
["a", "kind:1234:actor-session"],
|
||||
["session", "true"],
|
||||
],
|
||||
"view event should append the session tag when requested"
|
||||
);
|
||||
|
||||
const { nostrClient } = await import("../js/nostr.js");
|
||||
|
||||
function createMockNostrHarness() {
|
||||
const storedEvents = new Map();
|
||||
const customCountResults = new Map();
|
||||
const subscribers = new Map();
|
||||
const metrics = { list: 0, count: 0 };
|
||||
|
||||
const resetMetrics = () => {
|
||||
metrics.list = 0;
|
||||
metrics.count = 0;
|
||||
};
|
||||
|
||||
const pointerKeyFromInput = (input) => {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.includes(":")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `e:${trimmed}`;
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
if (input.length < 2) {
|
||||
return "";
|
||||
}
|
||||
const type = input[0] === "a" ? "a" : "e";
|
||||
const value = typeof input[1] === "string" ? input[1].trim() : "";
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return `${type}:${value}`;
|
||||
}
|
||||
if (typeof input === "object") {
|
||||
const type = input.type === "a" ? "a" : "e";
|
||||
const value = typeof input.value === "string" ? input.value.trim() : "";
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return `${type}:${value}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const listVideoViewEvents = async (pointer, options = {}) => {
|
||||
metrics.list += 1;
|
||||
const key = pointerKeyFromInput(pointer);
|
||||
const events = storedEvents.get(key) || [];
|
||||
const since = Number.isFinite(options?.since) ? Number(options.since) : null;
|
||||
if (since === null) {
|
||||
return events;
|
||||
}
|
||||
return events.filter((event) => {
|
||||
const createdAt = Number.isFinite(event?.created_at) ? Number(event.created_at) : 0;
|
||||
return createdAt >= since;
|
||||
});
|
||||
};
|
||||
|
||||
const countVideoViewEvents = async (pointer) => {
|
||||
metrics.count += 1;
|
||||
const key = pointerKeyFromInput(pointer);
|
||||
if (customCountResults.has(key)) {
|
||||
const stored = customCountResults.get(key);
|
||||
if (stored && typeof stored === "object" && !Array.isArray(stored)) {
|
||||
const totalValue = Number(stored.total);
|
||||
const normalizedTotal =
|
||||
Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : 0;
|
||||
const perRelay = Array.isArray(stored.perRelay)
|
||||
? stored.perRelay.map((entry) =>
|
||||
entry && typeof entry === "object" ? { ...entry } : entry
|
||||
)
|
||||
: [];
|
||||
const result = {
|
||||
total: normalizedTotal,
|
||||
perRelay,
|
||||
best:
|
||||
stored.best && typeof stored.best === "object"
|
||||
? { ...stored.best }
|
||||
: null,
|
||||
};
|
||||
if (stored.fallback) {
|
||||
result.fallback = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const numericTotal = Number(stored);
|
||||
const normalizedTotal =
|
||||
Number.isFinite(numericTotal) && numericTotal >= 0 ? numericTotal : 0;
|
||||
return { total: normalizedTotal, perRelay: [], best: null };
|
||||
}
|
||||
const events = storedEvents.get(key) || [];
|
||||
return { total: events.length, perRelay: [], best: null };
|
||||
};
|
||||
|
||||
const subscribeVideoViewEvents = (pointer, options = {}) => {
|
||||
const key = pointerKeyFromInput(pointer);
|
||||
let handlers = subscribers.get(key);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
subscribers.set(key, handlers);
|
||||
}
|
||||
const handler = typeof options?.onEvent === "function" ? options.onEvent : null;
|
||||
if (handler) {
|
||||
handlers.add(handler);
|
||||
}
|
||||
return () => {
|
||||
if (handler && handlers) {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
subscribers.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const emit = (pointer, event) => {
|
||||
const key = pointerKeyFromInput(pointer);
|
||||
const handlers = subscribers.get(key);
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
for (const handler of Array.from(handlers)) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
|
||||
const setEvents = (key, events) => {
|
||||
storedEvents.set(key, Array.isArray(events) ? events : []);
|
||||
};
|
||||
|
||||
const setCountTotal = (key, total) => {
|
||||
if (
|
||||
typeof total === "object" &&
|
||||
total !== null &&
|
||||
!Array.isArray(total)
|
||||
) {
|
||||
customCountResults.set(key, JSON.parse(JSON.stringify(total)));
|
||||
} else if (Number.isFinite(total)) {
|
||||
customCountResults.set(key, Number(total));
|
||||
} else {
|
||||
customCountResults.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
storedEvents.clear();
|
||||
customCountResults.clear();
|
||||
subscribers.clear();
|
||||
resetMetrics();
|
||||
};
|
||||
|
||||
const subscriptionCount = (key) => {
|
||||
const handlers = subscribers.get(key);
|
||||
return handlers ? handlers.size : 0;
|
||||
};
|
||||
|
||||
const getCallCounts = () => ({ list: metrics.list, count: metrics.count });
|
||||
|
||||
return {
|
||||
listVideoViewEvents,
|
||||
countVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
emit,
|
||||
setEvents,
|
||||
setCountTotal,
|
||||
reset,
|
||||
pointerKeyFromInput,
|
||||
subscriptionCount,
|
||||
resetMetrics,
|
||||
getCallCounts,
|
||||
};
|
||||
}
|
||||
|
||||
const harness = createMockNostrHarness();
|
||||
|
||||
nostrClient.listVideoViewEvents = harness.listVideoViewEvents;
|
||||
nostrClient.countVideoViewEvents = harness.countVideoViewEvents;
|
||||
nostrClient.subscribeVideoViewEvents = harness.subscribeVideoViewEvents;
|
||||
|
||||
const {
|
||||
initViewCounter,
|
||||
subscribeToVideoViewCount,
|
||||
unsubscribeFromVideoViewCount,
|
||||
ingestLocalViewEvent,
|
||||
} = await import("../js/viewCounter.js");
|
||||
|
||||
initViewCounter({ nostrClient });
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function testDedupesWithinWindow() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = { type: "e", value: "view-counter-dedupe" };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
const base = Math.floor(Date.now() / 1000);
|
||||
const events = [
|
||||
{ id: "evt-1", pubkey: "pub-dedupe", created_at: base },
|
||||
{
|
||||
id: "evt-2",
|
||||
pubkey: "pub-dedupe",
|
||||
created_at: base + Math.floor(VIEW_COUNT_DEDUPE_WINDOW_SECONDS / 2),
|
||||
},
|
||||
];
|
||||
harness.setEvents(pointerKey, events);
|
||||
harness.setCountTotal(pointerKey, 1);
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const final = updates.at(-1);
|
||||
assert.ok(final, "expected at least one state update");
|
||||
assert.equal(
|
||||
final.total,
|
||||
1,
|
||||
"multiple events from the same pubkey within the dedupe window should count once"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
}
|
||||
|
||||
async function testHydrationSkipsStaleEventsAndRollsOff() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = { type: "a", value: `${WATCH_HISTORY_KIND}:pk:test-hydrate` };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const horizonSeconds = VIEW_COUNT_BACKFILL_MAX_DAYS * 86_400;
|
||||
|
||||
const staleEvent = {
|
||||
id: "evt-stale",
|
||||
pubkey: "pub-hydrate",
|
||||
created_at: nowSeconds - horizonSeconds - 60,
|
||||
};
|
||||
const rolledOffEvent = {
|
||||
id: "evt-rolled-off",
|
||||
pubkey: "pub-hydrate",
|
||||
created_at: nowSeconds - VIEW_COUNT_DEDUPE_WINDOW_SECONDS - 10,
|
||||
};
|
||||
const recentEvent = {
|
||||
id: "evt-recent",
|
||||
pubkey: "pub-hydrate",
|
||||
created_at: nowSeconds - 5,
|
||||
};
|
||||
|
||||
harness.setEvents(pointerKey, [staleEvent, rolledOffEvent, recentEvent]);
|
||||
harness.setCountTotal(pointerKey, 2);
|
||||
|
||||
const updates = [];
|
||||
let token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const final = updates.at(-1);
|
||||
assert.ok(final, "expected hydration state update");
|
||||
assert.equal(
|
||||
final.total,
|
||||
2,
|
||||
"hydration should ignore events beyond the backfill window and keep unique buckets"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
|
||||
const futureEvent = {
|
||||
id: "evt-future",
|
||||
pubkey: "pub-hydrate",
|
||||
created_at: nowSeconds + VIEW_COUNT_DEDUPE_WINDOW_SECONDS + 5,
|
||||
};
|
||||
|
||||
harness.setEvents(pointerKey, [rolledOffEvent, recentEvent, futureEvent]);
|
||||
harness.setCountTotal(pointerKey, 3);
|
||||
|
||||
const secondUpdates = [];
|
||||
token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
secondUpdates.push({ ...state });
|
||||
});
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const final = secondUpdates.at(-1);
|
||||
assert.ok(final, "expected updated hydration state");
|
||||
assert.equal(
|
||||
final.total,
|
||||
3,
|
||||
"events outside the dedupe window should contribute when hydrating fresh data"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
}
|
||||
|
||||
async function testRelayCountAggregationUsesBestEstimate() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = { type: "e", value: "view-counter-multi-relay" };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
|
||||
harness.setEvents(pointerKey, []);
|
||||
harness.setCountTotal(pointerKey, {
|
||||
total: 5,
|
||||
best: { relay: "wss://relay.alpha", count: 5 },
|
||||
perRelay: [
|
||||
{ url: "wss://relay.alpha", ok: true, count: 5 },
|
||||
{ url: "wss://relay.beta", ok: true, count: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const final = updates.at(-1);
|
||||
assert.ok(final, "expected hydration update for aggregated relay counts");
|
||||
assert.equal(
|
||||
final.total,
|
||||
5,
|
||||
"identical relay COUNT responses should not be double-counted"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
}
|
||||
|
||||
async function testLocalIngestNotifiesImmediately() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = { type: "e", value: "view-counter-local" };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
harness.setEvents(pointerKey, []);
|
||||
harness.setCountTotal(pointerKey, 0);
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
|
||||
try {
|
||||
await flushPromises();
|
||||
const initial = updates.at(-1);
|
||||
assert.ok(initial, "expected initial state update");
|
||||
assert.equal(initial.total, 0, "initial total should start at zero");
|
||||
|
||||
const localEvent = {
|
||||
id: "evt-local",
|
||||
pubkey: "pub-local",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
ingestLocalViewEvent({ pointer, event: localEvent });
|
||||
|
||||
const latest = updates.at(-1);
|
||||
assert.equal(
|
||||
latest.total,
|
||||
1,
|
||||
"local ingestion should immediately notify subscribers with incremented total"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
}
|
||||
|
||||
async function testUnsubscribeStopsCallbacks() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = { type: "e", value: "view-counter-unsubscribe" };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
harness.setEvents(pointerKey, []);
|
||||
harness.setCountTotal(pointerKey, 0);
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
const updatesBeforeUnsubscribe = updates.length;
|
||||
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
assert.equal(
|
||||
harness.subscriptionCount(pointerKey),
|
||||
0,
|
||||
"tearing down the final handler should close the live subscription"
|
||||
);
|
||||
|
||||
harness.emit(pointerKey, {
|
||||
id: "evt-ignored",
|
||||
pubkey: "pub-unsubscribe",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
updates.length,
|
||||
updatesBeforeUnsubscribe,
|
||||
"no handlers should fire once the final subscriber has been removed"
|
||||
);
|
||||
}
|
||||
|
||||
async function testRecordVideoViewEmitsJsonPayload() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const pointer = {
|
||||
type: "a",
|
||||
value: `${WATCH_HISTORY_KIND}:pub:view-json`,
|
||||
relay: "wss://relay.example",
|
||||
};
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalEnsureSessionActor = nostrClient.ensureSessionActor;
|
||||
const originalSessionActor = nostrClient.sessionActor;
|
||||
const originalPubkey = nostrClient.pubkey;
|
||||
const originalWarn = console.warn;
|
||||
const originalNostrTools = window.NostrTools;
|
||||
|
||||
const warnings = [];
|
||||
console.warn = (...args) => {
|
||||
warnings.push(
|
||||
args
|
||||
.map((value) => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
})
|
||||
.join(" ")
|
||||
);
|
||||
};
|
||||
|
||||
window.NostrTools = {
|
||||
...window.NostrTools,
|
||||
getEventHash: () => "event-hash-record", // deterministic stub for tests
|
||||
signEvent: () => "event-sig-record",
|
||||
};
|
||||
|
||||
const publishCalls = [];
|
||||
nostrClient.pool = {
|
||||
publish(relayUrls, event) {
|
||||
publishCalls.push({ relays: relayUrls, event });
|
||||
return {
|
||||
on(type, handler) {
|
||||
if (type === "ok") {
|
||||
setTimeout(() => handler(), 0);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
nostrClient.relays = ["wss://relay.example"];
|
||||
nostrClient.ensureSessionActor = async () => {
|
||||
nostrClient.sessionActor = {
|
||||
pubkey: "pub-record-json",
|
||||
privateKey: "priv-record-json",
|
||||
};
|
||||
return "pub-record-json";
|
||||
};
|
||||
nostrClient.pubkey = "";
|
||||
|
||||
|
||||
try {
|
||||
const result = await nostrClient.recordVideoView(pointer, {
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
assert.ok(result.ok, "recordVideoView should succeed with stubbed handlers");
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
1,
|
||||
"publishViewEvent should be invoked once per recordVideoView call"
|
||||
);
|
||||
|
||||
const emittedEvent = publishCalls[0]?.event || null;
|
||||
assert.ok(emittedEvent, "publishViewEvent should enqueue an event for relays");
|
||||
assert.equal(
|
||||
typeof emittedEvent.content,
|
||||
"string",
|
||||
"recordVideoView should provide serialized view content"
|
||||
);
|
||||
|
||||
const payload = JSON.parse(emittedEvent.content);
|
||||
assert.deepEqual(
|
||||
payload,
|
||||
{
|
||||
target: {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
relay: pointer.relay,
|
||||
},
|
||||
created_at: createdAt,
|
||||
},
|
||||
"default view content should capture pointer metadata and timestamp"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.event.content,
|
||||
emittedEvent.content,
|
||||
"serialized payload should persist on the emitted event"
|
||||
);
|
||||
} finally {
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.ensureSessionActor = originalEnsureSessionActor;
|
||||
nostrClient.sessionActor = originalSessionActor;
|
||||
nostrClient.pubkey = originalPubkey;
|
||||
console.warn = originalWarn;
|
||||
window.NostrTools = originalNostrTools;
|
||||
}
|
||||
|
||||
const viewCounterWarnings = warnings.filter((message) =>
|
||||
message.includes("[viewCounter] Failed to ingest local view event")
|
||||
);
|
||||
assert.equal(
|
||||
viewCounterWarnings.length,
|
||||
0,
|
||||
"viewCounter.ingestLocalViewEvent should not reject JSON view payloads"
|
||||
);
|
||||
}
|
||||
|
||||
// Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays.
|
||||
async function testHydrationRefreshesAfterCacheTtl() {
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
assert.equal(
|
||||
localStorage.getItem(VIEW_COUNTER_STORAGE_KEY),
|
||||
null,
|
||||
"stale cache snapshot should be cleared when it exceeds the TTL"
|
||||
);
|
||||
|
||||
const pointer = CACHE_TTL_TEST_POINTER;
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const events = [
|
||||
{ id: "evt-ttl-1", pubkey: "pub-cache-ttl-1", created_at: nowSeconds - 15 },
|
||||
{ id: "evt-ttl-2", pubkey: "pub-cache-ttl-2", created_at: nowSeconds - 5 },
|
||||
];
|
||||
harness.setEvents(pointerKey, events);
|
||||
harness.setCountTotal(pointerKey, events.length);
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(pointer, (state) => {
|
||||
updates.push({ ...state });
|
||||
});
|
||||
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const initial = updates.at(0);
|
||||
assert.ok(initial, "expected initial state update for cache TTL test");
|
||||
assert.equal(
|
||||
initial.total,
|
||||
0,
|
||||
"expired cache entries should not hydrate subscribers with stale totals"
|
||||
);
|
||||
|
||||
const { list: listCalls, count: countCalls } = harness.getCallCounts();
|
||||
assert.ok(
|
||||
listCalls > 0 || countCalls > 0,
|
||||
"hydration should fetch fresh data from relays once the cache TTL elapses"
|
||||
);
|
||||
|
||||
const final = updates.at(-1);
|
||||
assert.ok(final, "expected final state update for cache TTL test");
|
||||
assert.equal(
|
||||
final.total,
|
||||
events.length,
|
||||
"totals should reflect fresh relay data after invalidating the expired cache"
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
}
|
||||
|
||||
await testHydrationRefreshesAfterCacheTtl();
|
||||
await testDedupesWithinWindow();
|
||||
await testHydrationSkipsStaleEventsAndRollsOff();
|
||||
await testLocalIngestNotifiesImmediately();
|
||||
await testUnsubscribeStopsCallbacks();
|
||||
await testRelayCountAggregationUsesBestEstimate();
|
||||
await testRecordVideoViewEmitsJsonPayload();
|
||||
|
||||
console.log("View counter tests completed successfully.");
|
||||
File diff suppressed because it is too large.
Load diff
+8
-5
@@ -17,6 +17,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
|
||||
<!-- Scripts: webtorrent, angular, etc. -->
|
||||
<script src="../js/tracking.js" defer></script>
|
||||
<script src="bundle.min.js"></script>
|
||||
<!--<script src="https://cdn.jsdelivr.net/combine/npm/webtorrent/webtorrent.min.js,npm/moment@2,npm/[email protected]/angular.min.js,npm/angular-[email protected]/angular-route.min.js,npm/angular-[email protected]/angular-sanitize.min.js,npm/angular-ui-grid@3/ui-grid.min.js,gh/matowens/ng-notify/dist/ng-notify.min.js,npm/ng-file-[email protected]/dist/ng-file-upload.min.js"></script>-->
|
||||
|
||||
@@ -43,11 +44,13 @@
|
||||
<header class="header-torrent">
|
||||
<div class="container">
|
||||
<div class="logo-container">
|
||||
<img
|
||||
src="../assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="bitvid Logo"
|
||||
class="bitvid-logo"
|
||||
/>
|
||||
<a href="../index.html" aria-label="Return to the bitvid home page">
|
||||
<img
|
||||
src="../assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="bitvid Logo"
|
||||
class="bitvid-logo"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+98
-16
@@ -1,29 +1,111 @@
|
||||
<section class="channel-profile-container">
|
||||
<!-- Modified banner wrapper for taller, rounded style -->
|
||||
<div
|
||||
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
|
||||
>
|
||||
<div class="profile-banner relative mb-6 rounded-lg shadow-lg">
|
||||
<!-- Increase the default height classes -->
|
||||
<img
|
||||
id="channelBanner"
|
||||
class="w-full h-48 md:h-64 object-cover"
|
||||
class="w-full h-48 md:h-64 object-cover rounded-lg"
|
||||
src=""
|
||||
alt="Banner"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50 rounded-lg"></div>
|
||||
|
||||
<div class="absolute bottom-4 left-4 flex items-center">
|
||||
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
||||
<img
|
||||
id="channelAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-4 left-4 right-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
||||
<img
|
||||
id="channelAvatar"
|
||||
src="assets/svg/default-profile.svg"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4 text-white">
|
||||
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
|
||||
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 text-white">
|
||||
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
|
||||
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
||||
<div class="flex items-center gap-2 self-start sm:self-end">
|
||||
<button
|
||||
id="zapButton"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Send a zap"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="channelShareBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Share channel"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
alt="Share channel"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="relative inline-block overflow-visible"
|
||||
data-more-menu-wrapper="true"
|
||||
>
|
||||
<button
|
||||
id="channelMoreBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Channel options"
|
||||
data-more-dropdown="channel-profile"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-channel-profile"
|
||||
class="hidden absolute right-0 top-12 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="copy-npub"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Copy npub
|
||||
</button>
|
||||
<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"
|
||||
data-context="channel-profile"
|
||||
data-blacklist-menu="true"
|
||||
>
|
||||
Blacklist channel
|
||||
</button>
|
||||
<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="block-author"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Block channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="report"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<!-- views/history.html -->
|
||||
<section id="watchHistoryView" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-100">Watch History</h2>
|
||||
<p class="text-sm text-gray-400">
|
||||
Review the videos you recently played, grouped by day.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2" id="watchHistoryActions">
|
||||
<button
|
||||
type="button"
|
||||
data-history-action="clear-cache"
|
||||
class="rounded-full bg-gray-800 px-4 py-2 text-sm font-medium text-gray-100 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Clear local cache
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-history-action="republish"
|
||||
class="rounded-full bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
>
|
||||
Republish now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="watchHistoryPrivacyBanner"
|
||||
class="hidden rounded-lg border border-blue-500/40 bg-blue-900/30 p-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p id="watchHistoryPrivacyMessage" class="text-sm text-blue-100">
|
||||
Your history stays encrypted. Share metadata with relays to sync
|
||||
thumbnails across devices?
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
id="watchHistoryPrivacyToggle"
|
||||
type="button"
|
||||
class="rounded-full bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
>
|
||||
Share metadata
|
||||
</button>
|
||||
<button
|
||||
id="watchHistoryPrivacyDismiss"
|
||||
type="button"
|
||||
class="rounded-full bg-transparent px-3 py-2 text-sm font-medium text-blue-200 hover:bg-blue-800/40 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="watchHistoryInfo"
|
||||
class="rounded-lg bg-gray-800/60 p-4 text-sm text-gray-200"
|
||||
>
|
||||
Thumbnails and titles are hydrated locally from your device caches.
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="watchHistoryError"
|
||||
class="hidden rounded-lg border border-red-500/50 bg-red-900/40 p-3 text-sm text-red-100"
|
||||
role="alert"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div id="watchHistoryLoading">
|
||||
<div id="watchHistoryStatus">
|
||||
<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="watchHistoryGrid" class="watch-history-grid hidden"></div>
|
||||
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
|
||||
Your watch history is empty. Watch some videos to populate this list.
|
||||
</p>
|
||||
|
||||
<button
|
||||
id="watchHistoryLoadMore"
|
||||
type="button"
|
||||
class="hidden w-full rounded-full bg-gray-800 px-4 py-2 text-sm font-medium text-gray-100 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
|
||||
<div id="watchHistorySentinel" class="hidden h-1 w-full"></div>
|
||||
|
||||
<script type="module" src="js/historyView.js"></script>
|
||||
</section>
|
||||
@@ -8,6 +8,11 @@
|
||||
id="videoList"
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||
>
|
||||
<!-- Videos will be dynamically inserted here -->
|
||||
<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 recent videos…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -9,5 +9,12 @@
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
"
|
||||
></div>
|
||||
>
|
||||
<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 subscriptions…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Reference in new issue
Block a user