mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f316f915b | ||
|
|
002790a01d | ||
|
|
143abc8fd1 | ||
|
|
6376e73951 | ||
|
|
e2935c23fd | ||
|
|
96bbeef040 | ||
|
|
b78337af6d | ||
|
|
d58d411180 | ||
|
|
5463677237 | ||
|
|
ca959cb33e | ||
|
|
4d367109d5 | ||
|
|
b5ee063260 | ||
|
|
77fa149cc3 | ||
|
|
35e84971b8 | ||
|
|
12b1c1e7f8 | ||
|
|
30895e4444 | ||
|
|
0a3057d081 | ||
|
|
f61563d2e2 | ||
|
|
614ff3b1f1 | ||
|
|
3792b218a9 | ||
|
|
492c591f06 | ||
|
|
5d7df0eb61 | ||
|
|
89986759e0 | ||
|
|
cc02228548 | ||
|
|
61b893e94d | ||
|
|
3b0465067f | ||
|
|
e2540c729f | ||
|
|
18981f546c | ||
|
|
a6e67e2284 | ||
|
|
b02d275bdb | ||
|
|
13430ccea0 | ||
|
|
599c40c3cc | ||
|
|
c092e10188 | ||
|
|
e36e2c0aee | ||
|
|
661d6965e4 | ||
|
|
4ed5c97678 | ||
|
|
32686c5ef0 | ||
|
|
fd6919b65f | ||
|
|
c081d950b2 | ||
|
|
e9e21f733d | ||
|
|
c870d35f7c | ||
|
|
507f09dff5 | ||
|
|
4e42db2667 | ||
|
|
0b0d3628ee | ||
|
|
0cf50ee7e9 | ||
|
|
b54a0209d9 | ||
|
|
0d7eb42643 | ||
|
|
419bb02610 | ||
|
|
e05006f0b7 | ||
|
|
da0d17c8f9 | ||
|
|
0bf0c1be5c | ||
|
|
70dec62d8f | ||
|
|
87d0caed5b | ||
|
|
d6fd0fd8e9 | ||
|
|
55e3be4624 | ||
|
|
a5f9a33465 | ||
|
|
5b57fc644d | ||
|
|
86be0284d9 | ||
|
|
79e592b4a6 | ||
|
|
de358dd643 | ||
|
|
cb58c73b17 | ||
|
|
98dd51509d | ||
|
|
ebbddf77bc | ||
|
|
f9e2b975d1 | ||
|
|
0434401596 | ||
|
|
c99f6834cd | ||
|
|
7d389bb33d | ||
|
|
0addd59226 | ||
|
|
e9ec0b790e | ||
|
|
0ec2d48142 | ||
|
|
55b2dc77e7 | ||
|
|
ebc306c928 | ||
|
|
eec5b58175 | ||
|
|
4f266e25db | ||
|
|
9361752b54 | ||
|
|
073b62459a | ||
|
|
55429a705c | ||
|
|
ff83438dee | ||
|
|
879330b2a2 | ||
|
|
04865ffdc5 | ||
|
|
c3f08a8178 | ||
|
|
eaae8066da | ||
|
|
cb18615c1b | ||
|
|
3573b7a287 | ||
|
|
0a929e7b6d | ||
|
|
ccf65f7ccd | ||
|
|
2f0d8ad07b | ||
|
|
df20aaa114 | ||
|
|
9d6f17fedb | ||
|
|
fb07d706b1 | ||
|
|
31cad16403 | ||
|
|
23c3681b21 | ||
|
|
d53ce6742a | ||
|
|
45749f1977 | ||
|
|
004c6426a8 | ||
|
|
484343b027 | ||
|
|
46ef2b8123 | ||
|
|
10751a8fb9 | ||
|
|
9d7efce44e | ||
|
|
151c547490 | ||
|
|
8855fac640 | ||
|
|
a43f8e634d | ||
|
|
934d7c6cce | ||
|
|
9122a14493 | ||
|
|
86464f9798 | ||
|
|
b84f8c6031 | ||
|
|
17dda8f2c1 | ||
|
|
ad0713b4cb | ||
|
|
6ecbef207f | ||
|
|
ae6ba0d9e0 | ||
|
|
0843c70123 | ||
|
|
a97ce36c70 | ||
|
|
7995f92a38 | ||
|
|
ea81e3bf32 | ||
|
|
9531fb21be | ||
|
|
daf39ce361 | ||
|
|
7070eea35e | ||
|
|
814bb65ffe | ||
|
|
749818fa13 | ||
|
|
f8c227e1ed | ||
|
|
dbceaa98df | ||
|
|
bf552d3816 | ||
|
|
c1ae2c8c9f | ||
|
|
efe4ff99eb | ||
|
|
1a6b304daa | ||
|
|
fe0e023bd7 | ||
|
|
1db10f7f81 | ||
|
|
90bbcf0ad4 | ||
|
|
35898a95a5 | ||
|
|
3bf380dfc6 | ||
|
|
2e516ca989 | ||
|
|
3fe9f75700 | ||
|
|
cc10bc33f0 | ||
|
|
30e66fe969 | ||
|
|
b5cde1b977 | ||
|
|
384892eea2 | ||
|
|
c3b16baf0d | ||
|
|
3eb63f28fb | ||
|
|
d2c4758d6d | ||
|
|
c76f780264 | ||
|
|
58d28019e9 | ||
|
|
bc392b7088 | ||
|
|
bece17afda | ||
|
|
c699d3f438 | ||
|
|
6fb26fede5 | ||
|
|
73be11403b | ||
|
|
56cf4aa8e6 | ||
|
|
38e010c828 | ||
|
|
19bcc8ca6b | ||
|
|
cfa2e2f8bf | ||
|
|
ee2175a7ad | ||
|
|
47a78a083c | ||
|
|
723c8f8bf8 | ||
|
|
4529a59499 | ||
|
|
41b2b8fba6 | ||
|
|
dba7763000 | ||
|
|
ac29eea29c | ||
|
|
98fb9b437d | ||
|
|
38f70b666d | ||
|
|
e7939f52c1 | ||
|
|
f4925a2726 | ||
|
|
715087992e | ||
|
|
ddf1a560a8 | ||
|
|
c0290b33a7 | ||
|
|
79aa7f7669 | ||
|
|
9834fc7d9f | ||
|
|
25e53c08e3 | ||
|
|
8cf1ff0037 | ||
|
|
4fd16c5a8e | ||
|
|
d1d481555d | ||
|
|
8ad39b5cf6 | ||
|
|
b3b467755a | ||
|
|
957a3ae6fa | ||
|
|
58fd6ef7d5 | ||
|
|
2bafecfb50 | ||
|
|
f04c21004a | ||
|
|
63a178263a | ||
|
|
4d97493003 | ||
|
|
bffd98247e | ||
|
|
a453ead95e | ||
|
|
9a9dcb239f | ||
|
|
8981f1cb6a | ||
|
|
ff3d9fa05c | ||
|
|
251290ab2e | ||
|
|
3b2874a5c9 | ||
|
|
38ec169b22 | ||
|
|
8158eb2089 | ||
|
|
48cf7ed4f8 | ||
|
|
8e8ab147fb | ||
|
|
19f62d545d | ||
|
|
7286295a3a | ||
|
|
24b12d9108 | ||
|
|
1bb935628e | ||
|
|
7a923ae77c | ||
|
|
26c4420293 | ||
|
|
4f75f2dff4 | ||
|
|
4926cb5242 | ||
|
|
d9d07d8ff9 | ||
|
|
82f779099a | ||
|
|
332fc156bf | ||
|
|
d35f396c2f | ||
|
|
c4cc89af29 | ||
|
|
f80ebef5c3 | ||
|
|
e3b2220269 | ||
|
|
b5f218b221 | ||
|
|
84948101f3 | ||
|
|
c39208c629 | ||
|
|
62ccc468f9 | ||
|
|
33f96b762a | ||
|
|
7c9443e712 | ||
|
|
beb8b54d14 | ||
|
|
e6b465ccca | ||
|
|
287d954823 | ||
|
|
1c805b053e | ||
|
|
908435f476 | ||
|
|
b773e1ec21 | ||
|
|
c0ffde6462 | ||
|
|
d84dbe5fae | ||
|
|
793375a4fb | ||
|
|
c93075bb22 | ||
|
|
aa4e86758a | ||
|
|
9d57a43c98 | ||
|
|
e76107c8b2 | ||
|
|
8dd4b40b5a | ||
|
|
1e42b2af56 | ||
|
|
24823ebed9 | ||
|
|
1f0fb6ac87 | ||
|
|
9e296d4910 | ||
|
|
f2c7520a40 | ||
|
|
05f3f88795 | ||
|
|
8fd5ce3c88 | ||
|
|
0fd54abde8 | ||
|
|
5d04571b6b | ||
|
|
a39446f1e0 | ||
|
|
01a9b2f0bd | ||
|
|
8925eee72e | ||
|
|
16562cbb39 | ||
|
|
c9ea82a6dc | ||
|
|
76e063e8f6 | ||
|
|
e07d4c4cab | ||
|
|
6dd970d55b | ||
|
|
ad4f56a1d4 | ||
|
|
2529ab1314 | ||
|
|
80438555f5 | ||
|
|
5d3c2a5f35 | ||
|
|
22e93d83dd | ||
|
|
991da0f2d5 | ||
|
|
0824bbca98 | ||
|
|
69ea5b0919 | ||
|
|
7550007fe6 | ||
|
|
2d20ecb6ca | ||
|
|
fe89e54a2a | ||
|
|
c2620a7564 | ||
|
|
d843a6aa51 | ||
|
|
2132a913d5 | ||
|
|
99e8bd0c78 | ||
|
|
90e3649506 | ||
|
|
ce94e2c411 | ||
|
|
dbc8a5278a | ||
|
|
bbad8f7d48 | ||
|
|
b990578027 | ||
|
|
a37e82fe03 | ||
|
|
c8600f824e | ||
|
|
73a7a48c5c | ||
|
|
f6e1bf2424 | ||
|
|
7672726088 | ||
|
|
57cde4460a | ||
|
|
62fe113bf4 | ||
|
|
fd400f6fb2 | ||
|
|
9041f9c0b7 | ||
|
|
1d8fa2d0c0 | ||
|
|
84002d439d | ||
|
|
109a0a7aec | ||
|
|
3d56cc3835 | ||
|
|
7d88cb1dbc | ||
|
|
94a203f594 | ||
|
|
57cf16a0f4 | ||
|
|
94eb750854 | ||
|
|
7c3af36003 | ||
|
|
b1b0b0f625 | ||
|
|
24309c1356 | ||
|
|
a940d0cdfd | ||
|
|
4a588ed5d5 | ||
|
|
6bb089f4c1 | ||
|
|
f81c97e5a8 | ||
|
|
58d2611df7 | ||
|
|
edc03bf152 | ||
|
|
be0e16efec | ||
|
|
205a9fdbda | ||
|
|
88ad16326b | ||
|
|
8a73d6d1fb | ||
|
|
7b324593c5 | ||
|
|
1421a0c198 | ||
|
|
21b095e45f | ||
|
|
cf34e28a47 | ||
|
|
ef6b8bf1cf | ||
|
|
ad58ff04a3 | ||
|
|
f579c965a7 | ||
|
|
b46135cd36 | ||
|
|
8baeab2b0f | ||
|
|
f33cd66642 | ||
|
|
e0a9fae4c8 | ||
|
|
2fb2a83272 | ||
|
|
2e77cdbe04 | ||
|
|
361185ec02 | ||
|
|
0abd4249d1 | ||
|
|
1e1600984c | ||
|
|
05a3254372 | ||
|
|
2cfcbc8366 | ||
|
|
7c295b57be | ||
|
|
cbf5203dcf | ||
|
|
97e74875ae | ||
|
|
61815ba289 | ||
|
|
9fa359f9b5 | ||
|
|
3d04c90ddc | ||
|
|
1dfb9f2877 | ||
|
|
2b42d0ada6 | ||
|
|
8a4ab536da | ||
|
|
06c979569f | ||
|
|
9498ba3465 | ||
|
|
2e26242153 | ||
|
|
d950fd9470 | ||
|
|
2e6e7012a6 | ||
|
|
e77db9d07d | ||
|
|
245f6bd0d1 | ||
|
|
762dbe5dcc | ||
|
|
c3ed4bc6ea | ||
|
|
b1a493f113 | ||
|
|
84b70475d9 | ||
|
|
a4eb4f2367 | ||
|
|
19ac142d31 | ||
|
|
7fad136f4a | ||
|
|
041aa58398 | ||
|
|
212f48c0e3 | ||
|
|
f6729b8e35 | ||
|
|
7ca48bf6de | ||
|
|
9112aeadcc | ||
|
|
54b5358854 | ||
|
|
09fc89bda6 | ||
|
|
02bd23ccaf | ||
|
|
6cfaf38ce1 | ||
|
|
b4f7ed1e54 | ||
|
|
138094f57d | ||
|
|
6e4323d9f6 | ||
|
|
a1a4a3bbe1 | ||
|
|
8a0280902f | ||
|
|
9945c024fc | ||
|
|
23377591ce | ||
|
|
97a8ff814e | ||
|
|
2cc0697b17 | ||
|
|
15a5bbe499 | ||
|
|
4fec220b7b | ||
|
|
c2c7f6445d | ||
|
|
fb0c2a31ea | ||
|
|
bf63808007 | ||
|
|
aff2a81bf5 | ||
|
|
7f586a38c4 | ||
|
|
632ee88b90 | ||
|
|
0320492c54 | ||
|
|
57655aa304 | ||
|
|
48410b1abb | ||
|
|
32ab19e915 | ||
|
|
10adeddd82 | ||
|
|
43bda18a52 | ||
|
|
d8f6da2b2c | ||
|
|
6d1bbea045 | ||
|
|
385e7c6476 | ||
|
|
8bdfd672aa | ||
|
|
e97d1bb084 | ||
|
|
7149255004 | ||
|
|
602fa753b1 | ||
|
|
6e838aedd9 | ||
|
|
d938d01a4c | ||
|
|
d17047c6ea | ||
|
|
bf088532dc | ||
|
|
d9388db649 | ||
|
|
a22b904ddc | ||
|
|
628a671277 | ||
|
|
92cc05f1f6 | ||
|
|
9aa5a360d6 | ||
|
|
6684da3e5f | ||
|
|
7fcc123cb4 | ||
|
|
ff30678afc | ||
|
|
da328db9c9 | ||
|
|
38924abdcc | ||
|
|
4e6383ddeb | ||
|
|
61aa55e70b | ||
|
|
e92bfee9c4 | ||
|
|
b6dc562a3e | ||
|
|
c842c4dbb3 | ||
|
|
672c7cb052 | ||
|
|
cea8fcf6de | ||
|
|
8ff22e1e81 | ||
|
|
061bda8aec | ||
|
|
61c8b3a8ee | ||
|
|
db0461e9c7 | ||
|
|
a1505b7538 | ||
|
|
297fbb2450 | ||
|
|
7510ad2574 | ||
|
|
1c99d4c2f3 | ||
|
|
1298443c00 | ||
|
|
b9ac6dd9ca | ||
|
|
c87d9d96a4 | ||
|
|
ea25e0a92a | ||
|
|
9416efdf1a | ||
|
|
f473fc69e3 | ||
|
|
bea08e3b3c | ||
|
|
b44373a4b6 | ||
|
|
ecd3f4026e | ||
|
|
af7738b4d8 | ||
|
|
28def68012 | ||
|
|
95b2f23d66 | ||
|
|
63274d4c43 | ||
|
|
b0774de823 | ||
|
|
8708431083 | ||
|
|
d378b4ca17 | ||
|
|
eae0ea47b0 | ||
|
|
2bb49dd404 | ||
|
|
cf99059564 | ||
|
|
c06233947f | ||
|
|
d1a9b8c79d | ||
|
|
eaa81f56ab | ||
|
|
a025dd210f | ||
|
|
1d977f5016 | ||
|
|
6b1602d170 | ||
|
|
8f004e5947 | ||
|
|
09c8e275c4 | ||
|
|
2599bdaabc | ||
|
|
ff452ebe77 | ||
|
|
7a0cf00810 | ||
|
|
c70a107d2f | ||
|
|
c8a79316d0 | ||
|
|
cd09dbc68d | ||
|
|
b944560a08 | ||
|
|
b2ceb197f2 | ||
|
|
1749351fa2 | ||
|
|
9f2e06744c | ||
|
|
9e509bb782 | ||
|
|
5f4f025a1b | ||
|
|
fa583f0685 | ||
|
|
284bab6cff | ||
|
|
259bd3b281 | ||
|
|
0e29b07d5b | ||
|
|
b6e1219d09 | ||
|
|
713e4eeab6 | ||
|
|
2f1193dbf2 | ||
|
|
434f5c3fa5 | ||
|
|
48ad71ae80 | ||
|
|
c5e95c2143 | ||
|
|
9e2fb61182 | ||
|
|
ca4c53e081 | ||
|
|
234f1f1a4c | ||
|
|
eae6b7b925 | ||
|
|
bc9ddaa12a | ||
|
|
a20dc838dc | ||
|
|
87ab3f6435 | ||
|
|
8d47b32193 | ||
|
|
41a0d15e7a | ||
|
|
9309ac11ea | ||
|
|
9c7884d4b7 | ||
|
|
a9be08be1d | ||
|
|
03950f4555 | ||
|
|
e543de8bb4 | ||
|
|
78b5677d58 | ||
|
|
5d4c0d494b | ||
|
|
7de5a0302e | ||
|
|
825d3c7ce4 | ||
|
|
72d4c3ff30 | ||
|
|
5a243eea50 | ||
|
|
faa72e4032 | ||
|
|
0de482efc0 | ||
|
|
8db49e7be2 | ||
|
|
d3d1e0c2e7 | ||
|
|
cffdc02d40 | ||
|
|
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 |
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/
|
||||
@@ -8,14 +8,14 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
|
||||
* **Main** is the production track. Anything merged here must preserve today’s UX and magnet safety guarantees. Rollbacks should be painless: keep commits atomic, avoid destructive migrations, and leave feature flags in a known-good default (`false` unless product explicitly flips them).
|
||||
* **Unstable** is our experimentation lane. Gate risky behavior behind feature flags defined in `js/constants.js` and document the toggle/rollback plan in PR descriptions.
|
||||
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately and annotate the AGENTS.md changelog with remediation tips so future agents do not repeat the mistake.
|
||||
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately, call this out in the PR, and annotate the AGENTS.md changelog with remediation tips. **Note:** These regressions usually surface in dev logs and tests around playback/magnet helpers—reference them when triaging so future agents don’t repeat the mistake.
|
||||
|
||||
## 2. Mission: URL‑First Playback with WebTorrent Fallback
|
||||
|
||||
* **Goal:** Always deliver smooth playback while keeping hosting costs low.
|
||||
* **Primary transport:** A hosted video URL (MP4/WebM/HLS/DASH) that the `<video>` element can stream directly.
|
||||
* **Fallback transport:** A WebTorrent magnet that includes browser‑safe trackers plus optional HTTP hints.
|
||||
* **Runtime behavior:** Call `playVideoWithFallback({ url, magnet })` (see `js/playbackUtils.js`). It probes the URL first; only when that fails should WebTorrent start.
|
||||
* **Runtime behavior:** Call `bitvidApp.playVideoWithFallback({ url, magnet })` in `js/app.js`. This function orchestrates URL probing and magnet fallback, delegating low-level stream handling to `js/services/playbackService.js`. `js/playbackUtils.js` now provides magnet/session helper utilities rather than the playback entry point itself.
|
||||
* **Content contract:** Bitvid posts (Nostr kind `30078`) must include a `title` and at least one of `url` or `magnet`. Prefer to publish both along with optional `thumbnail`, `description`, and `mode` fields. When mirroring to NIP‑94 (`kind 1063`), copy the hosted URL and (optionally) the magnet so other clients can discover the same asset.
|
||||
|
||||
---
|
||||
@@ -24,9 +24,10 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
|
||||
* **Use the helpers every time:** Always run inbound values through `safeDecodeMagnet()` before playback or normalization, and call `normalizeAndAugmentMagnet()` (see `js/magnetUtils.js`) to append `ws=` / `xs=` hints without mutating hashes.
|
||||
* **Keep magnets raw:** Persist the literal `magnet:?xt=urn:btih:...` string. Decode once, then feed that exact value to WebTorrent or storage. Never rely on re-encoding helpers that might alter casing.
|
||||
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
|
||||
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and can corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
|
||||
* **HTTP hints:** Encourage HTTPS `ws=` web seeds (file roots only) and HTTPS `xs=` pointers to `.torrent` files so fallback peers warm quickly.
|
||||
* **Tracker policy:** Browser code must ship WSS trackers only. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers.
|
||||
* **Tracker policy:** Browser code must ship **WSS trackers only**. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers. (If you introduce a new tracker set or feature flag, update `js/constants.js` and mention it here.)
|
||||
* **Validation reference:** When in doubt about legacy infohash handling or normalization, consult the existing tests that cover legacy/bare infohash inputs and magnet normalization. If you extend helper behavior, extend those tests alongside the code.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,29 +47,41 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
|
||||
Run this script before shipping notable UI changes, especially around upload/playback flows. Automate when possible, but manual verification is required for releases.
|
||||
|
||||
1. **Smoke test the modal**
|
||||
|
||||
* Launch the site, open the Upload modal, and confirm required-field validation (title + one of URL/magnet).
|
||||
* Submit with URL only, magnet only, and both; ensure success states are clear.
|
||||
2. **URL-first playback**
|
||||
|
||||
* Publish a post with both URL and magnet. Load the card and verify the player fetches the hosted URL first (check network tab or logs).
|
||||
* Temporarily block the URL (e.g., devtools throttling or offline). Confirm playback seamlessly falls back to the magnet.
|
||||
3. **Magnet hygiene**
|
||||
|
||||
* Paste an encoded magnet; ensure `safeDecodeMagnet()` returns the raw value and the final string still includes the original `xt` hash.
|
||||
* Confirm `normalizeAndAugmentMagnet()` appends `ws=`/`xs=` hints without corrupting parameters.
|
||||
4. **P2P hints**
|
||||
|
||||
* Verify magnets authored through the modal include HTTPS `ws=` and optional `xs=` values when provided.
|
||||
* Ensure tracker lists come from `js/constants.js` and remain WSS-only.
|
||||
5. **Cross-browser sanity**
|
||||
|
||||
* Spot-check playback in Chromium and Firefox (desktop) to ensure no console errors related to CORS, Range requests, or tracker connections.
|
||||
|
||||
Document the run in PR descriptions so QA can cross-reference results.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Notes for Agents
|
||||
## 6. Moderation Quickstart (Bitvid)
|
||||
|
||||
* **Nostr interoperability:** Keep kind `30078` events as the source of truth and optionally mirror to kind `1063` so external clients see the hosted URL.
|
||||
* **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI.
|
||||
* **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above.
|
||||
The moderation and admin list tooling remain active in Bitvid and must follow Nostr moderation policies and QA steps. Review `docs/moderation/README.md` and these key expectations:
|
||||
|
||||
1. **Report thresholds:** Parse NIP-56 reports and compute `trustedReportCount` using **only F1 followers**.
|
||||
2. **Default behaviors:** Blur thumbnails at ≥3 F1 `nudity` reports and block autoplay at ≥2.
|
||||
3. **Admin lists:** Use NIP‑51 lists — `10000` for mute/downrank and `30000` for admin hard-hide/whitelist. Ensure these list IDs are correctly handled in the profile modal.
|
||||
4. **Profile Modal Integration:** Admin list toggles and moderation controls in `ProfileModalController` must trigger local state changes and reflect mutations visually without breaking cached profiles.
|
||||
5. **User overrides:** Add a “show anyway” option to any blurred or blocked content.
|
||||
6. **Testing:** Run all cases from `docs/moderation/testing.md` before shipping changes. Confirm blur, hide, and override flows function correctly across sessions.
|
||||
7. **Telemetry and privacy:** Log moderation decisions locally for debugging without leaking PII.
|
||||
8. **QA demo:** Record a ≤90 s screen demo showing blur/hide/override flows when submitting major moderation work.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +91,70 @@ Document the run in PR descriptions so QA can cross-reference results.
|
||||
`{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`.
|
||||
* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable.
|
||||
* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied.
|
||||
* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
|
||||
* **Playback orchestration:** `bitvidApp.playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
|
||||
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
|
||||
|
||||
---
|
||||
|
||||
## 8. Architectural Guide: `bitvidApp` vs. UI Controllers
|
||||
|
||||
To keep the codebase maintainable, we are moving complex UI logic from `js/app.js` into dedicated controller classes (e.g., `ProfileModalController`, `UploadModal`). **This is an architectural direction for `unstable`**—some controllers may not exist yet. When working on these components, follow these core principles:
|
||||
|
||||
#### **Principle: Separation of Concerns**
|
||||
|
||||
* **`bitvidApp` (js/app.js): The Orchestrator.** Its main job is to manage application-wide state, wire up services, and orchestrate high-level actions. It should **not** directly manipulate the DOM elements of complex components like modals. Instead, it should instantiate their controllers and communicate with them through a clean API.
|
||||
|
||||
* **UI Controllers (e.g., `js/ui/profileModalController.js`): The Specialists.** A controller is responsible for a specific piece of the UI. It manages its own DOM elements, handles user interactions within its scope, and uses services and callbacks provided by `bitvidApp` to perform actions.
|
||||
|
||||
#### **State Management: The Source of Truth**
|
||||
|
||||
The single source of truth for shared application state (like saved profiles, relay lists, and block lists) lives in modules like `js/state/cache.js` and services like `relayManager`.
|
||||
|
||||
1. **`bitvidApp` Writes State:** `bitvidApp` is responsible for calling the functions that mutate this central state (e.g., `setSavedProfiles`, `userBlocks.addBlock`).
|
||||
2. **`bitvidApp` Notifies the Controller:** After updating the state, `bitvidApp` must call a method on the controller to tell it that its data is stale and it needs to re-render.
|
||||
|
||||
* **Example:** When a user logs in, `bitvidApp` updates the active user and saved profiles, then calls `this.profileModalController.handleAuthLogin(detail)` so the modal can update its own display.
|
||||
3. **Controller Reads State:** The controller receives functions to *read* this state in its constructor (via the `state` and `services` objects). It should **always** use these functions (e.g., `this.state.getSavedProfiles()`, `this.services.userBlocks.getBlocked()`) to get the data it needs to render.
|
||||
|
||||
#### **Execution Flow: Callbacks**
|
||||
|
||||
When a user performs an action inside a modal that needs to affect the whole application (like switching a profile or updating relays), the flow is:
|
||||
|
||||
1. **User Action:** A user clicks a "Switch Profile" button inside the modal.
|
||||
2. **Controller Event Handler:** The controller's internal event listener fires.
|
||||
3. **Controller Invokes Callback:** The controller calls a callback function passed to it by `bitvidApp` (e.g., `this.callbacks.onRequestSwitchProfile({ pubkey })`). It does *not* contain the logic for switching profiles itself.
|
||||
4. **`bitvidApp` Executes the Logic:** `bitvidApp` provides the implementation for that callback (e.g., `handleProfileSwitchRequest`). This method contains the actual logic to call the auth service, reload videos, and update the application state.
|
||||
|
||||
#### **Checklist for Refactoring from `bitvidApp` to a Controller:**
|
||||
|
||||
When moving a feature to a controller, follow these steps:
|
||||
|
||||
1. **Identify Logic:** Find the methods in `js/app.js` that directly read from or write to the modal's DOM (e.g., old `renderSavedProfiles` or `populateBlockedList` logic).
|
||||
2. **Create Controller Method:** Implement the new logic inside the controller. Ensure it gets its data from `this.services.*` or `this.state.*`.
|
||||
3. **Create `bitvidApp` Wrapper:** In `js/app.js`, replace the old logic with a new, thin wrapper method. This wrapper should:
|
||||
|
||||
* a. Update any central application state if needed.
|
||||
* b. Call the new method on the controller instance (e.g., `this.profileModalController.populateBlockedList()`).
|
||||
4. **Wire Up Callbacks:** If the controller needs to trigger an app-wide action, ensure a callback is defined in the controller's constructor and provided by `bitvidApp` during instantiation.
|
||||
5. **Remove Old Code:** Once fully replaced, delete the old DOM manipulation logic and direct event listeners from `js/app.js`. All interactions should be routed through the controller.
|
||||
|
||||
#### **File Map (quick reference)**
|
||||
|
||||
* `js/app.js` — **`bitvidApp`** (orchestrator; owns high-level flows like `playVideoWithFallback` wiring)
|
||||
* `js/services/playbackService.js` — Lower-level playback stream handling
|
||||
* `js/playbackUtils.js` — Magnet/session helper utilities
|
||||
* `js/magnetUtils.js` — `safeDecodeMagnet`, `normalizeAndAugmentMagnet` (WSS trackers, `ws=`/`xs=` hints)
|
||||
* `js/constants.js` — Feature flags, canonical WSS tracker list
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
Please read these documents next.
|
||||
|
||||
* `docs/nostr-event-schemas.md`
|
||||
* `js/nostrEventSchemas.js`
|
||||
|
||||
And if you need to create new nostr kinds please keep the logic centralized there in `nostrEventSchemas.js` and the `nostr-event-schemas.md` up to date.
|
||||
|
||||
**End of AGENTS.md**
|
||||
@@ -11,7 +11,7 @@
|
||||
## Features
|
||||
|
||||
- **Decentralized Sharing**: Video sharing without central servers.
|
||||
- **Private Video Listings**: Share encrypted videos for added privacy.
|
||||
- **Private Video Listings**: Hide cards from shared grids so only the owner sees them.
|
||||
- **Nostr Integration**: Use Nostr keys for login and interaction.
|
||||
- **WebTorrent Streaming**: Stream videos directly through torrent technology.
|
||||
- **Developer-Friendly**: Open source and customizable for your needs.
|
||||
@@ -37,7 +37,7 @@ The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly
|
||||
- **Web seeds (`ws=`)**: HTTPS only. Point to a file root (e.g., `https://cdn.example.com/video/`). Mixed-content (`http://`) hints are rejected just like the modal message explains.
|
||||
- **Additional sources (`xs=`)**: Recommend adding an HTTPS `.torrent` link so WebTorrent peers can bootstrap faster.
|
||||
- **Trackers**: Bitvid’s browser client only connects to WSS trackers shipped in `js/constants.js`. Do not add UDP or plaintext HTTP trackers to published magnets.
|
||||
- **Private toggle**: Encrypts the listing for invited viewers.
|
||||
- **Private toggle**: Hides the card from shared grids so only you see it and adds a purple accent so the private state stands out.
|
||||
|
||||
### How playback works
|
||||
|
||||
@@ -62,7 +62,6 @@ To run **bitvid** locally:
|
||||
```
|
||||
|
||||
2. Start a local server:
|
||||
|
||||
- Using Python:
|
||||
```bash
|
||||
python -m http.server 8000
|
||||
@@ -73,12 +72,44 @@ 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` and themed via
|
||||
the shared tokens in `css/tokens.css`. Install dependencies once and lean on the
|
||||
package scripts to keep formatting, linting, and generated output consistent:
|
||||
|
||||
```bash
|
||||
npm install # install Prettier, Stylelint, and Tailwind toolchain
|
||||
npm run format # format CSS/HTML/JS/MD with Prettier + tailwindcss plugin
|
||||
npm run lint:css # enforce token usage and forbid raw hex colors
|
||||
npm run build:css # rebuild css/tailwind.generated.css from tailwind.source.css
|
||||
npm run check:css # CI-friendly guard that fails if tailwind.generated.css is dirty
|
||||
```
|
||||
|
||||
The build command compiles Tailwind with `tailwind.config.cjs`, runs it through
|
||||
the PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and
|
||||
emits the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
|
||||
regenerated file alongside any template changes so deployments pick up the
|
||||
latest styles.
|
||||
|
||||
### 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.
|
||||
- Tune `PLATFORM_FEE_PERCENT` (0–100) to keep a percentage of Lightning tips.
|
||||
When the fee is positive, BitVid routes the platform’s split to
|
||||
`PLATFORM_LUD16_OVERRIDE`, so set it to the Lightning address that should
|
||||
receive the sats (or publish a `lud16` on the Super Admin profile). Leave
|
||||
the fee at `0` to pass through every satoshi.
|
||||
- Populate `DEFAULT_RELAY_URLS_OVERRIDE` with WSS URLs to replace the bundled
|
||||
relay bootstrap list. Keep it empty to stick with the upstream defaults.
|
||||
- **`config.js`**:
|
||||
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
||||
- **`js/constants.js`**:
|
||||
@@ -86,6 +117,13 @@ To run **bitvid** locally:
|
||||
- **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
|
||||
|
||||
1. **Fork the repository** and create a new branch for your feature.
|
||||
@@ -135,6 +173,12 @@ Use the manual QA checklist before releases or when altering upload/playback flo
|
||||
|
||||
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
|
||||
|
||||
For automated DOM-focused regression coverage, run the Node-based test suite:
|
||||
|
||||
```bash
|
||||
node tests/profile-modal-controller.test.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -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 |
@@ -33,7 +33,12 @@
|
||||
|
||||
<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;
|
||||
|
||||
@@ -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,870 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
id="editNip71EventDetails"
|
||||
data-nip71-section="event-details"
|
||||
>
|
||||
<fieldset
|
||||
id="editNip71KindFieldset"
|
||||
class="space-y-2"
|
||||
data-nip71-fieldset="kind"
|
||||
>
|
||||
<legend class="text-sm font-semibold text-gray-200">
|
||||
NIP-71 event type
|
||||
</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Choose the video format. <span class="font-medium text-gray-300">Kind 21</span> is a full video, while
|
||||
<span class="font-medium text-gray-300">Kind 22</span> is a short.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="editNip71KindFieldset"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindLong"
|
||||
value="21"
|
||||
checked
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Normal video (kind 21)
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindShort"
|
||||
value="22"
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Short video (kind 22)
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
|
||||
<div>
|
||||
<label for="editNip71PublishedAt" class="block text-sm font-medium text-gray-200">
|
||||
Published at
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="editNip71PublishedAt"
|
||||
data-nip71-input="published_at"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Set the canonical publish time for cross-client ordering.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71AltText" class="block text-sm font-medium text-gray-200">
|
||||
Alt text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71AltText"
|
||||
data-nip71-input="alt"
|
||||
placeholder="Describe the visuals for screen readers"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Plain-language fallback shown when the media cannot load.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71Duration" class="block text-sm font-medium text-gray-200">
|
||||
Duration (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="editNip71Duration"
|
||||
min="0"
|
||||
step="1"
|
||||
data-nip71-input="duration"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Provide the runtime in whole seconds for timeline scrubbing.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71ContentWarning" class="block text-sm font-medium text-gray-200">
|
||||
Content warning
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71ContentWarning"
|
||||
data-nip71-input="content-warning"
|
||||
placeholder="e.g., Flashing lights"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Leave blank if the video is safe for all audiences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="editNip71Summary" class="block text-sm font-medium text-gray-200">
|
||||
Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="editNip71Summary"
|
||||
rows="3"
|
||||
data-nip71-input="summary"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Short synopsis used as the Nostr content field"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
This becomes the event content. Keep it concise for Nostr readers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
id="editNip71TagsFieldset"
|
||||
class="space-y-6 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
data-nip71-section="tags"
|
||||
>
|
||||
<legend class="relative text-sm font-semibold text-gray-200">
|
||||
<span class="inline-block -ml-2 rounded px-2 bg-black/30">NIP-71 tags</span>
|
||||
</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Use the controls below to describe media variants, captions, chapters, participants, and references.
|
||||
</p>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-gray-200">Image metadata (imeta)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="imeta"
|
||||
aria-describedby="editNip71ImetaHelp"
|
||||
>
|
||||
Add variant
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71ImetaHelp" class="text-xs text-gray-400">
|
||||
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
|
||||
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
|
||||
</p>
|
||||
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
|
||||
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="imeta">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
MIME type (<code>m</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="m"
|
||||
placeholder="video/mp4"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">RFC 2046 media type for this encoding.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Pixel dimensions (<code>dim</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="dim"
|
||||
placeholder="1920x1080"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Formatted as widthxheight.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Streaming URL (<code>url</code>)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/variant.m3u8"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">HTTPS variant URL shared across clients.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Magnet (<code>x</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="x"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Optional magnet pointer if the variant is seeded via P2P.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="image">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Image values</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="image"
|
||||
>
|
||||
Add image URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="image"></div>
|
||||
<template data-nip71-nested-template="image">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.example.com/poster.jpg"
|
||||
data-nip71-nested-field="image"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove image URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="fallback">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Fallback URLs</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="fallback"
|
||||
>
|
||||
Add fallback
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
|
||||
<template data-nip71-nested-template="fallback">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.backup.net/video.mp4"
|
||||
data-nip71-nested-field="fallback"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove fallback URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="service">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Services</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="service"
|
||||
>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="service"></div>
|
||||
<template data-nip71-nested-template="service">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.example.com"
|
||||
data-nip71-nested-field="service"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove service entry"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="imeta"
|
||||
>
|
||||
Remove variant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-gray-200">
|
||||
Caption & subtitle tracks (text-track)
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="text-track"
|
||||
aria-describedby="editNip71TextTracksHelp"
|
||||
>
|
||||
Add track
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71TextTracksHelp" class="text-xs text-gray-400">
|
||||
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
|
||||
</p>
|
||||
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
|
||||
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="text-track">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Track URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/subs.vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="type"
|
||||
placeholder="text/vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="language"
|
||||
placeholder="en"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="text-track"
|
||||
>
|
||||
Remove track
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-gray-200">Chapters & segments (segment)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="segment"
|
||||
aria-describedby="editNip71SegmentsHelp"
|
||||
>
|
||||
Add segment
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71SegmentsHelp" class="text-xs text-gray-400">
|
||||
Outline chapters with start and end times, titles, and optional thumbnails.
|
||||
</p>
|
||||
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
|
||||
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="segment">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Start (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="start"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">End (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="end"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="title"
|
||||
placeholder="Chapter title"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Thumbnail URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="thumbnail"
|
||||
placeholder="https://cdn.example.com/frame.jpg"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="segment"
|
||||
>
|
||||
Remove segment
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-gray-200">Hashtags (<code>t</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="t"
|
||||
>
|
||||
Add hashtag
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
|
||||
</p>
|
||||
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
|
||||
<template id="editNip71HashtagTemplate" data-nip71-template="t">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="t">
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="value"
|
||||
placeholder="#nostr"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="t"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-gray-200">Participants (<code>p</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="p"
|
||||
>
|
||||
Add participant
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
|
||||
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="p">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Participant pubkey</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="pubkey"
|
||||
placeholder="npub1... or hex"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Relay hint</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="relay"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="p"
|
||||
>
|
||||
Remove participant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-gray-200">Reference links (<code>r</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="r"
|
||||
>
|
||||
Add reference
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
|
||||
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="r">
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://example.com/context"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="r"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
|
||||
Hosted video URL (https)
|
||||
</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">
|
||||
<span id="editVideoVisibilityLabel" class="block text-sm font-medium text-gray-200">
|
||||
Visibility
|
||||
</span>
|
||||
<div
|
||||
class="space-y-3 rounded-md border border-gray-800 bg-black/30 p-3"
|
||||
role="group"
|
||||
aria-labelledby="editVideoVisibilityLabel"
|
||||
data-visibility-toggle
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-between">
|
||||
<div
|
||||
class="video-visibility-toggle"
|
||||
data-visibility-options
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="video-visibility-toggle__option"
|
||||
data-visibility-option="public"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="video-visibility-toggle__option"
|
||||
data-visibility-option="private"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editVideoIsPrivate"
|
||||
class="hidden"
|
||||
data-default-checked="false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400" data-visibility-helper>
|
||||
Private notes stay visible only to you. Switch back to public when you're ready to share.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="edit-field-button hidden"
|
||||
data-edit-target="editVideoIsPrivate"
|
||||
>
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editEnableComments" class="block text-sm font-medium text-gray-200">
|
||||
Enable comments
|
||||
</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"
|
||||
data-default-checked="true"
|
||||
checked
|
||||
/>
|
||||
<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>
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Whitelist Application Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
@@ -112,16 +114,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 -->
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Bug Report Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
@@ -112,16 +114,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 -->
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Content Appeals Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
@@ -105,16 +107,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 -->
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid General Feedback Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
@@ -119,16 +121,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 -->
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>bitvid Feature Request Form</title>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/style.css" />
|
||||
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
|
||||
<style>
|
||||
/* Override for form page to match modal field styling */
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+557
-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,565 @@
|
||||
</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="profileNavWallet"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
aria-controls="profilePaneWallet"
|
||||
aria-selected="false"
|
||||
>
|
||||
Wallet
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
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="profilePaneWallet"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavWallet"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from BitVid.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm text-gray-300" for="profileWalletUri">
|
||||
<span class="mb-2 block font-medium">Wallet Connect URI</span>
|
||||
<input
|
||||
id="profileWalletUri"
|
||||
type="url"
|
||||
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block text-sm text-gray-300" for="profileWalletDefaultZap">
|
||||
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
|
||||
<input
|
||||
id="profileWalletDefaultZap"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="500"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="profileWalletSave"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Save wallet
|
||||
</button>
|
||||
<button
|
||||
id="profileWalletTest"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Test connection
|
||||
</button>
|
||||
<button
|
||||
id="profileWalletDisconnect"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Disconnect wallet
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileWalletStatus"
|
||||
class="text-sm text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
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,177 @@
|
||||
<!-- 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"
|
||||
>
|
||||
<!-- NOTE: Keep Upload/Edit metadata fields mirrored when adjusting these sections. -->
|
||||
<div class="space-y-6" aria-labelledby="revertVersionPlaceholderHeading">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 id="revertVersionPlaceholderHeading" class="text-sm font-semibold text-gray-200">
|
||||
Revision metadata
|
||||
</h3>
|
||||
<p
|
||||
id="revertVersionPlaceholder"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
Select an earlier revision to inspect its metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Playback sources
|
||||
</h4>
|
||||
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Hosted URL</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Magnet</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
NIP-71 event metadata
|
||||
</h4>
|
||||
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Kind</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold text-gray-300">Duration</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="font-semibold text-gray-300">Summary</dt>
|
||||
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
NIP-71 media metadata
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Media variants, text tracks, chapters, hashtags, participants, and references will appear once a revision is selected.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
+139
-116
@@ -5,139 +5,174 @@
|
||||
-->
|
||||
<aside
|
||||
id="sidebar"
|
||||
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto fade-in fade-in-delay-100"
|
||||
class="fade-in fade-in-delay-100 sidebar-collapsed"
|
||||
data-state="collapsed"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Top Navigation Links -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<div class="bg-gray-800 rounded-lg shadow-md mt-4 p-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a
|
||||
href="#view=most-recent-videos"
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
<div class="sidebar__inner">
|
||||
<div class="sidebar-scroll">
|
||||
<!-- Primary navigation -->
|
||||
<section class="sidebar-panel sidebar-panel--primary">
|
||||
<div class="sidebar-panel__header">
|
||||
<button
|
||||
id="sidebarCollapseToggle"
|
||||
type="button"
|
||||
class="sidebar-collapse-toggle"
|
||||
aria-controls="sidebar"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<svg
|
||||
data-sidebar-chevron
|
||||
class="sidebar-collapse-toggle__icon sidebar-toggle-icon--rotated"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav" aria-label="Primary">
|
||||
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recent">
|
||||
<img
|
||||
src="assets/svg/recent-icon.svg"
|
||||
alt="Recent"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Recent</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=explore"
|
||||
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
>
|
||||
<a href="#view=explore" class="sidebar-nav-link" title="Explore">
|
||||
<img
|
||||
src="assets/svg/explore-icon.svg"
|
||||
alt="Explore"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Explore</span>
|
||||
</a>
|
||||
<a href="#view=history" class="sidebar-nav-link" title="History">
|
||||
<img
|
||||
src="assets/svg/history-icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">History</span>
|
||||
</a>
|
||||
<a
|
||||
id="subscriptionsLink"
|
||||
href="#view=subscriptions"
|
||||
class="hidden flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||
class="sidebar-nav-link hidden"
|
||||
title="Subscriptions"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/subscriptions-icon.svg"
|
||||
alt="Subscriptions"
|
||||
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Subscriptions</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer Links Toggle -->
|
||||
<div class="md:mt-auto">
|
||||
<div class="p-4">
|
||||
<!-- Footer Links Toggle -->
|
||||
<footer class="sidebar-footer" data-footer-state="collapsed">
|
||||
<button
|
||||
id="footerDropdownButton"
|
||||
type="button"
|
||||
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
|
||||
class="sidebar-dropup-trigger"
|
||||
aria-expanded="false"
|
||||
aria-controls="footerLinksContainer"
|
||||
>
|
||||
<!-- Arrow on the left side -->
|
||||
<img
|
||||
src="assets/svg/ellipsis.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon sidebar-dropup-trigger__glyph"
|
||||
/>
|
||||
<span id="footerDropdownText" class="sidebar-text">More</span>
|
||||
<svg
|
||||
id="footerDropdownIcon"
|
||||
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
||||
class="sidebar-dropup-trigger__chevron"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span id="footerDropdownText">More</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links Container -->
|
||||
<div id="footerLinksContainer" class="hidden">
|
||||
<hr class="border-gray-700" />
|
||||
<div class="p-4">
|
||||
<nav class="space-y-2 text-sm opacity-70">
|
||||
<a
|
||||
href="#view=about"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<!-- Footer Links Container -->
|
||||
<div
|
||||
id="footerLinksContainer"
|
||||
class="sidebar-dropup-panel"
|
||||
aria-hidden="true"
|
||||
data-state="collapsed"
|
||||
>
|
||||
<nav class="sidebar-dropup-panel__inner" aria-label="Secondary">
|
||||
<a href="#view=about" class="sidebar-nav-link sidebar-nav-link--sub" title="About">
|
||||
<img
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">About</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=community-guidelines"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Community Guidelines"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Guidelines</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=getting-started"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Getting Started"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Getting Started</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=roadmap"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=roadmap" class="sidebar-nav-link sidebar-nav-link--sub" title="Roadmap">
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=blog" class="sidebar-nav-link sidebar-nav-link--sub" title="Blog">
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
@@ -145,12 +180,14 @@
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Nostr"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
@@ -158,12 +195,14 @@
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="GitHub"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
@@ -171,12 +210,14 @@
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="βeacon"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
@@ -184,34 +225,32 @@
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="Beta"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=links" class="sidebar-nav-link sidebar-nav-link--sub" title="Links">
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=ipns"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<a href="#view=ipns" class="sidebar-nav-link sidebar-nav-link--sub" title="IPNS">
|
||||
<img
|
||||
src="assets/svg/ipns-icon.svg"
|
||||
alt="IPNS"
|
||||
class="w-5 h-5 mr-3"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">IPNS</span>
|
||||
</a>
|
||||
@@ -219,36 +258,20 @@
|
||||
href="https://bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
class="sidebar-nav-link sidebar-nav-link--sub"
|
||||
title="DNS"
|
||||
>
|
||||
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
||||
<img
|
||||
src="assets/svg/dns-icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">DNS</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById("footerDropdownButton");
|
||||
var footerLinks = document.getElementById("footerLinksContainer");
|
||||
if (btn && footerLinks) {
|
||||
btn.addEventListener("click", function () {
|
||||
footerLinks.classList.toggle("hidden");
|
||||
var isHidden = footerLinks.classList.contains("hidden");
|
||||
btn.setAttribute("aria-expanded", String(!isHidden));
|
||||
var icon = document.getElementById("footerDropdownIcon");
|
||||
var textSpan = document.getElementById("footerDropdownText");
|
||||
if (textSpan) {
|
||||
textSpan.textContent = isHidden ? "More" : "Less";
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.toggle("rotate-180", !isHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</aside>
|
||||
+1395
-116
File diff suppressed because it is too large.
Load diff
+231
-37
@@ -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-150"
|
||||
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,20 +35,124 @@
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<!-- Zap 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>
|
||||
<!-- Share Button (circular) -->
|
||||
<div class="relative" data-modal-zap-wrapper>
|
||||
<button
|
||||
id="modalZapBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Send a zap"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls="modalZapDialog"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
id="modalZapDialog"
|
||||
class="zap-popover hidden"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="zap-popover__content w-72 max-w-[calc(100vw-2rem)] rounded-lg p-4 shadow-lg"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Send a zap
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapCloseBtn"
|
||||
class="text-gray-400 transition-colors hover:text-gray-200"
|
||||
aria-label="Close zap dialog"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapWalletPrompt"
|
||||
class="mb-3 hidden text-sm text-gray-200"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Connect a wallet in
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapWalletLink"
|
||||
class="font-semibold text-blue-300 underline decoration-dotted transition-colors hover:text-blue-200"
|
||||
>
|
||||
Wallet Connect settings
|
||||
</button>
|
||||
to send zaps.
|
||||
</p>
|
||||
<form id="modalZapForm" class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="modalZapAmountInput"
|
||||
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Zap amount (sats)
|
||||
</label>
|
||||
<input
|
||||
id="modalZapAmountInput"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="w-full rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
placeholder="Enter sats"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="modalZapCommentInput"
|
||||
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
|
||||
>
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="modalZapCommentInput"
|
||||
rows="2"
|
||||
maxlength="280"
|
||||
class="w-full resize-y rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
placeholder="Include a note with your zap"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapSplitSummary"
|
||||
class="text-sm text-gray-300"
|
||||
aria-live="polite"
|
||||
>
|
||||
Enter an amount to view the split.
|
||||
</p>
|
||||
<p
|
||||
id="modalZapStatus"
|
||||
class="text-xs text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<ul
|
||||
id="modalZapReceipts"
|
||||
class="space-y-2 text-xs text-gray-300"
|
||||
></ul>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
id="modalZapSendBtn"
|
||||
class="rounded bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="shareBtn" class="icon-button">
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
@@ -72,14 +160,120 @@
|
||||
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>
|
||||
<div
|
||||
class="px-4 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Boost on Nostr…
|
||||
</div>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="repost-event"
|
||||
data-context="modal"
|
||||
>
|
||||
Repost (kind 6)
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="mirror-video"
|
||||
data-context="modal"
|
||||
>
|
||||
Mirror (kind 1063)
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="ensure-presence"
|
||||
data-context="modal"
|
||||
>
|
||||
Rebroadcast
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-700 opacity-70"></div>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="blacklist-author"
|
||||
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"
|
||||
class="flex items-start justify-between text-sm text-gray-400 mb-4"
|
||||
>
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<div class="flex flex-wrap items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">Posted just now</span>
|
||||
<span
|
||||
id="videoEditedTimestamp"
|
||||
class="hidden"
|
||||
>Last edited just now</span>
|
||||
<span id="videoViewCount" class="modal-view-count">– views</span>
|
||||
</div>
|
||||
<div id="modalStatus" class="text-gray-300">
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ASSET_VERSION = "2024.10.29";
|
||||
@@ -0,0 +1,250 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Canonical URL for the public BitVid site.
|
||||
*
|
||||
* Surfaces in admin outreach copy and moderation DMs so that recipients can
|
||||
* quickly jump back to the primary destination. Update this if your
|
||||
* deployment relies on a different canonical hostname.
|
||||
*/
|
||||
export const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||
|
||||
/**
|
||||
* Default image included in automated moderation DMs.
|
||||
*
|
||||
* BitVid embeds this media asset at the top of notification messages so the
|
||||
* payload renders with a recognizable preview. Provide a fully qualified URL
|
||||
* that points to a hosted image accessible by the intended recipients.
|
||||
*/
|
||||
export const ADMIN_DM_IMAGE_URL =
|
||||
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
||||
|
||||
/**
|
||||
* Maximum satoshi value allowed when storing the default zap amount.
|
||||
*
|
||||
* Wallet settings clamp user input to this value before persistence to guard
|
||||
* against accidental overpayment. Tune the ceiling to match your instance's
|
||||
* risk tolerance.
|
||||
*/
|
||||
export const MAX_WALLET_DEFAULT_ZAP = 100000000;
|
||||
|
||||
/**
|
||||
* Percentage of every Lightning payment the platform retains as a fee.
|
||||
*
|
||||
* Accepts numbers between 0 and 100 (inclusive). Decimals are supported when
|
||||
* you want to keep a fractional cut—e.g., `2.5` represents a 2.5% fee. The
|
||||
* default of `0` disables the fee so creators receive the full payment.
|
||||
*/
|
||||
export const PLATFORM_FEE_PERCENT = 30;
|
||||
|
||||
/**
|
||||
* Lightning address to fall back to when authors omit their own `lud16`.
|
||||
*
|
||||
* Supply a string like `"[email protected]"` to force a deployment-wide
|
||||
* fallback Lightning address. When `PLATFORM_FEE_PERCENT` is greater than 0,
|
||||
* this override also acts as the Lightning target for the platform’s split.
|
||||
* Leave the value as `null` to respect the creator’s metadata and rely on the
|
||||
* Super Admin profile publishing a `lud16` so BitVid still knows where to route
|
||||
* fees when they are enabled.
|
||||
*/
|
||||
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
|
||||
|
||||
/**
|
||||
* Optional list of relays to seed new sessions with instead of the defaults.
|
||||
*
|
||||
* Provide WSS URLs (e.g., `"wss://relay.example.com"`). Leave the array empty
|
||||
* to keep BitVid’s bundled defaults. Operators that need a custom bootstrap set
|
||||
* should list the relays in priority order; entries later in the list are used
|
||||
* as fallbacks when earlier relays fail.
|
||||
*/
|
||||
export const DEFAULT_RELAY_URLS_OVERRIDE = Object.freeze([
|
||||
// "wss://relay.example.com",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Storage key used to persist whitelist-only mode in the browser.
|
||||
*
|
||||
* 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 = 10;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Cooldown window (in seconds) between manual "Rebroadcast" attempts.
|
||||
*
|
||||
* Rebroadcasting is an escape hatch for creators to nudge stale relays. A
|
||||
* short cooldown keeps this safeguard from turning into an accidental DDoS.
|
||||
* Five minutes provides enough breathing room for relays to catch up while
|
||||
* still letting operators retry if a publish genuinely failed.
|
||||
*/
|
||||
export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60;
|
||||
@@ -0,0 +1,85 @@
|
||||
// config/validate-config.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// Runtime validation helpers for instance-level configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
} from "./instance-config.js";
|
||||
|
||||
function assertSuperAdminConfigured(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB must be a string exported from config/instance-config.js."
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB is required. Populate it with the Super Admin npub in config/instance-config.js."
|
||||
);
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("npub")) {
|
||||
throw new Error(
|
||||
"ADMIN_SUPER_NPUB should be a bech32 npub (e.g., starting with 'npub')."
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function assertPlatformFeeInRange(value) {
|
||||
const numericValue =
|
||||
typeof value === "number" ? value : Number.parseFloat(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
throw new Error(
|
||||
"PLATFORM_FEE_PERCENT must be a finite number between 0 and 100."
|
||||
);
|
||||
}
|
||||
|
||||
if (numericValue < 0 || numericValue > 100) {
|
||||
throw new Error("PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive.");
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function getPlatformLud16Override(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function resolveConfig(overrides = {}) {
|
||||
return {
|
||||
adminSuperNpub:
|
||||
overrides.ADMIN_SUPER_NPUB ?? ADMIN_SUPER_NPUB,
|
||||
platformFeePercent:
|
||||
overrides.PLATFORM_FEE_PERCENT ?? PLATFORM_FEE_PERCENT,
|
||||
platformLud16Override:
|
||||
overrides.PLATFORM_LUD16_OVERRIDE ?? PLATFORM_LUD16_OVERRIDE,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInstanceConfig(overrides) {
|
||||
const config = resolveConfig(overrides);
|
||||
assertSuperAdminConfigured(config.adminSuperNpub);
|
||||
const platformFeePercent = assertPlatformFeeInRange(
|
||||
config.platformFeePercent
|
||||
);
|
||||
const trimmedOverride = getPlatformLud16Override(
|
||||
config.platformLud16Override
|
||||
);
|
||||
|
||||
if (platformFeePercent > 0 && !trimmedOverride) {
|
||||
throw new Error(
|
||||
"PLATFORM_FEE_PERCENT is positive but PLATFORM_LUD16_OVERRIDE is empty. Set PLATFORM_LUD16_OVERRIDE to the Lightning address that should receive the platform's split, or publish a lud16 value on the Super Admin profile so BitVid can route platform fees."
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
- Fix "Dev Mode" publishing "Live Mode" notes—add a flag for dev mode posts.
|
||||
- Fix issue with video post set to private.
|
||||
- Disable "private video" until I can make it work better.
|
||||
- Make "private video" work better using nip-04 encryption for magnet field.
|
||||
- Make "private video" work better by hiding cards from shared grids while keeping an owner-only view.
|
||||
- Fix slow back button issues on Firefox.
|
||||
- Add Amber login support for mobile.
|
||||
- Add settings (three dots) button for all videos, not the same as gear menu. Only show delete and edit gear for logged-in users videos.
|
||||
|
||||
@@ -31,7 +31,7 @@ This new file could export:
|
||||
- `buildNewNote(data, pubkey)`: Takes basic form data and returns a fully structured Nostr note (an object) ready to be signed.
|
||||
- `buildEditNote(originalEvent, updatedData)`: Merges old note content with new fields.
|
||||
- `softDeleteNote(originalEvent)`: Constructs a note with `deleted = true`.
|
||||
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Real or placeholder encryption functions.
|
||||
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Legacy pass-through helpers kept in case encryption returns; private listings now hide rather than encrypt.
|
||||
- `validateNoteContent(content)`: Ensures essential fields (title, magnet, mode, etc.) are present and valid.
|
||||
|
||||
Because you’re not implementing Version 3 yet, keep your existing version logic. If you do plan to adopt Version 3 later, the new file is where you’d add or change fields without scattering edits across multiple files.
|
||||
@@ -53,7 +53,7 @@ export function prepareNewNote(formInput, pubkey) {
|
||||
// Combine user inputs with defaults
|
||||
const isPrivate = formInput.isPrivate === true;
|
||||
const finalMagnet = isPrivate
|
||||
? encryptMagnet(formInput.magnet)
|
||||
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
|
||||
: formInput.magnet;
|
||||
|
||||
return {
|
||||
@@ -178,7 +178,7 @@ By delegating the actual note-building to `buildEditNote` and `buildDeleteNote`,
|
||||
|
||||
6. **Test Thoroughly:**
|
||||
- Create, edit, and delete events to ensure everything behaves the same.
|
||||
- Confirm that private videos are still encrypted as expected.
|
||||
- Confirm that private videos stay hidden from shared grids as expected.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,13 +189,13 @@ Below is a small outline showing how you might organize the new file. The actual
|
||||
```js
|
||||
// bitvidNoteSpec.js
|
||||
|
||||
// A placeholder or real encryption method
|
||||
// Legacy placeholder kept for future encryption experiments (currently returns the raw magnet)
|
||||
export function encryptMagnet(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
return magnet; // private cards are hidden instead of encrypted
|
||||
}
|
||||
|
||||
export function decryptMagnet(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
return magnet; // private cards are hidden instead of encrypted
|
||||
}
|
||||
|
||||
function generateUniqueDTag() {
|
||||
@@ -206,7 +206,7 @@ function generateUniqueDTag() {
|
||||
export function prepareNewNote(formInput, pubkey) {
|
||||
const isPrivate = formInput.isPrivate === true;
|
||||
const finalMagnet = isPrivate
|
||||
? encryptMagnet(formInput.magnet)
|
||||
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
|
||||
: formInput.magnet;
|
||||
|
||||
return {
|
||||
@@ -234,7 +234,7 @@ export function prepareNewNote(formInput, pubkey) {
|
||||
export function buildEditNote(originalEvent, updatedData) {
|
||||
// parse old content
|
||||
// combine with new fields
|
||||
// handle old vs new encryption
|
||||
// handle legacy visibility toggles / future encryption experiments
|
||||
// return the final event object
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -43,19 +43,19 @@ A typical event:
|
||||
| `videoRootId` | String (optional) | A stable root ID used to link multiple versions (edits) and a delete event together. **Recommended** for overshadow logic. |
|
||||
| `version` | Integer | Now set to `3`. |
|
||||
| `deleted` | Boolean | `true` marks the post as “soft deleted.” |
|
||||
| `isPrivate` | Boolean | Indicates if `magnet` (and possibly other fields) are encrypted. |
|
||||
| `isPrivate` | Boolean | Indicates the card should stay off shared grids and remain visible only to its owner; payloads stay plaintext. |
|
||||
| `title` | String | Display title for the media. |
|
||||
| `magnet` | String | Magnet link for primary media (encrypted if `isPrivate = true`). |
|
||||
| `magnet` | String | Magnet link for primary media (still stored in plaintext even when `isPrivate = true`). |
|
||||
| `extraMagnets` | Array (optional) | Additional magnet links (e.g., multiple resolutions). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `description` | String (optional) | A textual description (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image. |
|
||||
| `description` | String (optional) | A textual description. |
|
||||
| `mode` | String | Typically `live` or `dev`. |
|
||||
| `adult` | Boolean (optional) | `true` if content is adult-only. Default: `false` or omitted. |
|
||||
| `categories` | Array (optional) | Array of categories, e.g. `["comedy", "music"]`. |
|
||||
| `language` | String (optional) | Language code (e.g. `"en"`, `"es"`). |
|
||||
| `payment` | String (optional) | Monetization field (e.g. a Lightning address). |
|
||||
| `i18n` | Object (optional) | Internationalization map (e.g. `{"title_en": "...", "description_es": "..."}`). |
|
||||
| `encryptedMeta` | Boolean (optional) | Indicates if fields like `description` or `thumbnail` are encrypted. |
|
||||
| `encryptedMeta` | Boolean (optional) | Legacy flag from earlier encryption experiments; leave `false` unless a future spec revives encrypted metadata. |
|
||||
| **Audio/Podcast-Specific Fields** | | |
|
||||
| `contentType` | String (optional) | E.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
||||
| `albumName` | String (optional) | Name of the album (for music). |
|
||||
@@ -194,7 +194,7 @@ A typical event:
|
||||
### **8.2 Deletion**
|
||||
|
||||
1. **deleted = true**: Mark the item as deleted in the `content` JSON.
|
||||
2. **Remove or encrypt** sensitive fields (`magnet`, `description`, etc.).
|
||||
2. **Clear** sensitive fields (`magnet`, `description`, etc.) if you do not want them mirrored in older clients.
|
||||
3. **videoRootId**: Must remain the same as the original so clients remove/overshadow the old item.
|
||||
4. **subscribeVideos** Logic:
|
||||
```js
|
||||
@@ -217,7 +217,7 @@ A typical event:
|
||||
1. **Adult Content**: If `adult = true`, clients typically hide the post unless the user enables adult content in settings.
|
||||
2. **Categories**: Provide optional grouping or searching by `categories`.
|
||||
3. **Language**: If specified, clients can filter by language.
|
||||
4. **Encryption**: If `isPrivate = true`, some fields (e.g., `magnet`) may need client-side decryption.
|
||||
4. **Privacy handling**: If `isPrivate = true`, clients should hide the card from shared grids instead of attempting client-side decryption.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1622
-192
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
:root{--color-page:#fff;--color-page-alt:#f9fafb;--color-surface:#0f172a;--color-surface-alt:#111827;--color-bg:var(--color-surface);--color-card:#1e293b;--color-card-private:#3b1d74;--color-card-critical:#3f0b13;--color-panel:#1f2937;--color-panel-hover:#334155;--color-border:#374151;--color-border-subtle:#d1d5db;--color-primary:#fe0032;--color-secondary:#ff93a5;--color-info:#60a5fa;--color-info-strong:#2563eb;--color-info-pressed:#1d4ed8;--color-muted-strong:#6b7280;--color-text:#f8fafc;--color-text-strong:#0f172a;--color-muted:#94a3b8;--color-muted-alt:#9ca3af;--color-subtle-text:#e2e8f0;--color-subtle:#e5e7eb;--color-critical:#7f1d1d;--color-critical-strong:#991b1b;--color-neutral-700:#3f3f46;--color-neutral-600:#52525b;--color-white:#fff;--color-black:#000;--color-overlay:#1a2234;--space-2xs:0.25rem;--space-xs:0.5rem;--space-sm:0.75rem;--space-md:1rem;--space-lg:1.5rem;--space-xl:2rem;--space-2xl:3rem;--radius-sm:0.25rem;--radius-md:0.5rem;--radius-lg:0.75rem;--radius-xl:1rem;--radius-full:9999px;--shadow-sm:0 0.0625rem 0.125rem 0 rgb(0 0 0/5%);--shadow-md:0 0.25rem 0.375rem -0.0625rem rgba(0,0,0,.1);--shadow-lg:0 0.625rem 0.9375rem -0.1875rem rgba(0,0,0,.1);--shadow-modal:0 1.25rem 2.5rem -0.375rem rgba(15,23,42,.55);--font-family-sans:"Inter","Segoe UI",system-ui,-apple-system,sans-serif;--font-family-mono:"Fira Code","SFMono-Regular",ui-monospace,monospace;--font-size-xs:0.75rem;--font-size-sm:0.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.5rem;--font-size-2xl:2rem;--line-height-tight:1.1;--line-height-snug:1.25;--line-height-base:1.5;--line-height-relaxed:1.75;--motion-duration-fast:120ms;--motion-duration-base:160ms;--motion-duration-slow:280ms;--motion-ease-out:cubic-bezier(0.16,1,0.3,1);--motion-ease-in-out:cubic-bezier(0.4,0,0.2,1);--modal-motion-duration:var(--motion-duration-base)}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.bottom-4{bottom:1rem}.bottom-full{bottom:100%}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-12{top:3rem}.top-14{top:3.5rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[60\]{z-index:60}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-0{margin-top:0;margin-bottom:0}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-2{margin-left:-.5rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.box-border{box-sizing:border-box}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-4{height:1rem}.h-48{height:12rem}Line truncated
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
/* Color tokens */
|
||||
--color-page: rgb(255 255 255);
|
||||
--color-page-alt: rgb(249 250 251);
|
||||
--color-surface: rgb(15 23 42);
|
||||
--color-surface-alt: rgb(17 24 39);
|
||||
--color-bg: var(--color-surface);
|
||||
--color-card: rgb(30 41 59);
|
||||
--color-card-private: rgb(59 29 116);
|
||||
--color-card-critical: rgb(63 11 19);
|
||||
--color-panel: rgb(31 41 55);
|
||||
--color-panel-hover: rgb(51 65 85);
|
||||
--color-border: rgb(55 65 81);
|
||||
--color-border-subtle: rgb(209 213 219);
|
||||
--color-primary: rgb(254 0 50);
|
||||
--color-secondary: rgb(255 147 165);
|
||||
--color-info: rgb(96 165 250);
|
||||
--color-info-strong: rgb(37 99 235);
|
||||
--color-info-pressed: rgb(29 78 216);
|
||||
--color-muted-strong: rgb(107 114 128);
|
||||
--color-text: rgb(248 250 252);
|
||||
--color-text-strong: rgb(15 23 42);
|
||||
--color-muted: rgb(148 163 184);
|
||||
--color-muted-alt: rgb(156 163 175);
|
||||
--color-subtle-text: rgb(226 232 240);
|
||||
--color-subtle: rgb(229 231 235);
|
||||
--color-critical: rgb(127 29 29);
|
||||
--color-critical-strong: rgb(153 27 27);
|
||||
--color-neutral-700: rgb(63 63 70);
|
||||
--color-neutral-600: rgb(82 82 91);
|
||||
--color-white: rgb(255 255 255);
|
||||
--color-black: rgb(0 0 0);
|
||||
--color-overlay: rgb(26 34 52);
|
||||
|
||||
/* Spacing scale */
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
|
||||
/* Border radii */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 5%);
|
||||
--shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 10%);
|
||||
--shadow-lg: 0 0.625rem 0.9375rem -0.1875rem rgb(0 0 0 / 10%);
|
||||
--shadow-modal: 0 1.25rem 2.5rem -0.375rem rgb(15 23 42 / 55%);
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-family-mono: "Fira Code", "SFMono-Regular", ui-monospace, monospace;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 2rem;
|
||||
--line-height-tight: 1.1;
|
||||
--line-height-snug: 1.25;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Motion */
|
||||
--motion-duration-fast: 120ms;
|
||||
--motion-duration-base: 160ms;
|
||||
--motion-duration-slow: 280ms;
|
||||
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--modal-motion-duration: var(--motion-duration-base);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Application Responsibility Audit
|
||||
|
||||
This document catalogs the major responsibilities still implemented inside `Application` (formerly `bitvidApp`). Each area is a candidate for future extraction into dedicated service or UI modules.
|
||||
|
||||
## Auth & Profile Coordination
|
||||
- Instantiates `AuthService`, wires login/logout/profile listeners, and exposes helper methods for cached profile state management.【F:js/app.js†L221-L314】【F:js/app.js†L620-L772】
|
||||
- Manages profile modal lifecycle, navigation between panes, and account switching UI state.【F:js/app.js†L283-L420】【F:js/app.js†L1328-L1822】
|
||||
|
||||
## Playback Orchestration
|
||||
- Configures `PlaybackService`, logs telemetry callbacks, and tracks the active playback session lifecycle.【F:js/app.js†L224-L323】【F:js/app.js†L2805-L3352】
|
||||
- Coordinates view logging, cooldown keys, and watch-count subscriptions for the primary player and modal overlays.【F:js/app.js†L4305-L4592】【F:js/app.js†L4972-L5332】
|
||||
|
||||
## Modal & UI Composition
|
||||
- Constructs upload, edit, revert, and video modals; registers their event listeners; and bridges UI events back into application logic.【F:js/app.js†L340-L615】【F:js/app.js†L2382-L2799】
|
||||
- Builds and manages the `VideoListView` component, injecting helpers for health badges, share links, and context menu actions.【F:js/app.js†L516-L744】【F:js/app.js†L2149-L2376】
|
||||
|
||||
## Routing & View Bootstrapping
|
||||
- Handles initial view loading, hash-based navigation, and video list mounting during application startup.【F:js/app.js†L903-L959】【F:js/app.js†L1932-L2124】
|
||||
- Provides navigation helpers for channel/profile transitions tied to hash routing.【F:js/app.js†L934-L968】
|
||||
|
||||
## State & Watch History Management
|
||||
- Synchronizes watch-history preferences, local metadata caching, and publication of watch history events to services.【F:js/app.js†L1975-L2093】【F:js/app.js†L4087-L4478】
|
||||
- Tracks app-level state such as active intervals, cleanup routines, view counters, and URL/magnet health caches.【F:js/app.js†L200-L323】【F:js/app.js†L3354-L4075】
|
||||
|
||||
These clusters outline the remaining cross-cutting responsibilities that can be incrementally migrated into focused modules under `js/services` and `js/ui`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Feed Engine Architecture
|
||||
|
||||
The feed engine is a lightweight pipeline for composing Bitvid feeds from one
|
||||
or more data sources. It lives in `js/feedEngine/` and exposes factories for
|
||||
sources, stages, sorters, and the engine itself. The goal is to make it easy to
|
||||
register new feeds today while leaving room for future "open algorithm" work
|
||||
that surfaces optional "why this video" metadata.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Feed definition** – A feed consists of a source, zero or more pipeline
|
||||
stages, an optional sorter, and optional decorators. Each feed can expose its
|
||||
own hooks and configuration defaults.
|
||||
- **Feed items** – The engine normalizes everything into a `{ video, pointer?,
|
||||
metadata }` DTO so stages can reason about inputs without caring about the
|
||||
original source.
|
||||
- **Context** – Every source and stage receives a context object containing the
|
||||
feed configuration, runtime helpers, registered hooks, and a `addWhy()`
|
||||
method for collecting metadata about why an item was filtered or prioritised.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```js
|
||||
import {
|
||||
createFeedEngine,
|
||||
createActiveNostrSource,
|
||||
createDedupeByRootStage,
|
||||
createBlacklistFilterStage,
|
||||
createChronologicalSorter,
|
||||
} from "../js/feedEngine/index.js";
|
||||
|
||||
const engine = createFeedEngine();
|
||||
|
||||
engine.registerFeed("recent", {
|
||||
source: createActiveNostrSource(),
|
||||
stages: [
|
||||
createBlacklistFilterStage(),
|
||||
createDedupeByRootStage(),
|
||||
],
|
||||
sorter: createChronologicalSorter(),
|
||||
});
|
||||
|
||||
const { videos, metadata } = await engine.runFeed("recent", {
|
||||
runtime: {
|
||||
blacklistedEventIds: new Set(["..."]),
|
||||
isAuthorBlocked: (pubkey) => false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`metadata.why` collects the audit trail from each stage so later phases of the
|
||||
open algorithm project can surface transparency UI.
|
||||
|
||||
## Sources
|
||||
|
||||
| Factory | Description |
|
||||
| --- | --- |
|
||||
| `createActiveNostrSource` | Wraps `nostrService.getFilteredActiveVideos(...)` and emits DTOs with a `metadata.source` of `nostr:active`. |
|
||||
| `createSubscriptionAuthorsSource` | Filters the active video list down to subscribed authors using runtime hooks or `config.actorFilters`. |
|
||||
| `createWatchHistoryPointerSource` | Loads pointer DTOs from `watchHistoryService.getQueuedPointers(...)`. Optional hooks can resolve the backing video if a pointer is missing it. |
|
||||
|
||||
Every source resolves blacklist and author-block runtime helpers so feeds
|
||||
constructed today behave exactly like the existing UI.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Behavior |
|
||||
| --- | --- |
|
||||
| `createDedupeByRootStage` | Reuses the application’s `dedupeVideosByRoot` helper (falling back to `dedupeToNewestByRoot`) to drop older versions of the same videoRoot. Adds "why" metadata for removed entries. |
|
||||
| `createBlacklistFilterStage` | Calls `nostrService.shouldIncludeVideo(...)` so moderators and block lists stay enforced. Each rejection logs a "blacklist" reason in the why-trail. |
|
||||
| `createWatchHistorySuppressionStage` | Invokes feed-provided hooks to optionally suppress watched items. Useful for per-feed watch history preferences. |
|
||||
|
||||
Stages receive `(items, context)` and should return the transformed list. They
|
||||
can rely on `context.addWhy(...)` to annotate decisions without mutating the
|
||||
items in place.
|
||||
|
||||
## Sorting & Decorators
|
||||
|
||||
`createChronologicalSorter` is the baseline sorter that orders DTOs by
|
||||
`video.created_at` (newest first by default). Additional decorators can run
|
||||
after sorting to attach extra metadata or inject presentation hints.
|
||||
|
||||
## Configuration Hooks
|
||||
|
||||
Every feed inherits the default config contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"timeWindow": null,
|
||||
"actorFilters": [],
|
||||
"tagFilters": [],
|
||||
"sortOrder": "recent"
|
||||
}
|
||||
```
|
||||
|
||||
Feeds can override defaults or expose richer schemas through
|
||||
`definition.defaultConfig` and `definition.configSchema`. At execution time the
|
||||
engine merges `options.config` with those defaults and passes them to the
|
||||
pipeline via `context.config`.
|
||||
|
||||
Hooks can be provided globally when registering the feed or per-execution via
|
||||
`engine.runFeed(name, { hooks: { ... } })`. The watch-history suppression stage
|
||||
uses this mechanism so Phase 1 feeds can plug in actor-specific suppression
|
||||
logic later without changing the core pipeline.
|
||||
|
||||
## Why-Metadata
|
||||
|
||||
`context.addWhy()` records structured audit entries. All built-in stages use it
|
||||
for dedupe drops, blacklist filtering, and watch-history suppression. The
|
||||
engine returns these records alongside the final video list so UI components can
|
||||
render transparency affordances when the open algorithm effort ships.
|
||||
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bitvid Tailwind Kitchen Sink</title>
|
||||
<link rel="stylesheet" href="../css/tailwind.generated.css" />
|
||||
</head>
|
||||
<body class="bg-page font-sans text-text-strong">
|
||||
<div class="min-h-screen bg-page-alt">
|
||||
<header class="bg-surface text-text shadow-modal">
|
||||
<div
|
||||
class="mx-auto flex max-w-6xl items-center justify-between px-md py-sm"
|
||||
>
|
||||
<h1 class="text-2xl font-semibold">Bitvid UI Kitchen Sink</h1>
|
||||
<button
|
||||
class="rounded-lg bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
Primary Action
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex max-w-6xl flex-col gap-lg px-md py-lg">
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Buttons</h2>
|
||||
<div class="flex flex-wrap gap-sm">
|
||||
<button
|
||||
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-fast ease-out hover:bg-info-strong"
|
||||
>
|
||||
Informational
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-critical px-sm py-2 text-white shadow-sm transition duration-fast ease-out hover:bg-critical-strong"
|
||||
>
|
||||
Destructive
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-border-subtle px-sm py-2 text-text-muted shadow-none transition duration-fast ease-out hover:bg-panel-hover hover:text-text"
|
||||
>
|
||||
Ghost
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Cards</h2>
|
||||
<div class="grid gap-md md:grid-cols-3">
|
||||
<article class="rounded-lg bg-card p-md text-text shadow-lg">
|
||||
<h3 class="text-lg font-semibold">Default Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Uses
|
||||
<code class="font-mono text-sm text-info">bg-card</code> and
|
||||
tokenized shadows.
|
||||
</p>
|
||||
<button
|
||||
class="mt-sm rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-base ease-in-out hover:bg-info-strong"
|
||||
>
|
||||
Watch Now
|
||||
</button>
|
||||
</article>
|
||||
<article
|
||||
class="rounded-lg bg-card-private p-md text-text shadow-lg"
|
||||
>
|
||||
<h3 class="text-lg font-semibold">Private Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Emphasizes restricted content with
|
||||
<code class="font-mono text-sm text-info">bg-card-private</code
|
||||
>.
|
||||
</p>
|
||||
</article>
|
||||
<article
|
||||
class="rounded-lg bg-card-critical p-md text-text shadow-lg"
|
||||
>
|
||||
<h3 class="text-lg font-semibold">Critical Card</h3>
|
||||
<p class="mt-xs text-text-muted">
|
||||
Use for outage or abuse states.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Form Inputs</h2>
|
||||
<form class="grid gap-md md:grid-cols-2">
|
||||
<label class="flex flex-col gap-xs">
|
||||
<span class="text-sm font-medium text-text-muted"
|
||||
>Display Name</span
|
||||
>
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
type="text"
|
||||
placeholder="Satoshi Stack"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-xs">
|
||||
<span class="text-sm font-medium text-text-muted">Video URL</span>
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
type="url"
|
||||
placeholder="https://example.com/video.mp4"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-xs md:col-span-2">
|
||||
<span class="text-sm font-medium text-text-muted"
|
||||
>Description</span
|
||||
>
|
||||
<textarea
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
|
||||
rows="3"
|
||||
placeholder="Describe what viewers should expect..."
|
||||
></textarea>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">Modal Preview</h2>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl bg-overlay p-lg text-text shadow-modal"
|
||||
>
|
||||
<div class="space-y-sm">
|
||||
<h3 class="text-lg font-semibold">Upload Video</h3>
|
||||
<p class="text-text-muted">
|
||||
Motion tokens control the fade via
|
||||
<code class="font-mono text-sm text-info">duration-base</code>
|
||||
and
|
||||
<code class="font-mono text-sm text-info">ease-out</code>
|
||||
utilities.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-md flex flex-col gap-sm">
|
||||
<input
|
||||
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong focus:border-info focus:ring-2 focus:ring-info"
|
||||
type="text"
|
||||
placeholder="Magnet URI"
|
||||
/>
|
||||
<div class="flex justify-end gap-sm">
|
||||
<button
|
||||
class="rounded-md border border-border px-sm py-2 text-text-muted transition duration-fast ease-out hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-md">
|
||||
<h2 class="text-xl font-semibold text-text-strong">
|
||||
Typography Scale
|
||||
</h2>
|
||||
<div class="space-y-xs">
|
||||
<p class="text-2xl font-semibold">Display / 2xl</p>
|
||||
<p class="text-xl font-semibold">Headline / xl</p>
|
||||
<p class="text-lg font-medium">Title / lg</p>
|
||||
<p class="text-base">Body / base</p>
|
||||
<p class="text-sm text-text-muted">Caption / sm</p>
|
||||
<p class="text-xs uppercase tracking-wide text-text-muted">
|
||||
Meta / xs
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
# Manual QA — 2024-05-10
|
||||
|
||||
## Scope
|
||||
* Login/logout state transitions via `authService`
|
||||
* Video list rendering and playback fallback via `playbackService`
|
||||
* Upload submissions (URL only, magnet only, combined) via `publishVideoNote`
|
||||
|
||||
## Environment
|
||||
* Local static server: `python -m http.server 5173`
|
||||
* Browser automation: Playwright (Chromium) via sandboxed runner
|
||||
* Stubbed `window.nostr` for extension-less login flows
|
||||
|
||||
## Results
|
||||
### Authentication
|
||||
* Pre-login upload button hidden (`False`).
|
||||
* Post-login upload button visible (`True`); profile button also visible.
|
||||
* Post-logout upload button hidden again (`False`).
|
||||
|
||||
### Playback
|
||||
* Hosted URL attempt emitted `session-start` analytics event. Hosted playback errored in sandbox (no magnet fallback available for URL-only sample).
|
||||
* Second sample (URL + magnet) emitted `session-start`, `fallback`, `playViaWebTorrent`, and `sourcechange: torrent` events confirming magnet fallback wiring.
|
||||
|
||||
### Upload flows
|
||||
* URL-only submission succeeded (normalized payload kept empty magnet and blank hints).
|
||||
* Magnet-only submission appended WSS tracker hints plus provided `ws`/`xs` values.
|
||||
* Combined submission retained HTTPS URL and normalized magnet with tracker hints.
|
||||
|
||||
## Notes
|
||||
Automation invoked `app.authService.login`/`handleAuthLogin` and `app.publishVideoNote` directly to simulate modal interactions. Playback checks observed console analytics, not media rendering (hosted stream blocked by sandbox security).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Bitvid Moderation Overview
|
||||
|
||||
## What we’re building
|
||||
Bitvid is follow-centric. Your Home feed comes from people you follow (F1). Discovery can optionally expand to friends-of-friends (F2) and ranked sources. Moderation is **client-side** and **user-controlled** with optional admin lists.
|
||||
|
||||
## Core principles
|
||||
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
|
||||
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
|
||||
- **Whitelist > blacklist**: Prefer showing content from trusted networks over fighting global spam.
|
||||
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
|
||||
|
||||
## Threat model (short)
|
||||
- Spam/bots, impersonation, malware, NSFW thumbnails, illegal content, brigading/dogpiles, Sybil report attacks.
|
||||
|
||||
## Building blocks (Nostr)
|
||||
- **Reports**: NIP-56 (`kind 1984`) with types like `nudity`, `spam`, `illegal`, `impersonation`, etc.
|
||||
- **Lists**: NIP-51 (mute list 10000, categorized people 30000, bookmarks 30001).
|
||||
- **Replies/threads**: NIP-10 (comments).
|
||||
- **Counts**: NIP-45 (relay COUNT; optional fallbacks).
|
||||
|
||||
## Defaults (policy)
|
||||
- Blur video thumbnails if **≥ 3** F1 friends report `nudity`.
|
||||
- Disable autoplay preview if **≥ 2** F1 friends report `nudity`.
|
||||
- Downrank author when any F1 has them in mute list (10000).
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content.
|
||||
|
||||
> You can override all defaults in **Settings → Safety & Moderation**.
|
||||
|
||||
## Files in this folder
|
||||
- `web-of-trust.md` — how we compute trust signals and thresholds
|
||||
- `nips.md` — exact NIPs and kinds Bitvid uses
|
||||
- `relays.md` — relay compatibility and COUNT fallbacks
|
||||
- `testing.md` — QA checklist + test vectors
|
||||
@@ -0,0 +1,22 @@
|
||||
# NIPs Bitvid uses (Moderation-Relevant)
|
||||
|
||||
## Required
|
||||
- **NIP-56 — Reporting**
|
||||
`kind 1984` events with a `type` tag such as `nudity`, `spam`, `illegal`, `impersonation`, `malware`, `profanity`, `other`.
|
||||
Client may act on **reports from friends** (our trusted set).
|
||||
|
||||
- **NIP-51 — Lists**
|
||||
- `10000` mute list (authors to mute).
|
||||
- `30000` categorized people (used for admin/curation lists).
|
||||
- `30001` bookmarks (not moderation, but used elsewhere).
|
||||
|
||||
- **NIP-10 — Replies & Mentions**
|
||||
Threading for comments; moderation badges appear in the thread UI.
|
||||
|
||||
## Recommended
|
||||
- **NIP-45 — COUNT**
|
||||
Event counts; we use it opportunistically for faster totals with graceful fallback.
|
||||
|
||||
## Nice-to-have
|
||||
- **NIP-36 — Sensitive Content / Content Warning**
|
||||
If present, we pre-blur content even without reports; user can “show anyway”.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Relay Compatibility & Fallbacks
|
||||
|
||||
## COUNT (NIP-45)
|
||||
- Use COUNT when available to fetch totals efficiently.
|
||||
- If a relay doesn’t support COUNT:
|
||||
- Show “—” placeholder.
|
||||
- Fall back to client-side counting when cheap (narrow queries only).
|
||||
- Avoid heavy per-relay scans; cap by time window and result size.
|
||||
|
||||
## Query hygiene
|
||||
- Prefer specific filters: `authors`, `kinds`, `#e`, `#p`, `since/until`.
|
||||
- Batch requests across relays; debounce UI updates.
|
||||
- Cache viewer’s F1 set (pubkeys you follow) and their report summaries.
|
||||
|
||||
## Media/thumbnail considerations
|
||||
- Never block media fetch solely on relay count results.
|
||||
- Thumbnails respect blur rules locally; “show anyway” toggles do not re-query relays.
|
||||
|
||||
## Timeouts & errors
|
||||
- Per-relay timeout (e.g., 1500–2500 ms); proceed with partial results.
|
||||
- Surface a subtle “partial results” indicator when some relays fail.
|
||||
|
||||
## Privacy notes
|
||||
- Do not leak the viewer’s F1 set to third-party endpoints.
|
||||
- If using a reputation provider, pass pubkeys in batches; avoid attaching viewer identity unless needed.
|
||||
@@ -0,0 +1,71 @@
|
||||
# QA & Test Vectors (Moderation)
|
||||
|
||||
## Quick checklist
|
||||
- [ ] F1 set cached and used for “trusted reports”.
|
||||
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
|
||||
- [ ] “Show anyway” works and records only local pref.
|
||||
- [ ] Mute list (10000) downranks/hides consistently.
|
||||
- [ ] Admin lists (30000) take effect only when subscribed.
|
||||
- [ ] COUNT fallback shows placeholders and doesn’t crash UI.
|
||||
- [ ] Comment threads render with NIP-10; moderation badges show in context.
|
||||
- [ ] Reports from muted reporters are ignored.
|
||||
|
||||
## Fixtures (create on test relays)
|
||||
|
||||
### A) NIP-56 report events
|
||||
Create three distinct F1 reporter keys; each sends:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1984,
|
||||
"tags": [
|
||||
["e", "<VIDEO_EVENT_ID>", "nudity"],
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
],
|
||||
"content": "test: thumbnail too revealing"
|
||||
}
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* After 1 report → no blur; autoplay allowed.
|
||||
* After 2 reports → autoplay blocked.
|
||||
* After 3 reports → thumbnail blurred; reason chip shown.
|
||||
|
||||
### B) Mute list (10000)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10000,
|
||||
"tags": [
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expected: author’s items are downranked/hidden for this viewer.
|
||||
|
||||
### C) Admin blacklist (30000)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30000,
|
||||
"tags": [
|
||||
["d", "bitvid:admin:blacklist"],
|
||||
["p", "<AUTHOR_PUBKEY>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expected: when viewer subscribes to this list, author is hard-hidden.
|
||||
|
||||
### D) COUNT fallback
|
||||
|
||||
* Use a relay that doesn’t support COUNT.
|
||||
* Verify “—” placeholders; no spinner loops; no crashes.
|
||||
|
||||
## Automated checks (suggested)
|
||||
|
||||
* Unit test `trustedReportCount`.
|
||||
* Integration test: emit events over a local relay; assert UI state.
|
||||
* Regression test: ensure Home feed ignores reputation gating.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Web-of-Trust Policy (Bitvid)
|
||||
|
||||
## Graph terms
|
||||
- **F1 (friends)**: pubkeys you follow.
|
||||
- **F2**: friends-of-friends (off by default for Home; allowed for Discovery).
|
||||
- **Trusted report**: a NIP-56 report from an F1 account.
|
||||
|
||||
## Signals we use
|
||||
- NIP-56 reports by type (`nudity`, `spam`, `illegal`, `impersonation`, etc.).
|
||||
- NIP-51 lists:
|
||||
- 10000 mute list → downrank/hide author content.
|
||||
- 30000 categorized people → optional admin lists (see below).
|
||||
- (Optional) reputation score from a reputation source (e.g., PageRank/DVM) for **Discovery** only.
|
||||
|
||||
## Default thresholds (can be tuned)
|
||||
- `blurThumbnail = trustedReportCount(event,'nudity') >= 3`
|
||||
- `hideAutoplay = trustedReportCount(event,'nudity') >= 2`
|
||||
- `downrankIfMutedByF1 = true`
|
||||
|
||||
### Why these numbers?
|
||||
- F1-only reports resist Sybil attacks.
|
||||
- Blur is reversible; hiding autoplay reduces accidental exposure.
|
||||
|
||||
## Admin lists (opt-in)
|
||||
- We recognize curated lists using `30000` events:
|
||||
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
|
||||
- `['d','bitvid:admin:whitelist']` → always show in Discovery.
|
||||
- `['d','bitvid:admin:editors']` → trusted channel editors.
|
||||
- Users can subscribe/unsubscribe any time.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```ts
|
||||
type Hex = string;
|
||||
|
||||
interface Report {
|
||||
reporter: Hex; // F1 must include this key
|
||||
type: 'nudity'|'spam'|'illegal'|'impersonation'|'malware'|'profanity'|'other';
|
||||
targetEvent: string;
|
||||
}
|
||||
|
||||
function trustedReportCount(eventId: string, type: Report['type'], viewerFollows: Set<Hex>, reports: Report[]): number {
|
||||
return reports.filter(r => r.targetEvent === eventId && r.type === type && viewerFollows.has(r.reporter)).length;
|
||||
}
|
||||
|
||||
function shouldBlurThumb(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 3;
|
||||
}
|
||||
|
||||
function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 2;
|
||||
}
|
||||
```
|
||||
|
||||
## UI rules
|
||||
|
||||
* Always show a reason chip: e.g., `Blurred · 3 friends reported “nudity” · Show anyway`.
|
||||
* Respect per-viewer choices (global off, per-channel off).
|
||||
* Never bury the override.
|
||||
|
||||
## Discovery (optional reputation)
|
||||
|
||||
* Home feed: **never** gated by reputation.
|
||||
* Discovery/Trending: may require a minimum score (pluggable `ReputationSource`).
|
||||
* Keep a toggle in Settings to disable reputation gating.
|
||||
|
||||
```ts
|
||||
interface ReputationSource {
|
||||
// returns 0..1 rank per pubkey for a given perspective
|
||||
rank(pubkeys: Hex[], perspective?: Hex): Promise<Record<Hex, number>>;
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-abuse hardening
|
||||
|
||||
* Count unique F1 reporters only (dedupe by pubkey).
|
||||
* Minimum account age or minimum “being-followed” count for reporters (optional).
|
||||
* Rate-limit rapid report bursts per reporter.
|
||||
* Ignore reports from muted or blocked reporters.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Nostr Analytics Knobs
|
||||
|
||||
BitVid's view counter emits Nostr events so operators can track engagement without duplicating storage. Tune the following exports in `config/instance-config.js` to match your retention and performance goals:
|
||||
|
||||
- `VIEW_COUNT_DEDUPE_WINDOW_SECONDS` (default: `86_400`): repeat plays from the same viewer inside this window are treated as duplicates so stalled reloads do not inflate totals. Shorten the window to count more aggressive replays, or extend it if you want conservative numbers.
|
||||
- `VIEW_COUNT_BACKFILL_MAX_DAYS` (default: `90`): controls how far back hydrators should walk history when a new analytics worker boots. Longer windows deliver deeper trend lines at the cost of heavier relay scans.
|
||||
- `VIEW_COUNT_CACHE_TTL_MS` (default: `5 * 60 * 1000`): defines how long cached aggregates remain trustworthy before clients refresh them. Lower values surface spikes faster, while higher ones smooth traffic for relay-friendly dashboards.
|
||||
|
||||
Most operators can ship with the defaults—24-hour deduplication, a 90-day backfill horizon, and five-minute cache TTLs match what we run in production. Deviate only if your relays have unusual load constraints or your reporting needs stricter fidelity.
|
||||
|
||||
## View Event Lifecycle
|
||||
|
||||
### Anonymous session keys
|
||||
|
||||
Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr.js#L2011-L2105) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback.
|
||||
|
||||
### Publish latency expectations
|
||||
|
||||
When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing.
|
||||
|
||||
The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing.
|
||||
|
||||
To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts.
|
||||
|
||||
Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history.
|
||||
|
||||
### Hydration, COUNT, and UI sync
|
||||
|
||||
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
|
||||
|
||||
@@ -0,0 +1,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,125 @@
|
||||
# Nostr event schemas
|
||||
|
||||
BitVid now centralizes every note that it publishes to Nostr in
|
||||
[`js/nostrEventSchemas.js`](../js/nostrEventSchemas.js). The module defines the
|
||||
kind, required tags, and content format for each note type so troubleshooting
|
||||
no longer requires hunting through the codebase. It also exposes helpers for
|
||||
building events and for overriding the schema at runtime when you need to
|
||||
experiment.
|
||||
|
||||
## Runtime helpers
|
||||
|
||||
```js
|
||||
import {
|
||||
NOTE_TYPES,
|
||||
getNostrEventSchema,
|
||||
setNostrEventSchemaOverrides,
|
||||
buildVideoPostEvent,
|
||||
buildRepostEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
// Inspect the current schema
|
||||
console.log(getNostrEventSchema(NOTE_TYPES.VIDEO_POST));
|
||||
|
||||
// Temporarily override the kind while debugging
|
||||
setNostrEventSchemaOverrides({
|
||||
[NOTE_TYPES.VIDEO_POST]: { kind: 30000 },
|
||||
});
|
||||
|
||||
// Build an event with the active schema
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: "debug-video",
|
||||
content: { version: 3, title: "Test", videoRootId: "debug" },
|
||||
});
|
||||
```
|
||||
|
||||
In the browser you can call the same helpers from DevTools via
|
||||
`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`.
|
||||
|
||||
When managing relay metadata, use `buildRelayListEvent` so the resulting
|
||||
replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the
|
||||
read/write split.
|
||||
|
||||
### Accessing raw events
|
||||
|
||||
`NostrClient` now keeps a lightweight cache of parsed videos alongside a
|
||||
separate cache of the untouched event payloads. Call
|
||||
`nostrClient.fetchRawEventById(eventId)` when you need the original JSON that
|
||||
was received from the relay. For convenience,
|
||||
`nostrClient.getEventById(eventId, { includeRaw: true })` returns both shapes at
|
||||
once:
|
||||
|
||||
```js
|
||||
const { video, rawEvent } = await nostrClient.getEventById(eventId, {
|
||||
includeRaw: true,
|
||||
});
|
||||
|
||||
// `video` is the normalized BitVid object and `rawEvent` is the original
|
||||
// Nostr event with `sig`, `id`, and relay metadata intact.
|
||||
```
|
||||
|
||||
In DevTools the active client lives at `window.bitvidApp?.nostrClient`.
|
||||
|
||||
Use the `rawEvent` blob when implementing “Rebroadcast” style flows so the
|
||||
client can republish exactly what was signed, including the original
|
||||
`sig`. Republishers should prefer `rawEvent.sig` over re-signing unless the
|
||||
payload actually changes; this avoids accidental drift in dedupe tags or
|
||||
timestamps.
|
||||
|
||||
## Event catalogue
|
||||
|
||||
| Note | Kind (default) | Tags | Content format |
|
||||
| --- | --- | --- | --- |
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
|
||||
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
|
||||
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
|
||||
| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) |
|
||||
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
|
||||
The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility.
|
||||
|
||||
If you introduce a new Nostr feature, add its schema to
|
||||
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
|
||||
builders inherit the same debugging knobs.
|
||||
|
||||
### NIP-71 rollout
|
||||
|
||||
BitVid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short
|
||||
form) whenever the `FEATURE_PUBLISH_NIP71` flag is enabled. The builder converts
|
||||
structured upload metadata—image variants, captions, segments, hashtags, and
|
||||
participant pointers—into canonical tags so other clients can discover the same
|
||||
video. The legacy kind `30078` post remains the source of truth during the
|
||||
transition, and schema overrides can adjust either kind if relays need
|
||||
experimentation.
|
||||
|
||||
### Watch history identifiers
|
||||
|
||||
The encrypted watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2`
|
||||
runtime flag. When the flag is disabled, clients continue emitting view events
|
||||
but skip publishing snapshots; the UI still resolves legacy `watch-history:v2:index`
|
||||
lists so operators can stage the rollout per deployment.【F:config/instance-config.js†L69-L94】【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L948-L985】
|
||||
|
||||
Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER`
|
||||
(`"watch-history"`) and the legacy aliases enumerated in
|
||||
`WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Chunk events derive their `d` tag from
|
||||
`<snapshotId>:<index>`, advertise `['snapshot', <id>]`, and carry `['chunk', <index>, <total>]`
|
||||
plus an optional leading `['head','1']` marker so relays can prioritize the first
|
||||
ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer
|
||||
entries; richer metadata remains on-device via the
|
||||
[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to
|
||||
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr.js†L2329-L2369】【F:js/watchHistoryService.js†L331-L376】
|
||||
|
||||
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
|
||||
management hooks, manual snapshot helpers, and metadata toggle controls that
|
||||
complement these schema definitions.【F:js/watchHistoryService.js†L695-L776】【F:js/watchHistoryService.js†L1040-L1093】
|
||||
@@ -0,0 +1,53 @@
|
||||
# Nostr Wallet Connect Coverage
|
||||
|
||||
This note cross-references Bitvid's current Nostr Wallet Connect (NIP-47) implementation with the official
|
||||
specification so we can quickly spot regressions or missing features.
|
||||
|
||||
## URI handling
|
||||
|
||||
* `parseNwcUri()` accepts all aliases we see in the wild (`nostr+walletconnect://`, `walletconnect://`, and `nwc://`)
|
||||
and rejects unknown schemes before continuing. It extracts the wallet pubkey, all relay query parameters, and the
|
||||
32-byte secret required for signing. The helper lowercases the secret, reserializes the URI in canonical form, and
|
||||
derives the client public key from the secret so downstream calls can sign events without leaking user keys.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
* `ensureActiveState()` caches the parsed connection and keeps WebSocket state so repeated actions reuse the same
|
||||
subscription. When a new URI is supplied we tear down the previous socket, update the relay list, and reset
|
||||
encryption state before reconnecting.
|
||||
* `connectSocket()` opens a WebSocket to the relay, subscribes to kind `23195` responses authored by the wallet and
|
||||
addressed to the client pubkey, and hooks message/error/close callbacks so we can retry or surface failures.
|
||||
|
||||
## Request flow
|
||||
|
||||
* `ensureWallet()` validates that the user configured an NWC URI, connects to the relay if needed, and negotiates an
|
||||
encryption scheme before returning the active context.
|
||||
* `encryptRequestPayload()` serializes the JSON-RPC payload, encrypts it with the selected scheme, and creates the
|
||||
Nostr event with kind `23194`, `pubkey` set to the client key, a `p` tag pointing at the wallet, and an
|
||||
`encryption` tag mirroring the algorithm that was used. Events are signed with the secret from the connection URI
|
||||
so relays will accept them.
|
||||
* `sendPayment()` constructs the `pay_invoice` request, attaches a unique `id`, and dispatches it over the relay.
|
||||
|
||||
## Response flow
|
||||
|
||||
* `subscribeToResponses()` issues a `REQ` for kind `23195` events authored by the wallet and tagged for the client.
|
||||
* `handleSocketMessage()` decrypts responses, matches them against pending `id`s (or the `e` tag), clears pending
|
||||
timeouts, and surfaces either the `result` or `error.code`/`error.message` back to callers.
|
||||
|
||||
## Encryption negotiation
|
||||
|
||||
* `requestInfoEvent()` fetches the wallet's replaceable kind `13194` info note and passes the result to
|
||||
`getWalletSupportedEncryption()` so we only pick schemes advertised by the wallet service.
|
||||
* `getEncryptionCandidates()` prefers NIP-44 (`nip44_v2`) when both sides support it and falls back to NIP-04 when
|
||||
necessary. We also remember schemes that failed so future retries skip them.
|
||||
|
||||
## Current gaps
|
||||
|
||||
* We only connect to the first relay supplied by the URI. If a wallet publishes multiple relays we should consider
|
||||
retrying additional ones when the primary is offline.
|
||||
* The client currently implements the `pay_invoice` command. Other optional commands (`make_invoice`,
|
||||
`get_balance`, `multi_pay_invoice`, etc.) and notification handling (kinds `23196`/`23197`) would require follow-up
|
||||
work.
|
||||
* Wallet responses that signal `UNSUPPORTED_ENCRYPTION` as an error today bubble straight back to the caller. The
|
||||
transport already retries once when we encounter that error during submission, but we could add richer UX (e.g.
|
||||
prompting the user to reconnect) in the future.
|
||||
@@ -17,5 +17,11 @@ Run this checklist before publishing releases or merging changes that touch uplo
|
||||
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.
|
||||
+83
-39
@@ -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,25 +45,33 @@
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- Styles -->
|
||||
<script src="js/tracking.js" defer></script>
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
<link href="css/markdown.css" rel="stylesheet" />
|
||||
<!--
|
||||
Cache busting: keep these query strings in sync with config/asset-version.js
|
||||
so browsers pick up the latest sidebar markup and styling without
|
||||
hard-refreshing around CDN caches.
|
||||
-->
|
||||
<script src="js/tracking.js?v=2024.10.29" defer></script>
|
||||
<link href="css/tailwind.generated.css?v=2024.10.29" rel="stylesheet" />
|
||||
<link href="css/style.css?v=2024.10.29" rel="stylesheet" />
|
||||
<link href="css/markdown.css?v=2024.10.29" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<!--
|
||||
<body class="sidebar-collapsed">
|
||||
<!--
|
||||
SIDEBAR CONTAINER:
|
||||
The <aside id="sidebar"> inside handles visibility.
|
||||
The <aside id="sidebar"> inside handles visibility.
|
||||
By default, no special position or z-index on #sidebarContainer.
|
||||
-->
|
||||
<div id="sidebarContainer">
|
||||
<!-- components/sidebar.html gets injected here -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<div id="sidebarOverlay" class="md:hidden" aria-hidden="true"></div>
|
||||
|
||||
<!--
|
||||
MAIN CONTENT:
|
||||
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
|
||||
On mobile, we also toggle .sidebar-open to shift the entire content.
|
||||
Desktop spacing now follows the --sidebar-width custom property so the layout stays in
|
||||
sync with the collapsible rail. On mobile we float the sidebar over the content and dim
|
||||
the background overlay.
|
||||
-->
|
||||
<!--
|
||||
`fade-in` is scrubbed by js/index.js after the first animation so that
|
||||
@@ -65,7 +79,11 @@
|
||||
elements that should animate exactly once on initial paint.
|
||||
-->
|
||||
|
||||
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col fade-in">
|
||||
<div
|
||||
id="app"
|
||||
class="px-4 py-8 min-h-screen flex flex-col fade-in"
|
||||
data-sidebar-state="collapsed"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="mb-8 flex items-center w-full">
|
||||
<!-- Mobile hamburger button (hidden on md+) -->
|
||||
@@ -78,6 +96,10 @@
|
||||
<button
|
||||
id="mobileMenuBtn"
|
||||
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60] fade-in"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="sidebar"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
||||
@@ -87,11 +109,17 @@
|
||||
</button>
|
||||
<!-- Logo -->
|
||||
<!-- Logo fades in once on initial load. -->
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16 fade-in fade-in-delay-100"
|
||||
/>
|
||||
<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 -->
|
||||
|
||||
@@ -137,17 +165,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>
|
||||
@@ -167,20 +214,17 @@
|
||||
</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 };
|
||||
</script>
|
||||
<script type="module" src="js/config.js"></script>
|
||||
<script type="module" src="js/lists.js"></script>
|
||||
<script type="module" src="js/accessControl.js"></script>
|
||||
<script type="module" src="js/webtorrent.js"></script>
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
<script type="module" src="js/disclaimer.js"></script>
|
||||
<script type="module" src="js/index.js"></script>
|
||||
<!-- Bootstraps nostr-tools before other modules rely on nostrToolsReady -->
|
||||
<script type="module" src="js/nostrToolsBootstrap.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/config.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/lists.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/accessControl.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/webtorrent.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/nostr.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/viewManager.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/bootstrap.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/disclaimer.js?v=2024.10.29"></script>
|
||||
<script type="module" src="js/index.js?v=2024.10.29"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
||||
</body>
|
||||
|
||||
+385
-200
@@ -1,228 +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");
|
||||
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();
|
||||
}
|
||||
|
||||
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
|
||||
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
|
||||
|
||||
if (storedWhitelist !== null) {
|
||||
this.whitelist = new Set(storedWhitelist);
|
||||
} else {
|
||||
this.whitelist = new Set(initialWhitelist);
|
||||
this.saveWhitelist();
|
||||
if (whitelistStatus && whitelistStatus !== "missing") {
|
||||
console.warn(
|
||||
`Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
|
||||
);
|
||||
}
|
||||
_performRefresh() {
|
||||
if (this._isRefreshing) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
if (storedBlacklist !== null) {
|
||||
this.blacklist = new Set(storedBlacklist);
|
||||
} else {
|
||||
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
||||
this.saveBlacklist();
|
||||
if (blacklistStatus && blacklistStatus !== "missing") {
|
||||
console.warn(
|
||||
`Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
|
||||
);
|
||||
}
|
||||
}
|
||||
this._isRefreshing = true;
|
||||
|
||||
// Debug the sets
|
||||
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
|
||||
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
|
||||
}
|
||||
|
||||
// Rest of the class remains the same...
|
||||
loadWhitelist() {
|
||||
try {
|
||||
const stored = localStorage.getItem("bitvid_whitelist");
|
||||
if (!stored) {
|
||||
if (isDevMode) console.log("No stored whitelist found in localStorage.");
|
||||
return { data: null, status: "missing" };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
const sanitized = this.sanitizeList(parsed, "whitelist");
|
||||
return {
|
||||
data: sanitized,
|
||||
status: sanitized === null ? "invalid" : "ok",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading whitelist, using defaults:", error);
|
||||
return { data: null, status: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
loadBlacklist() {
|
||||
try {
|
||||
const stored = localStorage.getItem("bitvid_blacklist");
|
||||
if (!stored) {
|
||||
if (isDevMode) console.log("No stored blacklist found in localStorage.");
|
||||
return { data: null, status: "missing" };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
const sanitized = this.sanitizeList(parsed, "blacklist");
|
||||
return {
|
||||
data: sanitized,
|
||||
status: sanitized === null ? "invalid" : "ok",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading blacklist, using defaults:", error);
|
||||
return { data: null, status: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeList(list, listName) {
|
||||
if (!Array.isArray(list)) {
|
||||
console.warn(
|
||||
`Stored ${listName} is not an array. Received:`,
|
||||
list
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized = [];
|
||||
let invalidEntries = 0;
|
||||
|
||||
list.forEach((value) => {
|
||||
if (typeof value !== "string") {
|
||||
invalidEntries += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
sanitized.push(trimmed);
|
||||
} else {
|
||||
invalidEntries += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidEntries > 0) {
|
||||
console.warn(
|
||||
`Stored ${listName} contained ${invalidEntries} invalid entr${
|
||||
invalidEntries === 1 ? "y" : "ies"
|
||||
}. Sanitized list:`,
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
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,640 @@
|
||||
// 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() : "";
|
||||
}
|
||||
|
||||
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function isHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return HEX_PUBKEY_REGEX.test(trimmed);
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
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 normalizeParticipantTagValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const nip19 = window?.NostrTools?.nip19;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("npub")) {
|
||||
if (nip19?.decode) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded?.type === "npub") {
|
||||
return trimmed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Swallow decode errors so we can still fall back to the raw npub string.
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (isHexPubkey(trimmed)) {
|
||||
const encoded = encodeHexToNpub(trimmed);
|
||||
if (encoded) {
|
||||
return encoded;
|
||||
}
|
||||
if (!canDecodeNpub()) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (nip19?.decode) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded?.type === "npub") {
|
||||
return trimmed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode failures; values that cannot be interpreted as npubs are dropped.
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractNpubsFromEvent(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = event.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
const npubs = candidates
|
||||
.map((value) => normalizeParticipantTagValue(value))
|
||||
.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);
|
||||
}
|
||||
|
||||
export const __adminListStoreTestHooks = Object.freeze({
|
||||
extractNpubsFromEvent,
|
||||
normalizeParticipantTagValue,
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// js/applicationContext.js
|
||||
|
||||
let applicationInstance = null;
|
||||
let readyPromise = Promise.resolve();
|
||||
|
||||
export function setApplication(app) {
|
||||
applicationInstance = app || null;
|
||||
}
|
||||
|
||||
export function getApplication() {
|
||||
return applicationInstance;
|
||||
}
|
||||
|
||||
export function setApplicationReady(promise) {
|
||||
if (promise && typeof promise.then === "function") {
|
||||
readyPromise = promise;
|
||||
} else {
|
||||
readyPromise = Promise.resolve(promise);
|
||||
}
|
||||
}
|
||||
|
||||
export function getApplicationReady() {
|
||||
return readyPromise;
|
||||
}
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
// js/bootstrap.js
|
||||
|
||||
import Application from "./app.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView } from "./viewManager.js";
|
||||
|
||||
function mergeServices(overrides = {}) {
|
||||
const merged = { nostrService, r2Service };
|
||||
|
||||
if (overrides && typeof overrides === "object") {
|
||||
if (overrides.nostrService) {
|
||||
merged.nostrService = overrides.nostrService;
|
||||
}
|
||||
if (overrides.r2Service) {
|
||||
merged.r2Service = overrides.r2Service;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function createApplication({ services, loadView: loadViewOverride } = {}) {
|
||||
return new Application({
|
||||
services: mergeServices(services),
|
||||
loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView,
|
||||
});
|
||||
}
|
||||
|
||||
export default createApplication;
|
||||
+2173
-420
File diff suppressed because it is too large.
Load diff
+84
-1
@@ -1,4 +1,87 @@
|
||||
// js/config.js
|
||||
|
||||
import {
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_DM_IMAGE_URL,
|
||||
BITVID_WEBSITE_URL,
|
||||
MAX_WALLET_DEFAULT_ZAP,
|
||||
PLATFORM_FEE_PERCENT,
|
||||
PLATFORM_LUD16_OVERRIDE,
|
||||
DEFAULT_RELAY_URLS_OVERRIDE,
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
WATCH_HISTORY_BATCH_RESOLVE,
|
||||
WATCH_HISTORY_BATCH_PAGE_SIZE,
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
WATCH_HISTORY_FETCH_EVENT_LIMIT,
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
} from "../config/instance-config.js";
|
||||
|
||||
export const isDevMode = true; // Set to false for production
|
||||
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 { ADMIN_DM_IMAGE_URL };
|
||||
export { BITVID_WEBSITE_URL };
|
||||
export { MAX_WALLET_DEFAULT_ZAP };
|
||||
export { PLATFORM_FEE_PERCENT };
|
||||
export { PLATFORM_LUD16_OVERRIDE };
|
||||
export { DEFAULT_RELAY_URLS_OVERRIDE };
|
||||
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
|
||||
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
|
||||
|
||||
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 };
|
||||
export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS };
|
||||
+89
-3
@@ -1,10 +1,14 @@
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: false,
|
||||
FEATURE_PUBLISH_NIP71: false,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.btorrent.xyz",
|
||||
"wss://tracker.fastcast.nz",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://tracker.sloppyta.co:443/announce",
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -17,6 +21,10 @@ const runtimeFlags = (() => {
|
||||
const initial = {
|
||||
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
@@ -92,6 +100,21 @@ export let ACCEPT_LEGACY_V1 = coerceBoolean(
|
||||
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
|
||||
);
|
||||
|
||||
export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
|
||||
export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
|
||||
export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71,
|
||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
||||
);
|
||||
|
||||
export let WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
);
|
||||
@@ -118,6 +141,48 @@ Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
},
|
||||
set(next) {
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_PUBLISH_NIP71;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -132,6 +197,9 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
// Ensure the runtime object reflects the sanitized defaults immediately.
|
||||
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
|
||||
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||
|
||||
export function setUrlFirstEnabled(next) {
|
||||
@@ -144,6 +212,15 @@ export function setAcceptLegacyV1(next) {
|
||||
return ACCEPT_LEGACY_V1;
|
||||
}
|
||||
|
||||
export function setViewFilterIncludeLegacyVideo(next) {
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = next;
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
}
|
||||
|
||||
export function getWatchHistoryV2Enabled() {
|
||||
return FEATURE_WATCH_HISTORY_V2 === true;
|
||||
}
|
||||
|
||||
export function setWssTrackers(next) {
|
||||
runtimeFlags.WSS_TRACKERS = next;
|
||||
return WSS_TRACKERS;
|
||||
@@ -152,8 +229,17 @@ export function setWssTrackers(next) {
|
||||
export function resetRuntimeFlags() {
|
||||
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||
setViewFilterIncludeLegacyVideo(
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
|
||||
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
|
||||
}
|
||||
|
||||
export const RUNTIME_FLAGS = runtimeFlags;
|
||||
|
||||
export function setWatchHistoryV2Enabled(next) {
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next;
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
const DEFAULT_CARD_SELECTOR = ".video-card";
|
||||
|
||||
function defaultIsCardVisible(entry) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const isIntersecting = Boolean(entry.isIntersecting);
|
||||
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
|
||||
return isIntersecting && ratio > 0;
|
||||
}
|
||||
|
||||
function normalizeState(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function createCardObserver(options = {}) {
|
||||
const {
|
||||
rootMargin = "0px",
|
||||
threshold = 0,
|
||||
cardSelector = DEFAULT_CARD_SELECTOR,
|
||||
isCardVisible = defaultIsCardVisible,
|
||||
prepareEntries,
|
||||
onCardVisible,
|
||||
onCardRegister,
|
||||
createState,
|
||||
} = options;
|
||||
|
||||
const containerState = new WeakMap();
|
||||
|
||||
function ensureState(container) {
|
||||
let state = containerState.get(container);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const observedCards = new WeakSet();
|
||||
const customState = normalizeState(
|
||||
typeof createState === "function" ? createState(container) : {}
|
||||
);
|
||||
|
||||
const stateWrapper = {
|
||||
container,
|
||||
observedCards,
|
||||
customState,
|
||||
observer: null,
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
handleEntries(entries, stateWrapper);
|
||||
},
|
||||
{ root: null, rootMargin, threshold }
|
||||
);
|
||||
|
||||
stateWrapper.observer = observer;
|
||||
containerState.set(container, stateWrapper);
|
||||
return stateWrapper;
|
||||
}
|
||||
|
||||
function mapEntries(entries, state) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const context = {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
state: state.customState,
|
||||
};
|
||||
|
||||
if (typeof prepareEntries === "function") {
|
||||
try {
|
||||
const prepared = prepareEntries(entries, context);
|
||||
if (Array.isArray(prepared)) {
|
||||
return prepared;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] prepareEntries failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function handleEntries(entries, state) {
|
||||
const mapped = mapEntries(entries, state);
|
||||
if (!mapped.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapped.forEach((item) => {
|
||||
let entry = item;
|
||||
let card;
|
||||
let meta;
|
||||
|
||||
if (item && typeof item === "object" && "entry" in item) {
|
||||
entry = item.entry;
|
||||
card = item.card instanceof HTMLElement ? item.card : entry?.target;
|
||||
if ("meta" in item) {
|
||||
meta = item.meta;
|
||||
} else if ("data" in item) {
|
||||
meta = item.data;
|
||||
} else if (typeof item.priority !== "undefined") {
|
||||
meta = { priority: item.priority };
|
||||
}
|
||||
} else if (entry && entry.target instanceof HTMLElement) {
|
||||
card = entry.target;
|
||||
}
|
||||
|
||||
if (!(card instanceof HTMLElement) || !entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
let visible = false;
|
||||
try {
|
||||
visible = Boolean(isCardVisible(entry, card, {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
state: state.customState,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] isCardVisible failed", err);
|
||||
visible = false;
|
||||
}
|
||||
|
||||
if (!visible || typeof onCardVisible !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onCardVisible({
|
||||
card,
|
||||
entry,
|
||||
meta,
|
||||
state: state.customState,
|
||||
context: {
|
||||
container: state.container,
|
||||
observer: state.observer,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] onCardVisible failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observe(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = ensureState(container);
|
||||
const cards = container.querySelectorAll(cardSelector);
|
||||
cards.forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (state.observedCards.has(card)) {
|
||||
return;
|
||||
}
|
||||
state.observedCards.add(card);
|
||||
if (typeof onCardRegister === "function") {
|
||||
try {
|
||||
onCardRegister({
|
||||
card,
|
||||
state: state.customState,
|
||||
context: { container },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[cardObserver] onCardRegister failed", err);
|
||||
}
|
||||
}
|
||||
state.observer.observe(card);
|
||||
});
|
||||
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
handleEntries(records, state);
|
||||
}
|
||||
|
||||
return state.customState;
|
||||
}
|
||||
|
||||
function refresh(container) {
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const records = state.observer.takeRecords();
|
||||
if (records.length) {
|
||||
handleEntries(records, state);
|
||||
}
|
||||
}
|
||||
|
||||
function getState(container) {
|
||||
const state = containerState.get(container);
|
||||
return state ? state.customState : null;
|
||||
}
|
||||
|
||||
return {
|
||||
observe,
|
||||
refresh,
|
||||
getState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// js/feedEngine/engine.js
|
||||
|
||||
import { isPlainObject } from "./utils.js";
|
||||
|
||||
const DEFAULT_FEED_CONFIG = Object.freeze({
|
||||
timeWindow: null,
|
||||
actorFilters: [],
|
||||
tagFilters: [],
|
||||
sortOrder: "recent",
|
||||
});
|
||||
|
||||
const DEFAULT_CONFIG_SCHEMA = Object.freeze({
|
||||
timeWindow: {
|
||||
type: "relative-window",
|
||||
description:
|
||||
"Restrict results to a rolling time window (e.g., last 24 hours).",
|
||||
default: null,
|
||||
},
|
||||
actorFilters: {
|
||||
type: "string[]",
|
||||
description: "Optional list of author pubkeys to include in the feed.",
|
||||
default: [],
|
||||
},
|
||||
tagFilters: {
|
||||
type: "string[]",
|
||||
description: "Optional list of tag identifiers to include in the feed.",
|
||||
default: [],
|
||||
},
|
||||
sortOrder: {
|
||||
type: "enum",
|
||||
values: ["recent"],
|
||||
description:
|
||||
"Controls the final ordering of the feed. Currently only 'recent' is implemented.",
|
||||
default: "recent",
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeLogger(logger) {
|
||||
if (typeof logger === "function") {
|
||||
return logger;
|
||||
}
|
||||
if (logger && typeof logger.log === "function") {
|
||||
return (...args) => logger.log(...args);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function normalizeArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((entry) => typeof entry === "function");
|
||||
}
|
||||
|
||||
function normalizeDto(candidate) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = candidate.video ?? null;
|
||||
const pointer = candidate.pointer ?? null;
|
||||
const metadata = isPlainObject(candidate.metadata)
|
||||
? { ...candidate.metadata }
|
||||
: {};
|
||||
|
||||
return { video, pointer, metadata };
|
||||
}
|
||||
|
||||
function normalizeItems(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = [];
|
||||
for (const candidate of items) {
|
||||
const dto = normalizeDto(candidate);
|
||||
if (dto) {
|
||||
normalized.push(dto);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createExecutionContext(entry, { config, hooks, runtime, logger }) {
|
||||
const whyLog = [];
|
||||
const resolvedLogger = normalizeLogger(logger);
|
||||
|
||||
const context = {
|
||||
feedName: entry.name,
|
||||
config: { ...entry.defaultConfig, ...(isPlainObject(config) ? config : {}) },
|
||||
hooks: isPlainObject(hooks) ? { ...entry.hooks, ...hooks } : { ...entry.hooks },
|
||||
runtime: isPlainObject(runtime) ? { ...runtime } : {},
|
||||
log: (...args) => {
|
||||
try {
|
||||
resolvedLogger(`[feed:${entry.name}]`, ...args);
|
||||
} catch (error) {
|
||||
console.warn(`[feed:${entry.name}] logger threw`, error);
|
||||
}
|
||||
},
|
||||
addWhy: (detail) => {
|
||||
if (!isPlainObject(detail)) {
|
||||
return;
|
||||
}
|
||||
const record = { feed: entry.name, ...detail };
|
||||
whyLog.push(record);
|
||||
return record;
|
||||
},
|
||||
getWhy: () => whyLog.slice(),
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function normalizeDefinition(name, definition = {}) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
throw new Error("Feed name must be a non-empty string");
|
||||
}
|
||||
|
||||
if (typeof definition.source !== "function") {
|
||||
throw new Error(`Feed \"${name}\" is missing a source function`);
|
||||
}
|
||||
|
||||
const stages = normalizeArray(definition.stages);
|
||||
const decorators = normalizeArray(definition.decorators);
|
||||
const sorter =
|
||||
typeof definition.sorter === "function" ? definition.sorter : null;
|
||||
const hooks = isPlainObject(definition.hooks) ? { ...definition.hooks } : {};
|
||||
const defaultConfig = {
|
||||
...DEFAULT_FEED_CONFIG,
|
||||
...(isPlainObject(definition.defaultConfig) ? definition.defaultConfig : {}),
|
||||
};
|
||||
const configSchema = isPlainObject(definition.configSchema)
|
||||
? { ...DEFAULT_CONFIG_SCHEMA, ...definition.configSchema }
|
||||
: { ...DEFAULT_CONFIG_SCHEMA };
|
||||
|
||||
return {
|
||||
name,
|
||||
source: definition.source,
|
||||
stages,
|
||||
sorter,
|
||||
decorators,
|
||||
hooks,
|
||||
defaultConfig,
|
||||
configSchema,
|
||||
publicDefinition: Object.freeze({
|
||||
name,
|
||||
configDefaults: { ...defaultConfig },
|
||||
configSchema: { ...configSchema },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFeedEngine({ logger } = {}) {
|
||||
const feeds = new Map();
|
||||
const resolvedLogger = normalizeLogger(logger);
|
||||
|
||||
function registerFeed(name, definition) {
|
||||
if (feeds.has(name)) {
|
||||
throw new Error(`Feed \"${name}\" is already registered`);
|
||||
}
|
||||
|
||||
const entry = normalizeDefinition(name, definition);
|
||||
feeds.set(name, entry);
|
||||
return entry.publicDefinition;
|
||||
}
|
||||
|
||||
async function runFeed(name, options = {}) {
|
||||
const entry = feeds.get(name);
|
||||
if (!entry) {
|
||||
throw new Error(`Feed \"${name}\" is not registered`);
|
||||
}
|
||||
|
||||
const context = createExecutionContext(entry, {
|
||||
config: options.config,
|
||||
hooks: options.hooks,
|
||||
runtime: options.runtime,
|
||||
logger: resolvedLogger,
|
||||
});
|
||||
|
||||
const sourceResult = await entry.source(context);
|
||||
let items = normalizeItems(await Promise.resolve(sourceResult));
|
||||
|
||||
for (const stage of entry.stages) {
|
||||
const result = await stage(items, context);
|
||||
if (Array.isArray(result)) {
|
||||
items = normalizeItems(result);
|
||||
} else if (result == null) {
|
||||
items = normalizeItems(items);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.sorter) {
|
||||
const sorted = await entry.sorter(items, context);
|
||||
if (Array.isArray(sorted)) {
|
||||
items = normalizeItems(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
for (const decorator of entry.decorators) {
|
||||
const decorated = await decorator(items, context);
|
||||
if (Array.isArray(decorated)) {
|
||||
items = normalizeItems(decorated);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
videos: items.map((item) => item.video).filter(Boolean),
|
||||
metadata: {
|
||||
why: context.getWhy(),
|
||||
config: context.config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listFeeds() {
|
||||
return Array.from(feeds.values()).map((entry) => entry.publicDefinition);
|
||||
}
|
||||
|
||||
function getFeedDefinition(name) {
|
||||
const entry = feeds.get(name);
|
||||
return entry ? entry.publicDefinition : null;
|
||||
}
|
||||
|
||||
return {
|
||||
registerFeed,
|
||||
runFeed,
|
||||
listFeeds,
|
||||
getFeedDefinition,
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_FEED_CONFIG, DEFAULT_CONFIG_SCHEMA };
|
||||
@@ -0,0 +1,19 @@
|
||||
// js/feedEngine/index.js
|
||||
|
||||
export {
|
||||
createFeedEngine,
|
||||
DEFAULT_FEED_CONFIG,
|
||||
DEFAULT_CONFIG_SCHEMA,
|
||||
} from "./engine.js";
|
||||
export {
|
||||
createDedupeByRootStage,
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
} from "./stages.js";
|
||||
export { createChronologicalSorter } from "./sorters.js";
|
||||
export {
|
||||
createActiveNostrSource,
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
// js/feedEngine/sorters.js
|
||||
|
||||
export function createChronologicalSorter({ direction = "desc" } = {}) {
|
||||
const normalizedDirection = direction === "asc" ? "asc" : "desc";
|
||||
|
||||
return function chronologicalSorter(items = []) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const copy = [...items];
|
||||
copy.sort((a, b) => {
|
||||
const aTs = Number(a?.video?.created_at);
|
||||
const bTs = Number(b?.video?.created_at);
|
||||
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
|
||||
const normalizedBTs = Number.isFinite(bTs) ? bTs : Number.NEGATIVE_INFINITY;
|
||||
|
||||
if (normalizedATs !== normalizedBTs) {
|
||||
const diff = normalizedATs - normalizedBTs;
|
||||
return normalizedDirection === "asc" ? diff : -diff;
|
||||
}
|
||||
|
||||
const aId = typeof a?.video?.id === "string" ? a.video.id : "";
|
||||
const bId = typeof b?.video?.id === "string" ? b.video.id : "";
|
||||
return normalizedDirection === "asc"
|
||||
? aId.localeCompare(bId)
|
||||
: bId.localeCompare(aId);
|
||||
});
|
||||
|
||||
return copy;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// js/feedEngine/sources.js
|
||||
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import { isPlainObject, toArray, toSet } from "./utils.js";
|
||||
|
||||
function resolveService(candidate, fallback) {
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeAuthor(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeActorCandidate(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function createActiveNostrSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
|
||||
return async function activeNostrSource(context = {}) {
|
||||
const options = {
|
||||
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
||||
isAuthorBlocked:
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false,
|
||||
};
|
||||
|
||||
let videos = [];
|
||||
try {
|
||||
videos = await Promise.resolve(
|
||||
resolvedService.getFilteredActiveVideos(options)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.("[active-source] Failed to resolve active videos", error);
|
||||
videos = [];
|
||||
}
|
||||
|
||||
return (Array.isArray(videos) ? videos : []).map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:active",
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
|
||||
return async function subscriptionAuthorsSource(context = {}) {
|
||||
const runtimeAuthors = toArray(
|
||||
context?.runtime?.subscriptionAuthors || context?.runtime?.authors
|
||||
).map(normalizeAuthor);
|
||||
const configAuthors = toArray(context?.config?.actorFilters).map(
|
||||
normalizeAuthor
|
||||
);
|
||||
const hookAuthors = [];
|
||||
|
||||
const hook = context?.hooks?.subscriptions;
|
||||
if (isPlainObject(hook) && typeof hook.resolveAuthors === "function") {
|
||||
try {
|
||||
const resolved = await hook.resolveAuthors(context);
|
||||
hookAuthors.push(...toArray(resolved).map(normalizeAuthor));
|
||||
} catch (error) {
|
||||
context?.log?.("[subscriptions-source] resolveAuthors threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
const authors = new Set(
|
||||
[...runtimeAuthors, ...configAuthors, ...hookAuthors].filter(Boolean)
|
||||
);
|
||||
|
||||
if (!authors.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = {
|
||||
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
||||
isAuthorBlocked:
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false,
|
||||
};
|
||||
|
||||
let videos = [];
|
||||
try {
|
||||
videos = await Promise.resolve(
|
||||
resolvedService.getFilteredActiveVideos(options)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[subscriptions-source] Failed to resolve videos from nostrService",
|
||||
error
|
||||
);
|
||||
videos = [];
|
||||
}
|
||||
|
||||
const filtered = (Array.isArray(videos) ? videos : []).filter((video) => {
|
||||
const author = normalizeAuthor(video?.pubkey);
|
||||
return author && authors.has(author);
|
||||
});
|
||||
|
||||
return filtered.map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:subscriptions",
|
||||
matchedAuthor: normalizeAuthor(video?.pubkey),
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function createWatchHistoryPointerSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, watchHistoryService);
|
||||
|
||||
return async function watchHistoryPointerSource(context = {}) {
|
||||
const actor = normalizeActorCandidate(
|
||||
context?.config?.actor,
|
||||
context?.runtime?.watchHistory?.actor,
|
||||
context?.runtime?.actor
|
||||
);
|
||||
|
||||
if (!resolvedService || typeof resolvedService.getQueuedPointers !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let pointers = [];
|
||||
try {
|
||||
pointers = await Promise.resolve(
|
||||
resolvedService.getQueuedPointers(actor || undefined)
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-source] Failed to load queued pointers",
|
||||
error
|
||||
);
|
||||
pointers = [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const hook = context?.hooks?.watchHistory;
|
||||
const resolveVideoHook =
|
||||
isPlainObject(hook) && typeof hook.resolveVideo === "function"
|
||||
? hook.resolveVideo
|
||||
: null;
|
||||
|
||||
for (const pointer of Array.isArray(pointers) ? pointers : []) {
|
||||
const dto = {
|
||||
video: null,
|
||||
pointer,
|
||||
metadata: {
|
||||
source: "watch-history",
|
||||
actor: actor || null,
|
||||
},
|
||||
};
|
||||
|
||||
if (pointer && isPlainObject(pointer) && pointer.video) {
|
||||
dto.video = pointer.video;
|
||||
}
|
||||
|
||||
if (!dto.video && resolveVideoHook) {
|
||||
try {
|
||||
const resolvedVideo = await resolveVideoHook(pointer, context);
|
||||
if (resolvedVideo) {
|
||||
dto.video = resolvedVideo;
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-source] resolveVideo hook threw",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// js/feedEngine/stages.js
|
||||
|
||||
import { getApplication } from "../applicationContext.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
|
||||
import { isPlainObject, toSet } from "./utils.js";
|
||||
|
||||
function resolveDedupeFunction(customDedupe) {
|
||||
if (typeof customDedupe === "function") {
|
||||
return customDedupe;
|
||||
}
|
||||
|
||||
const app = getApplication?.();
|
||||
if (app && typeof app.dedupeVideosByRoot === "function") {
|
||||
return (videos) => app.dedupeVideosByRoot(videos);
|
||||
}
|
||||
|
||||
return (videos) => dedupeToNewestByRoot(videos);
|
||||
}
|
||||
|
||||
export function createDedupeByRootStage({
|
||||
stageName = "dedupe-by-root",
|
||||
dedupe: customDedupe,
|
||||
} = {}) {
|
||||
const dedupeFn = resolveDedupeFunction(customDedupe);
|
||||
|
||||
return async function dedupeByRootStage(items = [], context = {}) {
|
||||
const videos = items.map((item) => item?.video).filter(Boolean);
|
||||
const dedupedVideos = dedupeFn(videos) || [];
|
||||
const allowedIds = new Set();
|
||||
|
||||
for (const video of dedupedVideos) {
|
||||
if (video && typeof video.id === "string") {
|
||||
allowedIds.add(video.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedIds.size) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const keep = [];
|
||||
for (const item of items) {
|
||||
const video = item?.video;
|
||||
const videoId = video && typeof video.id === "string" ? video.id : "";
|
||||
if (!videoId || allowedIds.has(videoId)) {
|
||||
keep.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rootId =
|
||||
typeof video?.videoRootId === "string" && video.videoRootId
|
||||
? video.videoRootId
|
||||
: videoId;
|
||||
|
||||
if (typeof item?.metadata === "object" && item.metadata) {
|
||||
item.metadata.droppedByStage = stageName;
|
||||
}
|
||||
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "dedupe",
|
||||
reason: "older-root-version",
|
||||
videoId,
|
||||
rootId,
|
||||
});
|
||||
}
|
||||
|
||||
return keep;
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlacklistFilterStage({
|
||||
stageName = "blacklist-filter",
|
||||
shouldIncludeVideo,
|
||||
} = {}) {
|
||||
const includeFn =
|
||||
typeof shouldIncludeVideo === "function"
|
||||
? shouldIncludeVideo
|
||||
: (video, options) => nostrService.shouldIncludeVideo(video, options);
|
||||
|
||||
return async function blacklistFilterStage(items = [], context = {}) {
|
||||
const blacklist = toSet(context?.runtime?.blacklistedEventIds);
|
||||
const isAuthorBlocked =
|
||||
typeof context?.runtime?.isAuthorBlocked === "function"
|
||||
? context.runtime.isAuthorBlocked
|
||||
: () => false;
|
||||
|
||||
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
const video = item?.video;
|
||||
if (!video || typeof video !== "object") {
|
||||
results.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
let include = false;
|
||||
try {
|
||||
include = includeFn(video, options) !== false;
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] shouldIncludeVideo threw`, error);
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!include) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "blacklist",
|
||||
videoId: typeof video.id === "string" ? video.id : null,
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSuppressionHook(context) {
|
||||
if (!context || typeof context !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromHooks = context?.hooks?.watchHistory;
|
||||
if (isPlainObject(fromHooks) && typeof fromHooks.shouldSuppress === "function") {
|
||||
return fromHooks.shouldSuppress;
|
||||
}
|
||||
|
||||
const runtime = context?.runtime?.watchHistory;
|
||||
if (isPlainObject(runtime) && typeof runtime.shouldSuppress === "function") {
|
||||
return runtime.shouldSuppress;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWatchHistorySuppressionStage({
|
||||
stageName = "watch-history-suppression",
|
||||
shouldSuppress,
|
||||
} = {}) {
|
||||
return async function watchHistorySuppressionStage(items = [], context = {}) {
|
||||
const hook =
|
||||
typeof shouldSuppress === "function" ? shouldSuppress : resolveSuppressionHook(context);
|
||||
|
||||
if (typeof hook !== "function") {
|
||||
return items;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
let suppress = false;
|
||||
try {
|
||||
suppress = await hook(item, context);
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] suppression hook threw`, error);
|
||||
suppress = false;
|
||||
}
|
||||
|
||||
if (suppress) {
|
||||
const detail = {
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "watch-history",
|
||||
};
|
||||
if (item?.video && typeof item.video.id === "string") {
|
||||
detail.videoId = item.video.id;
|
||||
}
|
||||
if (item?.pointer) {
|
||||
detail.pointer = item.pointer;
|
||||
} else if (item?.metadata && item.metadata.pointerKey) {
|
||||
detail.pointerKey = item.metadata.pointerKey;
|
||||
}
|
||||
context?.addWhy?.(detail);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// js/feedEngine/utils.js
|
||||
|
||||
export function isPlainObject(value) {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function toSet(values) {
|
||||
if (values instanceof Set) {
|
||||
return new Set(values);
|
||||
}
|
||||
if (Array.isArray(values)) {
|
||||
return new Set(values);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
export function toArray(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return [...value];
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// js/feedEngine/watchHistoryFeed.js
|
||||
|
||||
import { pointerKey, normalizePointerInput } from "../nostr.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js";
|
||||
import {
|
||||
isWatchHistoryDebugEnabled,
|
||||
logWatchHistoryDebug,
|
||||
} from "../watchHistoryDebug.js";
|
||||
import {
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
} from "./stages.js";
|
||||
import { isPlainObject } from "./utils.js";
|
||||
|
||||
const WATCH_HISTORY_FEED_LOG_NAMESPACE = "watchHistoryFeed";
|
||||
|
||||
function debugInfo(message, details) {
|
||||
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "info", message, details);
|
||||
}
|
||||
|
||||
function debugWarn(message, details) {
|
||||
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "warn", message, details);
|
||||
}
|
||||
|
||||
function normalizeActorCandidate(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveWatchedAt(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (!Number.isFinite(candidate)) {
|
||||
continue;
|
||||
}
|
||||
let normalized = Math.floor(Number(candidate));
|
||||
if (normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (normalized > 10_000_000_000) {
|
||||
normalized = Math.floor(normalized / 1000);
|
||||
}
|
||||
if (normalized > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveResumeSeconds(candidate) {
|
||||
const candidates = [
|
||||
candidate?.resumeSeconds,
|
||||
candidate?.resume,
|
||||
candidate?.resumeAt,
|
||||
candidate?.resumePosition,
|
||||
candidate?.resumeTime,
|
||||
candidate?.progress?.resume,
|
||||
candidate?.progress?.seconds,
|
||||
candidate?.progress?.position,
|
||||
candidate?.pointer?.resumeSeconds,
|
||||
candidate?.pointer?.resume,
|
||||
candidate?.pointer?.resumeAt,
|
||||
];
|
||||
|
||||
for (const entry of candidates) {
|
||||
if (!Number.isFinite(entry)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = Math.max(0, Math.floor(entry));
|
||||
if (normalized > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCompleted(candidate) {
|
||||
const candidates = [
|
||||
candidate?.completed,
|
||||
candidate?.complete,
|
||||
candidate?.progress?.completed,
|
||||
candidate?.progress?.complete,
|
||||
candidate?.pointer?.completed,
|
||||
candidate?.pointer?.complete,
|
||||
];
|
||||
return candidates.some((value) => value === true);
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(raw) {
|
||||
const pointer = normalizePointerInput(raw?.pointer || raw);
|
||||
if (!pointer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = pointerKey(pointer);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const watchedAt = resolveWatchedAt(
|
||||
raw?.watchedAt,
|
||||
raw?.timestamp,
|
||||
raw?.created_at,
|
||||
pointer?.watchedAt,
|
||||
raw?.metadata?.watchedAt,
|
||||
pointer?.metadata?.watchedAt,
|
||||
);
|
||||
|
||||
const resumeAt = (() => {
|
||||
const fromPointer = Number.isFinite(pointer?.resumeAt)
|
||||
? Math.max(0, Math.floor(pointer.resumeAt))
|
||||
: Number.isFinite(pointer?.metadata?.resumeAt)
|
||||
? Math.max(0, Math.floor(pointer.metadata.resumeAt))
|
||||
: null;
|
||||
if (fromPointer !== null) {
|
||||
return fromPointer;
|
||||
}
|
||||
return resolveResumeSeconds(raw);
|
||||
})();
|
||||
|
||||
const completed =
|
||||
pointer?.completed === true ||
|
||||
pointer?.metadata?.completed === true ||
|
||||
resolveCompleted(raw);
|
||||
|
||||
const pointerVideo =
|
||||
(pointer?.video && isPlainObject(pointer.video) ? pointer.video : null) ||
|
||||
(pointer?.metadata?.video && isPlainObject(pointer.metadata.video)
|
||||
? pointer.metadata.video
|
||||
: null);
|
||||
|
||||
const pointerProfile =
|
||||
(pointer?.profile && isPlainObject(pointer.profile) ? pointer.profile : null) ||
|
||||
(pointer?.metadata?.profile && isPlainObject(pointer.metadata.profile)
|
||||
? pointer.metadata.profile
|
||||
: null);
|
||||
|
||||
return {
|
||||
pointer,
|
||||
pointerKey: key,
|
||||
watchedAt,
|
||||
video:
|
||||
isPlainObject(raw?.video) && raw.video
|
||||
? raw.video
|
||||
: pointerVideo,
|
||||
metadata: {
|
||||
source: "watch-history",
|
||||
pointerKey: key,
|
||||
watchedAt,
|
||||
resumeAt,
|
||||
completed,
|
||||
session: raw?.session === true || pointer.session === true,
|
||||
video: pointerVideo,
|
||||
profile: pointerProfile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistorySource({ service = watchHistoryService } = {}) {
|
||||
return async function watchHistorySource(context = {}) {
|
||||
const actor = normalizeActorCandidate(
|
||||
context?.runtime?.watchHistory?.actor,
|
||||
context?.config?.actor,
|
||||
context?.runtime?.actor,
|
||||
);
|
||||
|
||||
if (!service || typeof service.loadLatest !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let items = [];
|
||||
try {
|
||||
items = await Promise.resolve(service.loadLatest(actor || undefined));
|
||||
} catch (error) {
|
||||
context?.log?.("[watch-history-feed] Failed to load history", error);
|
||||
items = [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const raw of Array.isArray(items) ? items : []) {
|
||||
const normalized = normalizeHistoryEntry(raw);
|
||||
if (normalized) {
|
||||
results.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (a.watchedAt !== b.watchedAt) {
|
||||
return b.watchedAt - a.watchedAt;
|
||||
}
|
||||
return a.pointerKey.localeCompare(b.pointerKey);
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistoryHydratorStage({
|
||||
service = watchHistoryService,
|
||||
metadataResolver,
|
||||
} = {}) {
|
||||
const resolver = metadataResolver || createWatchHistoryMetadataResolver();
|
||||
|
||||
return async function watchHistoryHydratorStage(items = [], context = {}) {
|
||||
const shouldStoreMetadata =
|
||||
typeof service?.shouldStoreMetadata === "function"
|
||||
? service.shouldStoreMetadata() !== false
|
||||
: true;
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointerKeyValue =
|
||||
typeof item.pointerKey === "string" && item.pointerKey
|
||||
? item.pointerKey
|
||||
: pointerKey(item.pointer);
|
||||
|
||||
const pointerType =
|
||||
typeof item?.pointer?.type === "string" ? item.pointer.type : null;
|
||||
const pointerValue =
|
||||
typeof item?.pointer?.value === "string" ? item.pointer.value : null;
|
||||
|
||||
const watchedAtValue = Number.isFinite(item?.watchedAt)
|
||||
? Math.max(0, Math.floor(Number(item.watchedAt)))
|
||||
: Number.isFinite(item?.metadata?.watchedAt)
|
||||
? Math.max(0, Math.floor(Number(item.metadata.watchedAt)))
|
||||
: 0;
|
||||
|
||||
let video = item.video || null;
|
||||
let profile = item.metadata?.profile || null;
|
||||
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Hydrating watch history item.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
hasInitialVideo: Boolean(video),
|
||||
hasInitialProfile: Boolean(profile),
|
||||
watchedAt: watchedAtValue || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (pointerKeyValue && typeof service?.getLocalMetadata === "function") {
|
||||
try {
|
||||
const stored = service.getLocalMetadata(pointerKeyValue);
|
||||
if (stored) {
|
||||
const hadVideo = Boolean(video);
|
||||
const hadProfile = Boolean(profile);
|
||||
video = video || stored.video || null;
|
||||
profile = profile || stored.profile || null;
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Loaded cached metadata for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
hadVideo,
|
||||
hadProfile,
|
||||
cachedVideo: Boolean(stored.video),
|
||||
cachedProfile: Boolean(stored.profile),
|
||||
resultingVideo: Boolean(video),
|
||||
resultingProfile: Boolean(profile),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to read cached metadata",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to read cached metadata for pointer.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video && resolver?.resolveVideo) {
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Resolving video via metadata resolver.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
});
|
||||
}
|
||||
try {
|
||||
video = await resolver.resolveVideo(item.pointer);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
if (video) {
|
||||
debugInfo("Metadata resolver returned video candidate.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
videoId: typeof video.id === "string" ? video.id : null,
|
||||
title:
|
||||
typeof video.title === "string" && video.title.trim()
|
||||
? video.title
|
||||
: null,
|
||||
invalid: Boolean(video.invalid),
|
||||
});
|
||||
} else {
|
||||
debugWarn("Metadata resolver returned no video candidate.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to resolve video from pointer",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Metadata resolver threw while resolving video.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (video?.pubkey && !profile && resolver?.resolveProfile) {
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Resolving profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
}
|
||||
try {
|
||||
profile = resolver.resolveProfile(video.pubkey) || null;
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
if (profile) {
|
||||
const label =
|
||||
profile.display_name ||
|
||||
profile.name ||
|
||||
profile.username ||
|
||||
profile.nip05 ||
|
||||
null;
|
||||
debugInfo("Resolved profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
label: label || null,
|
||||
});
|
||||
} else {
|
||||
debugWarn("Resolver returned no profile for video author.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to resolve profile for pointer",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Profile resolver threw while hydrating metadata.", {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pubkey: video.pubkey,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
...(isPlainObject(item.metadata) ? item.metadata : {}),
|
||||
pointerKey: pointerKeyValue,
|
||||
watchedAt: watchedAtValue || null,
|
||||
resumeAt: item.metadata?.resumeAt ?? null,
|
||||
completed: item.metadata?.completed ?? false,
|
||||
session: item.metadata?.session === true,
|
||||
video: video || null,
|
||||
profile: profile || null,
|
||||
};
|
||||
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
const summary = {
|
||||
pointerKey: pointerKeyValue || null,
|
||||
pointerType,
|
||||
pointerValue,
|
||||
watchedAt: watchedAtValue || null,
|
||||
videoId: typeof video?.id === "string" ? video.id : null,
|
||||
title:
|
||||
typeof video?.title === "string" && video.title?.trim()
|
||||
? video.title
|
||||
: null,
|
||||
invalid: Boolean(video?.invalid),
|
||||
hasProfile: Boolean(profile),
|
||||
};
|
||||
if (video) {
|
||||
debugInfo("Hydrated watch history entry metadata.", summary);
|
||||
} else {
|
||||
debugWarn("Hydrated entry is missing resolved video metadata.", summary);
|
||||
}
|
||||
}
|
||||
|
||||
const nextItem = {
|
||||
...item,
|
||||
pointerKey: pointerKeyValue,
|
||||
watchedAt: watchedAtValue,
|
||||
video: video || null,
|
||||
metadata,
|
||||
};
|
||||
results.push(nextItem);
|
||||
|
||||
if (pointerKeyValue) {
|
||||
if (shouldStoreMetadata) {
|
||||
try {
|
||||
service?.setLocalMetadata?.(pointerKeyValue, {
|
||||
video: metadata.video,
|
||||
profile: metadata.profile,
|
||||
});
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Stored metadata cache entry for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
cachedVideo: Boolean(metadata.video),
|
||||
cachedProfile: Boolean(metadata.profile),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to persist metadata cache",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to persist metadata cache entry.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof service?.removeLocalMetadata === "function") {
|
||||
try {
|
||||
service.removeLocalMetadata(pointerKeyValue);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugInfo("Cleared metadata cache entry for pointer.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[watch-history-feed] Failed to clear cached metadata",
|
||||
error,
|
||||
);
|
||||
if (isWatchHistoryDebugEnabled()) {
|
||||
debugWarn("Failed to clear cached metadata entry.", {
|
||||
pointerKey: pointerKeyValue,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
function createWatchHistorySorter() {
|
||||
return function watchHistorySorter(items = []) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
const copy = [...items];
|
||||
copy.sort((a, b) => {
|
||||
if (a?.watchedAt !== b?.watchedAt) {
|
||||
return (b?.watchedAt || 0) - (a?.watchedAt || 0);
|
||||
}
|
||||
const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : "";
|
||||
const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : "";
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
return copy;
|
||||
};
|
||||
}
|
||||
|
||||
export function createWatchHistoryFeedDefinition({
|
||||
service = watchHistoryService,
|
||||
nostr = nostrService,
|
||||
metadataResolverFactory = createWatchHistoryMetadataResolver,
|
||||
shouldIncludeVideo,
|
||||
} = {}) {
|
||||
const metadataResolver = metadataResolverFactory({ nostr });
|
||||
|
||||
const blacklistStage = createBlacklistFilterStage({
|
||||
shouldIncludeVideo:
|
||||
typeof shouldIncludeVideo === "function"
|
||||
? shouldIncludeVideo
|
||||
: (video, options) =>
|
||||
(nostr?.shouldIncludeVideo
|
||||
? nostr.shouldIncludeVideo(video, options)
|
||||
: true),
|
||||
});
|
||||
|
||||
return {
|
||||
source: createWatchHistorySource({ service }),
|
||||
stages: [
|
||||
createWatchHistoryHydratorStage({ service, metadataResolver }),
|
||||
blacklistStage,
|
||||
createWatchHistorySuppressionStage(),
|
||||
],
|
||||
sorter: createWatchHistorySorter(),
|
||||
defaultConfig: {
|
||||
actorFilters: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWatchHistoryFeed(engine, options = {}) {
|
||||
if (!engine || typeof engine.registerFeed !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = createWatchHistoryFeedDefinition(options);
|
||||
return engine.registerFeed("watch-history", definition);
|
||||
}
|
||||
|
||||
+324
-120
@@ -1,7 +1,26 @@
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { infoHashFromMagnet } from "./magnets.js";
|
||||
import { TorrentClient, torrentClient } from "./webtorrent.js";
|
||||
|
||||
const containerState = new WeakMap();
|
||||
const badgeUpdateListeners = new Set();
|
||||
|
||||
function notifyBadgeUpdate(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
badgeUpdateListeners.forEach((listener) => {
|
||||
if (typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
listener(payload);
|
||||
} catch (err) {
|
||||
console.warn("[gridHealth] badge listener failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ROOT_MARGIN = "200px 0px";
|
||||
|
||||
function now() {
|
||||
@@ -110,7 +129,7 @@ function normalizeResult(result) {
|
||||
};
|
||||
}
|
||||
|
||||
function queueProbe(magnet, cacheKey) {
|
||||
function queueProbe(magnet, cacheKey, priority = 0) {
|
||||
if (!magnet) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -125,21 +144,23 @@ function queueProbe(magnet, 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,
|
||||
}))
|
||||
.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);
|
||||
@@ -163,8 +184,9 @@ function queueProbe(magnet, cacheKey) {
|
||||
|
||||
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
const PROBE_CONCURRENCY = 3;
|
||||
const PROBE_CONCURRENCY = 96;
|
||||
const PROBE_POLL_COUNT = 3;
|
||||
const PRIORITY_BASELINE = 1_000_000;
|
||||
|
||||
class ProbeQueue {
|
||||
constructor(max = 2) {
|
||||
@@ -173,51 +195,72 @@ class ProbeQueue {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
run(task) {
|
||||
run(task, priority = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ task, 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() {
|
||||
if (this.running >= this.max) {
|
||||
return;
|
||||
}
|
||||
const job = this.queue.shift();
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
this.running += 1;
|
||||
let finished = false;
|
||||
const finalize = () => {
|
||||
if (finished) {
|
||||
while (this.running < this.max && this.queue.length > 0) {
|
||||
const job = this.queue.shift();
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
this.running -= 1;
|
||||
this.drain();
|
||||
};
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(result)
|
||||
.then((value) => {
|
||||
job.resolve(value);
|
||||
})
|
||||
.catch((err) => {
|
||||
let result;
|
||||
try {
|
||||
result = job.task();
|
||||
} catch (err) {
|
||||
job.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
finalize();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve(result)
|
||||
.then((value) => {
|
||||
job.resolve(value);
|
||||
})
|
||||
.catch((err) => {
|
||||
job.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,41 +268,171 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
|
||||
const probeCache = new Map();
|
||||
const probeInflight = new Map();
|
||||
|
||||
function ensureState(container) {
|
||||
let state = containerState.get(container);
|
||||
if (state) {
|
||||
return state;
|
||||
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 [];
|
||||
}
|
||||
|
||||
const pendingByCard = new WeakMap();
|
||||
const observedCards = new WeakSet();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const card = entry.target;
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
if (!viewportCenter) {
|
||||
return filtered
|
||||
.sort((a, b) => {
|
||||
if (b.ratio !== a.ratio) {
|
||||
return b.ratio - a.ratio;
|
||||
}
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
handleCardVisible({ card, pendingByCard });
|
||||
});
|
||||
},
|
||||
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
|
||||
);
|
||||
return a.centerY - b.centerY;
|
||||
})
|
||||
.map((item, index) => ({
|
||||
entry: item.entry,
|
||||
priority: PRIORITY_BASELINE - index,
|
||||
}));
|
||||
}
|
||||
|
||||
state = { observer, pendingByCard, observedCards };
|
||||
containerState.set(container, state);
|
||||
return state;
|
||||
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 = [
|
||||
@@ -307,50 +480,66 @@ function setBadge(card, state, details) {
|
||||
},
|
||||
};
|
||||
|
||||
const entry = map[state] || map.unknown;
|
||||
const entry = map[normalizedState] || map.unknown;
|
||||
entry.classes.forEach((cls) => badge.classList.add(cls));
|
||||
|
||||
const peers =
|
||||
details && Number.isFinite(details.peers)
|
||||
? Math.max(0, Number(details.peers))
|
||||
: 0;
|
||||
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
|
||||
const peersText =
|
||||
normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : "";
|
||||
|
||||
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
|
||||
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
|
||||
const tooltip =
|
||||
state === "checking" || state === "unknown"
|
||||
normalizedState === "checking" || normalizedState === "unknown"
|
||||
? entry.aria
|
||||
: buildTooltip({
|
||||
peers,
|
||||
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");
|
||||
const ariaLive = entry.role === "alert" ? "assertive" : "polite";
|
||||
badge.setAttribute("aria-live", ariaLive);
|
||||
badge.setAttribute("role", entry.role);
|
||||
badge.dataset.streamHealthState = state;
|
||||
if (Number.isFinite(peers)) {
|
||||
badge.dataset.streamHealthPeers = String(peers);
|
||||
badge.dataset.streamHealthState = normalizedState;
|
||||
if (hasPeerCount) {
|
||||
badge.dataset.streamHealthPeers = peersTextValue;
|
||||
} else if (badge.dataset.streamHealthPeers) {
|
||||
delete badge.dataset.streamHealthPeers;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
card,
|
||||
state: normalizedState,
|
||||
peers: hasPeerCount ? peersValue : null,
|
||||
reason: details && typeof details.reason === "string" ? details.reason : null,
|
||||
checkedAt:
|
||||
details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null,
|
||||
text: badge.textContent,
|
||||
tooltip,
|
||||
role: entry.role,
|
||||
ariaLive,
|
||||
};
|
||||
notifyBadgeUpdate(payload);
|
||||
}
|
||||
|
||||
function handleCardVisible({ card, pendingByCard }) {
|
||||
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const magnet = card.dataset.magnet || "";
|
||||
if (!magnet) {
|
||||
setBadge(card, "unknown");
|
||||
setBadge(card, "unhealthy", { reason: "missing-source" });
|
||||
return;
|
||||
}
|
||||
|
||||
const infoHash = infoHashFromMagnet(magnet);
|
||||
if (!infoHash) {
|
||||
setBadge(card, "unknown");
|
||||
setBadge(card, "unhealthy", { reason: "invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(pendingByCard instanceof WeakMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -371,7 +560,7 @@ function handleCardVisible({ card, pendingByCard }) {
|
||||
|
||||
setBadge(card, "checking");
|
||||
|
||||
const probePromise = queueProbe(magnet, infoHash);
|
||||
const probePromise = queueProbe(magnet, infoHash, priority);
|
||||
|
||||
pendingByCard.set(card, probePromise);
|
||||
|
||||
@@ -405,38 +594,53 @@ function handleCardVisible({ card, pendingByCard }) {
|
||||
});
|
||||
}
|
||||
|
||||
export function attachHealthBadges(container) {
|
||||
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, options = {}) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const state = ensureState(container);
|
||||
const cards = container.querySelectorAll(".video-card");
|
||||
cards.forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (state.observedCards.has(card)) {
|
||||
return;
|
||||
}
|
||||
state.observedCards.add(card);
|
||||
state.observer.observe(card);
|
||||
if (!card.dataset.magnet) {
|
||||
setBadge(card, "unknown");
|
||||
}
|
||||
});
|
||||
if (options && typeof options.onUpdate === "function") {
|
||||
badgeUpdateListeners.add(options.onUpdate);
|
||||
}
|
||||
gridCardObserver.observe(container);
|
||||
}
|
||||
|
||||
export function refreshHealthBadges(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.observer.takeRecords().forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
|
||||
}
|
||||
});
|
||||
gridCardObserver.refresh(container);
|
||||
}
|
||||
+1962
File diff suppressed because it is too large.
Load diff
+449
-151
@@ -1,7 +1,50 @@
|
||||
// js/index.js
|
||||
|
||||
import { validateInstanceConfig } from "../config/validate-config.js";
|
||||
import { ASSET_VERSION } from "../config/asset-version.js";
|
||||
import "./bufferPolyfill.js";
|
||||
import { trackPageView } from "./analytics.js";
|
||||
import Application from "./app.js";
|
||||
import { setApplication, setApplicationReady } from "./applicationContext.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView, viewInitRegistry } from "./viewManager.js";
|
||||
|
||||
validateInstanceConfig();
|
||||
|
||||
let application = null;
|
||||
let applicationReadyPromise = Promise.resolve();
|
||||
|
||||
function startApplication() {
|
||||
if (application) {
|
||||
return applicationReadyPromise;
|
||||
}
|
||||
|
||||
application = new Application({
|
||||
services: {
|
||||
nostrService,
|
||||
r2Service,
|
||||
},
|
||||
loadView,
|
||||
});
|
||||
|
||||
setApplication(application);
|
||||
|
||||
const startupPromise = (async () => {
|
||||
await application.init();
|
||||
if (typeof application.start === "function") {
|
||||
await application.start();
|
||||
}
|
||||
})();
|
||||
|
||||
applicationReadyPromise = startupPromise;
|
||||
setApplicationReady(startupPromise);
|
||||
|
||||
startupPromise.catch((error) => {
|
||||
console.error("Application failed to initialize:", error);
|
||||
});
|
||||
|
||||
return startupPromise;
|
||||
}
|
||||
|
||||
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
|
||||
@@ -60,13 +103,26 @@ document.addEventListener("animationend", handleFadeInAnimationComplete, true);
|
||||
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
|
||||
|
||||
// 1) Load modals (login, application, etc.)
|
||||
const withAssetVersion = (url) => {
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}v=${encodeURIComponent(ASSET_VERSION)}`;
|
||||
};
|
||||
|
||||
const fetchPartial = async (url) => {
|
||||
const response = await fetch(withAssetVersion(url), { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
async function loadModal(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
|
||||
const sanitizedHtml = html.replace(
|
||||
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
|
||||
@@ -84,11 +140,7 @@ async function loadModal(url) {
|
||||
// 2) Load sidebar
|
||||
async function loadSidebar(url, containerId) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
document.getElementById(containerId).innerHTML = html;
|
||||
console.log(url, "loaded into", containerId);
|
||||
} catch (err) {
|
||||
@@ -99,11 +151,7 @@ async function loadSidebar(url, containerId) {
|
||||
// 3) Load the disclaimer (now separate)
|
||||
async function loadDisclaimer(url, containerId) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
const html = await response.text();
|
||||
const html = await fetchPartial(url);
|
||||
document.getElementById(containerId).insertAdjacentHTML("beforeend", html);
|
||||
console.log(url, "disclaimer loaded into", containerId);
|
||||
} catch (err) {
|
||||
@@ -111,138 +159,376 @@ async function loadDisclaimer(url, containerId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Load everything: modals, sidebar, disclaimers
|
||||
Promise.all([
|
||||
// Existing modals
|
||||
loadModal("components/login-modal.html"),
|
||||
loadModal("components/application-form.html"),
|
||||
loadModal("components/content-appeals-form.html"),
|
||||
async function bootstrapInterface() {
|
||||
await Promise.all([
|
||||
loadModal("components/login-modal.html"),
|
||||
loadModal("components/application-form.html"),
|
||||
loadModal("components/content-appeals-form.html"),
|
||||
loadModal("components/general-feedback-form.html"),
|
||||
loadModal("components/feature-request-form.html"),
|
||||
loadModal("components/bug-fix-form.html"),
|
||||
]);
|
||||
|
||||
// New forms
|
||||
loadModal("components/general-feedback-form.html"),
|
||||
loadModal("components/feature-request-form.html"),
|
||||
loadModal("components/bug-fix-form.html"),
|
||||
])
|
||||
.then(() => {
|
||||
console.log("Modals loaded.");
|
||||
return loadSidebar("components/sidebar.html", "sidebarContainer");
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Sidebar loaded.");
|
||||
console.log("Modals loaded.");
|
||||
|
||||
// Attach mobile menu button toggle logic (for sidebar)
|
||||
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const app = document.getElementById("app");
|
||||
if (mobileMenuBtn && sidebar && app) {
|
||||
mobileMenuBtn.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("sidebar-open");
|
||||
app.classList.toggle("sidebar-open");
|
||||
});
|
||||
await loadSidebar("components/sidebar.html", "sidebarContainer");
|
||||
console.log("Sidebar loaded.");
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const collapseToggle = document.getElementById("sidebarCollapseToggle");
|
||||
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
|
||||
sidebar.setAttribute("data-footer-state", "collapsed");
|
||||
}
|
||||
if (!collapseToggle) {
|
||||
console.warn("Sidebar collapse toggle not found; skipping density controls.");
|
||||
}
|
||||
|
||||
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
|
||||
const sidebarOverlay = document.getElementById("sidebarOverlay");
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebarCollapsed";
|
||||
const SIDEBAR_WIDTH_EXPANDED = "16rem";
|
||||
const SIDEBAR_WIDTH_COLLAPSED = "4rem";
|
||||
const DEFAULT_SIDEBAR_COLLAPSED = true;
|
||||
let isSidebarCollapsed = DEFAULT_SIDEBAR_COLLAPSED;
|
||||
let isFooterDropupExpanded = false;
|
||||
let syncFooterDropupFn = null;
|
||||
|
||||
const readStoredSidebarCollapsed = () => {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||
if (storedValue === null) {
|
||||
return DEFAULT_SIDEBAR_COLLAPSED;
|
||||
}
|
||||
return storedValue === "true";
|
||||
} catch (error) {
|
||||
console.warn("Unable to read sidebar collapse state from storage:", error);
|
||||
return DEFAULT_SIDEBAR_COLLAPSED;
|
||||
}
|
||||
};
|
||||
|
||||
// Attach "More" button toggle logic for footer links
|
||||
const footerDropdownButton = document.getElementById(
|
||||
"footerDropdownButton"
|
||||
const persistSidebarCollapsed = (collapsed) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
collapsed ? "true" : "false",
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Unable to persist sidebar collapse state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const applySidebarDensity = (collapsed) => {
|
||||
const widthTargets = [document.documentElement, document.body].filter(
|
||||
(element) => element instanceof HTMLElement,
|
||||
);
|
||||
if (footerDropdownButton) {
|
||||
footerDropdownButton.addEventListener("click", () => {
|
||||
const footerLinksContainer = document.getElementById(
|
||||
"footerLinksContainer"
|
||||
);
|
||||
if (!footerLinksContainer) return;
|
||||
footerLinksContainer.classList.toggle("hidden");
|
||||
if (footerLinksContainer.classList.contains("hidden")) {
|
||||
footerDropdownButton.innerHTML = "More ▼";
|
||||
} else {
|
||||
footerDropdownButton.innerHTML = "Less ▲";
|
||||
}
|
||||
});
|
||||
if (sidebar instanceof HTMLElement) {
|
||||
widthTargets.push(sidebar);
|
||||
}
|
||||
const appShell = document.getElementById("app");
|
||||
if (appShell instanceof HTMLElement) {
|
||||
widthTargets.push(appShell);
|
||||
}
|
||||
const state = collapsed ? "collapsed" : "expanded";
|
||||
const nextWidth = collapsed
|
||||
? SIDEBAR_WIDTH_COLLAPSED
|
||||
: SIDEBAR_WIDTH_EXPANDED;
|
||||
|
||||
// Load and set up sidebar navigation
|
||||
return import("./sidebar.js").then((module) => {
|
||||
module.setupSidebarNavigation();
|
||||
widthTargets.forEach((element) => {
|
||||
element.style.setProperty("--sidebar-width", nextWidth);
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Now load the disclaimer
|
||||
return loadDisclaimer("components/disclaimer.html", "modalContainer");
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Disclaimer loaded.");
|
||||
|
||||
// 1) Login button => open login modal
|
||||
const loginNavBtn = document.getElementById("loginButton");
|
||||
if (loginNavBtn) {
|
||||
loginNavBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
if (appShell instanceof HTMLElement) {
|
||||
appShell.dataset.sidebarState = state;
|
||||
}
|
||||
|
||||
// 2) Close login modal
|
||||
const closeLoginBtn = document.getElementById("closeLoginModal");
|
||||
if (closeLoginBtn) {
|
||||
closeLoginBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
[sidebar, document.body]
|
||||
.filter((element) => element instanceof HTMLElement)
|
||||
.forEach((element) => {
|
||||
element.classList.toggle("sidebar-collapsed", collapsed);
|
||||
element.classList.toggle("sidebar-expanded", !collapsed);
|
||||
element.setAttribute("data-state", state);
|
||||
});
|
||||
|
||||
if (collapseToggle) {
|
||||
const actionLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
|
||||
collapseToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
collapseToggle.setAttribute("data-state", state);
|
||||
collapseToggle.setAttribute("aria-label", actionLabel);
|
||||
collapseToggle.setAttribute("title", actionLabel);
|
||||
}
|
||||
|
||||
// 3) "Application Form" => open application form
|
||||
const openAppFormBtn = document.getElementById("openApplicationModal");
|
||||
if (openAppFormBtn) {
|
||||
openAppFormBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
if (
|
||||
collapsed &&
|
||||
isFooterDropupExpanded &&
|
||||
typeof syncFooterDropupFn === "function"
|
||||
) {
|
||||
syncFooterDropupFn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncSidebarDensityToViewport = (isDesktopViewport) => {
|
||||
if (!collapseToggle) {
|
||||
applySidebarDensity(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Close application form
|
||||
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
|
||||
if (closeNostrFormBtn) {
|
||||
closeNostrFormBtn.addEventListener("click", () => {
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.add("hidden");
|
||||
}
|
||||
// If user hasn't seen disclaimer, show it
|
||||
if (!localStorage.getItem("hasSeenDisclaimer")) {
|
||||
const disclaimerModal = document.getElementById("disclaimerModal");
|
||||
if (disclaimerModal) {
|
||||
disclaimerModal.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (isDesktopViewport) {
|
||||
isSidebarCollapsed = readStoredSidebarCollapsed();
|
||||
applySidebarDensity(isSidebarCollapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once everything is loaded, handle the query params (modal? v?) & disclaimers
|
||||
handleQueryParams();
|
||||
applySidebarDensity(false);
|
||||
};
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
if (collapseToggle) {
|
||||
isSidebarCollapsed = readStoredSidebarCollapsed();
|
||||
}
|
||||
|
||||
// Also run once on initial load
|
||||
handleHashChange();
|
||||
const setSidebarState = (isOpen) => {
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle("sidebar-open", isOpen);
|
||||
}
|
||||
document.body.classList.toggle("sidebar-open", isOpen);
|
||||
if (mobileMenuBtn) {
|
||||
mobileMenuBtn.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
return import("./disclaimer.js");
|
||||
})
|
||||
.then(({ default: disclaimerModal }) => {
|
||||
disclaimerModal.init();
|
||||
disclaimerModal.show();
|
||||
const closeSidebar = () => setSidebarState(false);
|
||||
const isMobileViewport = () => {
|
||||
if (typeof window.matchMedia === "function") {
|
||||
return window.matchMedia("(max-width: 767px)").matches;
|
||||
}
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (!sidebar) return;
|
||||
const isMobile = isMobileViewport();
|
||||
if (!isMobile) return;
|
||||
const shouldOpen = !sidebar.classList.contains("sidebar-open");
|
||||
setSidebarState(shouldOpen);
|
||||
};
|
||||
|
||||
let desktopQuery = null;
|
||||
const isDesktopViewport = () => {
|
||||
if (desktopQuery) {
|
||||
return desktopQuery.matches;
|
||||
}
|
||||
return window.innerWidth >= 768;
|
||||
};
|
||||
|
||||
if (collapseToggle) {
|
||||
collapseToggle.addEventListener("click", (event) => {
|
||||
const desktop = isDesktopViewport();
|
||||
if (!desktop) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextCollapsed = !isSidebarCollapsed;
|
||||
isSidebarCollapsed = nextCollapsed;
|
||||
applySidebarDensity(nextCollapsed);
|
||||
persistSidebarCollapsed(nextCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileMenuBtn && sidebar) {
|
||||
mobileMenuBtn.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.addEventListener("click", closeSidebar);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && sidebar && sidebar.classList.contains("sidebar-open")) {
|
||||
closeSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof window.matchMedia === "function") {
|
||||
desktopQuery = window.matchMedia("(min-width: 768px)");
|
||||
syncSidebarDensityToViewport(desktopQuery.matches);
|
||||
|
||||
const onDesktopChange = (event) => {
|
||||
if (event.matches) {
|
||||
closeSidebar();
|
||||
syncSidebarDensityToViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
syncSidebarDensityToViewport(false);
|
||||
};
|
||||
|
||||
if (typeof desktopQuery.addEventListener === "function") {
|
||||
desktopQuery.addEventListener("change", onDesktopChange);
|
||||
} else if (typeof desktopQuery.addListener === "function") {
|
||||
desktopQuery.addListener(onDesktopChange);
|
||||
}
|
||||
} else {
|
||||
syncSidebarDensityToViewport(window.innerWidth >= 768);
|
||||
}
|
||||
|
||||
const footerDropdownButton = document.getElementById("footerDropdownButton");
|
||||
const footerLinksContainer = document.getElementById("footerLinksContainer");
|
||||
const footerDropdownLabel = document.getElementById("footerDropdownText");
|
||||
const footerDropdownIcon = document.getElementById("footerDropdownIcon");
|
||||
|
||||
if (footerDropdownButton && footerLinksContainer) {
|
||||
const sidebarFooter = footerDropdownButton.closest(".sidebar-footer");
|
||||
|
||||
syncFooterDropupFn = (expanded) => {
|
||||
const nextState = expanded ? "expanded" : "collapsed";
|
||||
const actionLabel = expanded ? "Show fewer sidebar links" : "Show more sidebar links";
|
||||
|
||||
footerDropdownButton.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
footerDropdownButton.dataset.state = nextState;
|
||||
footerDropdownButton.setAttribute("aria-label", actionLabel);
|
||||
footerDropdownButton.setAttribute("title", actionLabel);
|
||||
|
||||
if (footerDropdownLabel) {
|
||||
footerDropdownLabel.textContent = expanded ? "Less" : "More";
|
||||
}
|
||||
|
||||
if (footerDropdownIcon) {
|
||||
footerDropdownIcon.classList.toggle("is-rotated", expanded);
|
||||
}
|
||||
|
||||
footerLinksContainer.classList.remove("hidden");
|
||||
footerLinksContainer.setAttribute("aria-hidden", expanded ? "false" : "true");
|
||||
footerLinksContainer.dataset.state = nextState;
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.setAttribute("data-footer-state", nextState);
|
||||
}
|
||||
|
||||
if (sidebarFooter instanceof HTMLElement) {
|
||||
sidebarFooter.dataset.footerState = nextState;
|
||||
}
|
||||
isFooterDropupExpanded = expanded;
|
||||
};
|
||||
|
||||
footerDropdownButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const expanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
|
||||
|
||||
if (isSidebarCollapsed && !expanded) {
|
||||
const nextCollapsed = false;
|
||||
isSidebarCollapsed = nextCollapsed;
|
||||
applySidebarDensity(nextCollapsed);
|
||||
persistSidebarCollapsed(nextCollapsed);
|
||||
}
|
||||
|
||||
if (typeof syncFooterDropupFn === "function") {
|
||||
syncFooterDropupFn(!expanded);
|
||||
}
|
||||
});
|
||||
|
||||
const initialExpanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
|
||||
if (typeof syncFooterDropupFn === "function") {
|
||||
syncFooterDropupFn(initialExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sidebarModule = await import("./sidebar.js");
|
||||
if (typeof sidebarModule.setupSidebarNavigation === "function") {
|
||||
sidebarModule.setupSidebarNavigation({ closeSidebar });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set up sidebar navigation:", error);
|
||||
}
|
||||
|
||||
await loadDisclaimer("components/disclaimer.html", "modalContainer");
|
||||
console.log("Disclaimer loaded.");
|
||||
|
||||
const loginNavBtn = document.getElementById("loginButton");
|
||||
if (loginNavBtn) {
|
||||
loginNavBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeLoginBtn = document.getElementById("closeLoginModal");
|
||||
if (closeLoginBtn) {
|
||||
closeLoginBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const openAppFormBtn = document.getElementById("openApplicationModal");
|
||||
if (openAppFormBtn) {
|
||||
openAppFormBtn.addEventListener("click", () => {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
}
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
|
||||
if (closeNostrFormBtn) {
|
||||
closeNostrFormBtn.addEventListener("click", () => {
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
if (appModal) {
|
||||
appModal.classList.add("hidden");
|
||||
}
|
||||
if (!localStorage.getItem("hasSeenDisclaimer")) {
|
||||
const disclaimerModal = document.getElementById("disclaimerModal");
|
||||
if (disclaimerModal) {
|
||||
disclaimerModal.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleQueryParams();
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
await handleHashChange();
|
||||
|
||||
const { default: disclaimerModal } = await import("./disclaimer.js");
|
||||
disclaimerModal.init();
|
||||
disclaimerModal.show();
|
||||
}
|
||||
|
||||
async function initializeInterface() {
|
||||
startApplication();
|
||||
|
||||
try {
|
||||
await bootstrapInterface();
|
||||
} catch (error) {
|
||||
console.error("Failed to bootstrap Bitvid interface:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function onDomReady() {
|
||||
initializeInterface().catch((error) => {
|
||||
console.error("Unhandled error during Bitvid initialization:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", onDomReady, { once: true });
|
||||
} else {
|
||||
onDomReady();
|
||||
}
|
||||
|
||||
/* -------------------------------------------
|
||||
HELPER FUNCTIONS FOR QUERY AND HASH
|
||||
-------------------------------------------- */
|
||||
@@ -257,6 +543,10 @@ export function setHashView(viewName) {
|
||||
url.searchParams.delete("v");
|
||||
const newUrl = url.pathname + url.search + `#view=${viewName}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
|
||||
}
|
||||
|
||||
handleHashChange();
|
||||
}
|
||||
|
||||
@@ -362,38 +652,46 @@ function handleQueryParams() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
async function handleHashChange() {
|
||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||
|
||||
try {
|
||||
await applicationReadyPromise;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Proceeding with hash handling despite application initialization failure:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const hash = window.location.hash || "";
|
||||
// Use a regex that captures up to the first ampersand or end of string.
|
||||
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
|
||||
const match = hash.match(/^#view=([^&]+)/);
|
||||
|
||||
if (!match || !match[1]) {
|
||||
// No valid "#view=..." => default to "most-recent-videos"
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView("views/most-recent-videos.html").then(() => {
|
||||
const initFn = viewInitRegistry["most-recent-videos"];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
recordView("most-recent-videos");
|
||||
});
|
||||
});
|
||||
return;
|
||||
try {
|
||||
if (!match || !match[1]) {
|
||||
// No valid "#view=..." => default to "most-recent-videos"
|
||||
await loadView("views/most-recent-videos.html");
|
||||
const initFn = viewInitRegistry["most-recent-videos"];
|
||||
if (typeof initFn === "function") {
|
||||
await initFn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const viewName = match[1]; // only the chunk before any '&'
|
||||
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
|
||||
}
|
||||
const viewUrl = `views/${viewName}.html`;
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
await loadView(viewUrl);
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
await initFn();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle hash change:", error);
|
||||
}
|
||||
|
||||
const viewName = match[1]; // only the chunk before any '&'
|
||||
const viewUrl = `views/${viewName}.html`;
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView(viewUrl).then(() => {
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+6
-22
@@ -1,26 +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
|
||||
"npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex
|
||||
];
|
||||
|
||||
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 = [];
|
||||
+31
-151
@@ -1,170 +1,50 @@
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
buildMagnetUri,
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
formatAbsoluteUrl,
|
||||
normalizeMagnetInput,
|
||||
resolveAppProtocol,
|
||||
extractMagnetHints as sharedExtractMagnetHints,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
export const extractMagnetHints = sharedExtractMagnetHints;
|
||||
|
||||
function getOriginProtocol() {
|
||||
if (typeof window !== "undefined" && window.location?.protocol) {
|
||||
return window.location.protocol.toLowerCase();
|
||||
}
|
||||
return "https:";
|
||||
}
|
||||
|
||||
function decodeLoose(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function tryFormatAbsoluteUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.toString();
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and enrich a magnet string entered in the upload form so that it
|
||||
* always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of
|
||||
* the legacy magnetUtils helper but returns a plain string for ease of use.
|
||||
*/
|
||||
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
|
||||
if (HEX_INFO_HASH.test(working)) {
|
||||
working = `magnet:?xt=urn:btih:${working.toLowerCase()}`;
|
||||
if (!isMagnet) {
|
||||
return canonicalValue;
|
||||
}
|
||||
|
||||
working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`);
|
||||
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
return working;
|
||||
}
|
||||
|
||||
let fragment = "";
|
||||
const hashIndex = working.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const [, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = "magnet:";
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let value = rawVal.trim();
|
||||
if (key.toLowerCase() === "xt" && value) {
|
||||
const decoded = decodeLoose(value);
|
||||
if (decoded) {
|
||||
value = decoded;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value,
|
||||
lowerKey: key.toLowerCase(),
|
||||
compareValue: normalizeForComparison(value),
|
||||
});
|
||||
}
|
||||
|
||||
const appendUniqueParam = (key, value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return;
|
||||
}
|
||||
const lowerKey = key.toLowerCase();
|
||||
const compareValue = normalizeForComparison(trimmedValue);
|
||||
if (compareValue) {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === lowerKey && param.compareValue === compareValue
|
||||
);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value: trimmedValue,
|
||||
lowerKey,
|
||||
compareValue,
|
||||
});
|
||||
};
|
||||
|
||||
for (const tracker of WSS_TRACKERS) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
appendUniqueParam("tr", trimmedTracker);
|
||||
}
|
||||
ensureTrackers(params);
|
||||
|
||||
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
|
||||
if (normalizedXs) {
|
||||
appendUniqueParam("xs", normalizedXs);
|
||||
ensureTorrentHint(params, normalizedXs, { requireHttp: false });
|
||||
}
|
||||
|
||||
const originProtocol = getOriginProtocol();
|
||||
const rawWs = typeof ws === "string" ? ws.trim() : "";
|
||||
if (rawWs) {
|
||||
const formattedWs = tryFormatAbsoluteUrl(rawWs);
|
||||
const lowerWs = formattedWs.toLowerCase();
|
||||
const isHttpSeed = lowerWs.startsWith("http://");
|
||||
const allowHttpSeed = originProtocol === "http:";
|
||||
if (!isHttpSeed || allowHttpSeed) {
|
||||
appendUniqueParam("ws", formattedWs);
|
||||
}
|
||||
const formattedWs = formatAbsoluteUrl(rawWs);
|
||||
const allowHttpSeed = resolveAppProtocol() === "http:";
|
||||
ensureWebSeeds(params, formattedWs, {
|
||||
allowHttp: allowHttpSeed,
|
||||
allowUnparsed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
|
||||
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||
return buildMagnetUri(normalizedScheme, params, fragment);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
safeDecodeURIComponent,
|
||||
safeDecodeURIComponentLoose,
|
||||
} from "./utils/safeDecode.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const BTIH_PREFIX = "urn:btih:";
|
||||
const MAGNET_SCHEME = "magnet:";
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
|
||||
export function safeDecodeMagnet(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let decoded = value.trim();
|
||||
if (!decoded) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (!decoded.includes("%")) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidate = safeDecodeURIComponent(decoded);
|
||||
if (!candidate || candidate === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = candidate.trim();
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname.replace(/\/?$/, "");
|
||||
const normalizedPath = pathname ? pathname : "";
|
||||
return (
|
||||
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
} catch (err) {
|
||||
return trimmed.replace(/\/?$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function createParam(key, value) {
|
||||
const trimmedValue = typeof value === "string" ? value.trim() : "";
|
||||
const decoded = safeDecodeURIComponentLoose(trimmedValue);
|
||||
const comparisonBasis = decoded || trimmedValue;
|
||||
return {
|
||||
key,
|
||||
value: trimmedValue,
|
||||
lowerKey: key.toLowerCase(),
|
||||
decoded,
|
||||
comparison: normalizeForComparison(comparisonBasis),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMagnetInput(rawValue) {
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
if (!initial) {
|
||||
return {
|
||||
initial,
|
||||
canonicalValue: "",
|
||||
didMutate: false,
|
||||
isMagnet: false,
|
||||
normalizedScheme: "",
|
||||
fragment: "",
|
||||
params: [],
|
||||
};
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
let didMutate = false;
|
||||
|
||||
if (HEX_INFO_HASH.test(working)) {
|
||||
working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
ENCODED_BTih_PATTERN.lastIndex = 0;
|
||||
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
|
||||
didMutate = true;
|
||||
return `xt=${BTIH_PREFIX}${hash}`;
|
||||
});
|
||||
working = decodedXt;
|
||||
|
||||
let fragment = "";
|
||||
const hashIndex = working.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const canonicalValue = working;
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
return {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate: didMutate || canonicalValue !== initial,
|
||||
isMagnet: false,
|
||||
normalizedScheme: "",
|
||||
fragment: "",
|
||||
params: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [schemePart, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = MAGNET_SCHEME;
|
||||
if (schemePart !== normalizedScheme) {
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const lowerKey = key.toLowerCase();
|
||||
let value = rawVal.trim();
|
||||
if (lowerKey === "xt" && value) {
|
||||
const decoded = safeDecodeURIComponentLoose(value);
|
||||
if (decoded && decoded !== value) {
|
||||
value = decoded;
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
params.push(createParam(key, value));
|
||||
}
|
||||
|
||||
return {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate,
|
||||
isMagnet: true,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMagnetUri(normalizedScheme, params, fragment) {
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`;
|
||||
}
|
||||
|
||||
export function appendUniqueParam(params, key, value) {
|
||||
if (!Array.isArray(params)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = createParam(key, value);
|
||||
if (!candidate.value && candidate.value !== "") {
|
||||
return false;
|
||||
}
|
||||
if (candidate.comparison) {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison
|
||||
);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const exists = params.some(
|
||||
(param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value
|
||||
);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
params.push(candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureTrackers(params, trackers = WSS_TRACKERS) {
|
||||
let didMutate = false;
|
||||
for (const tracker of trackers) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!trimmedTracker) {
|
||||
continue;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
if (appendUniqueParam(params, "tr", trimmedTracker)) {
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
return didMutate;
|
||||
}
|
||||
|
||||
export function sanitizeHttpUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ensureTorrentHint(params, candidate, { requireHttp = true } = {}) {
|
||||
if (requireHttp) {
|
||||
const sanitized = sanitizeHttpUrl(candidate);
|
||||
if (!sanitized) {
|
||||
return false;
|
||||
}
|
||||
return appendUniqueParam(params, "xs", sanitized);
|
||||
}
|
||||
|
||||
if (typeof candidate !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return appendUniqueParam(params, "xs", trimmed);
|
||||
}
|
||||
|
||||
export function resolveAppProtocol(explicitProtocol) {
|
||||
if (typeof explicitProtocol === "string" && explicitProtocol.trim()) {
|
||||
return explicitProtocol.trim().toLowerCase();
|
||||
}
|
||||
if (typeof window !== "undefined" && window.location?.protocol) {
|
||||
return window.location.protocol.toLowerCase();
|
||||
}
|
||||
return "https:";
|
||||
}
|
||||
|
||||
export function ensureWebSeeds(
|
||||
params,
|
||||
seeds,
|
||||
{ allowHttp = false, allowUnparsed = false, logger } = {}
|
||||
) {
|
||||
const list = Array.isArray(seeds)
|
||||
? seeds
|
||||
: typeof seeds === "string"
|
||||
? [seeds]
|
||||
: [];
|
||||
let didMutate = false;
|
||||
for (const seed of list) {
|
||||
if (typeof seed !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedSeed = seed.trim();
|
||||
if (!trimmedSeed) {
|
||||
continue;
|
||||
}
|
||||
let finalValue = trimmedSeed;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmedSeed);
|
||||
const protocol = parsed.protocol;
|
||||
if (protocol === "https:") {
|
||||
finalValue = parsed.toString();
|
||||
} else if (protocol === "http:") {
|
||||
if (!allowHttp) {
|
||||
if (typeof logger === "function") {
|
||||
logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
finalValue = parsed.toString();
|
||||
} else if (!allowUnparsed) {
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!allowUnparsed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (appendUniqueParam(params, "ws", finalValue)) {
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
return didMutate;
|
||||
}
|
||||
|
||||
export function formatAbsoluteUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.toString();
|
||||
} catch (err) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMagnetHints(rawValue) {
|
||||
const { params } = normalizeMagnetInput(rawValue);
|
||||
const hints = { ws: "", xs: "" };
|
||||
for (const param of params) {
|
||||
if (param.lowerKey === "ws" && !hints.ws) {
|
||||
hints.ws = param.decoded || param.value;
|
||||
} else if (param.lowerKey === "xs" && !hints.xs) {
|
||||
hints.xs = param.decoded || param.value;
|
||||
}
|
||||
if (hints.ws && hints.xs) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
+44
-261
@@ -1,101 +1,19 @@
|
||||
// js/magnetUtils.js
|
||||
|
||||
import { WSS_TRACKERS } from "./constants.js";
|
||||
import {
|
||||
buildMagnetUri,
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
normalizeMagnetInput,
|
||||
resolveAppProtocol,
|
||||
safeDecodeMagnet as sharedSafeDecodeMagnet,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
export { WSS_TRACKERS };
|
||||
export const safeDecodeMagnet = sharedSafeDecodeMagnet;
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const BTIH_PREFIX = "urn:btih:";
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
|
||||
function normalizeForComparison(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname.replace(/\/?$/, "");
|
||||
const normalizedPath = pathname ? pathname : "";
|
||||
return (
|
||||
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
} catch (err) {
|
||||
return trimmed.replace(/\/?$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHttpUrl(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function safeDecodeMagnet(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let decoded = value.trim();
|
||||
if (!decoded) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (!decoded.includes("%")) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = decodeURIComponent(decoded);
|
||||
if (!candidate) {
|
||||
break;
|
||||
}
|
||||
if (candidate === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = candidate.trim();
|
||||
} catch (err) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a magnet URI while preserving legacy payload quirks and augmenting it
|
||||
* for browser playback.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links.
|
||||
* - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization
|
||||
* beyond decoding legacy `%3A` segments.
|
||||
* - Only appends browser-safe WSS trackers in addition to whatever the caller provides.
|
||||
* - Skips insecure `http:` web seeds unless the application itself is running over HTTP.
|
||||
*
|
||||
* Note: `didChange` may be reported even if the resulting magnet string is textually
|
||||
* identical to the input. Callers should not rely on `didChange` to detect a modified
|
||||
* URI.
|
||||
*/
|
||||
export function normalizeAndAugmentMagnet(
|
||||
rawValue,
|
||||
{
|
||||
@@ -107,83 +25,32 @@ export function normalizeAndAugmentMagnet(
|
||||
appProtocol,
|
||||
} = {}
|
||||
) {
|
||||
const log = typeof logger === "function" ? logger : () => {};
|
||||
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||
const {
|
||||
initial,
|
||||
canonicalValue,
|
||||
didMutate,
|
||||
isMagnet,
|
||||
normalizedScheme,
|
||||
fragment,
|
||||
params,
|
||||
} = normalizeMagnetInput(rawValue);
|
||||
|
||||
if (!initial) {
|
||||
return { magnet: "", didChange: false };
|
||||
}
|
||||
|
||||
let working = initial;
|
||||
let didMutate = false;
|
||||
const bareHashMatch = HEX_INFO_HASH.test(working);
|
||||
if (bareHashMatch) {
|
||||
working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
|
||||
didMutate = true;
|
||||
}
|
||||
|
||||
const hashIndex = working.indexOf("#");
|
||||
let fragment = "";
|
||||
if (hashIndex !== -1) {
|
||||
fragment = working.slice(hashIndex);
|
||||
working = working.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
|
||||
didMutate = true;
|
||||
return `xt=${BTIH_PREFIX}${hash}`;
|
||||
});
|
||||
working = decodedXt;
|
||||
|
||||
if (!/^magnet:/i.test(working)) {
|
||||
if (!isMagnet) {
|
||||
const magnet = canonicalValue;
|
||||
return {
|
||||
magnet: working,
|
||||
didChange: didMutate || working !== initial,
|
||||
magnet,
|
||||
didChange: didMutate || magnet !== initial,
|
||||
};
|
||||
}
|
||||
|
||||
const [schemePart, queryPart = ""] = working.split("?", 2);
|
||||
const normalizedScheme = "magnet:";
|
||||
if (schemePart !== normalizedScheme) {
|
||||
didMutate = true;
|
||||
}
|
||||
let didChange = didMutate;
|
||||
|
||||
const rawParams = queryPart
|
||||
.split("&")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const params = [];
|
||||
|
||||
const decodeLoose = (value) => {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawParam of rawParams) {
|
||||
const [rawKey, rawValue = ""] = rawParam.split("=", 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let value = rawValue.trim();
|
||||
if (key.toLowerCase() === "xt" && value) {
|
||||
const decoded = decodeLoose(value);
|
||||
if (decoded !== value) {
|
||||
value = decoded;
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
params.push({
|
||||
key,
|
||||
value,
|
||||
decoded: decodeLoose(value),
|
||||
});
|
||||
if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
|
||||
@@ -192,118 +59,34 @@ export function normalizeAndAugmentMagnet(
|
||||
? xs.trim()
|
||||
: "";
|
||||
|
||||
const safeTorrentHint = sanitizeHttpUrl(torrentHint);
|
||||
if (torrentHint) {
|
||||
if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) {
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
const seedInputs = Array.isArray(webSeed)
|
||||
? webSeed
|
||||
: typeof webSeed === "string"
|
||||
? [webSeed]
|
||||
: [];
|
||||
|
||||
const resolvedProtocol = typeof appProtocol === "string" && appProtocol
|
||||
? appProtocol.toLowerCase()
|
||||
: typeof window !== "undefined" && window.location && window.location.protocol
|
||||
? window.location.protocol.toLowerCase()
|
||||
: "https:";
|
||||
const resolvedProtocol = resolveAppProtocol(appProtocol);
|
||||
|
||||
const existingTrackers = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "tr")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const trackerCandidates = [
|
||||
...WSS_TRACKERS,
|
||||
...extraTrackers,
|
||||
];
|
||||
|
||||
for (const tracker of trackerCandidates) {
|
||||
if (typeof tracker !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedTracker = tracker.trim();
|
||||
if (!trimmedTracker) {
|
||||
continue;
|
||||
}
|
||||
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||
continue;
|
||||
}
|
||||
const normalizedTracker = normalizeForComparison(trimmedTracker);
|
||||
if (!normalizedTracker || existingTrackers.has(normalizedTracker)) {
|
||||
continue;
|
||||
}
|
||||
params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker });
|
||||
existingTrackers.add(normalizedTracker);
|
||||
didMutate = true;
|
||||
if (
|
||||
ensureWebSeeds(params, seedInputs, {
|
||||
allowHttp: resolvedProtocol === "http:",
|
||||
allowUnparsed: false,
|
||||
logger,
|
||||
})
|
||||
) {
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
if (safeTorrentHint) {
|
||||
const existingXs = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "xs")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const normalizedXs = normalizeForComparison(safeTorrentHint);
|
||||
if (normalizedXs && !existingXs.has(normalizedXs)) {
|
||||
params.push({
|
||||
key: "xs",
|
||||
value: safeTorrentHint,
|
||||
decoded: safeTorrentHint,
|
||||
});
|
||||
didMutate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (seedInputs.length) {
|
||||
const existingWs = new Set(
|
||||
params
|
||||
.filter((param) => param.key.toLowerCase() === "ws")
|
||||
.map((param) => normalizeForComparison(param.decoded))
|
||||
.filter(Boolean)
|
||||
);
|
||||
for (const seedInput of seedInputs) {
|
||||
if (typeof seedInput !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmedSeed = seedInput.trim();
|
||||
if (!trimmedSeed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsedSeed = new URL(trimmedSeed);
|
||||
const seedProtocol = parsedSeed.protocol;
|
||||
const allowHttpSeed = resolvedProtocol === "http:";
|
||||
if (
|
||||
seedProtocol === "https:" ||
|
||||
(seedProtocol === "http:" && allowHttpSeed)
|
||||
) {
|
||||
const seedValue = parsedSeed.toString();
|
||||
const normalizedSeed = normalizeForComparison(seedValue);
|
||||
if (normalizedSeed && !existingWs.has(normalizedSeed)) {
|
||||
params.push({ key: "ws", value: seedValue, decoded: seedValue });
|
||||
existingWs.add(normalizedSeed);
|
||||
didMutate = true;
|
||||
}
|
||||
} else if (seedProtocol === "http:") {
|
||||
log(
|
||||
`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore invalid web seed values silently.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params
|
||||
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||
.join("&");
|
||||
|
||||
const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||
const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment);
|
||||
|
||||
return {
|
||||
magnet: finalMagnet,
|
||||
didChange: didMutate || finalMagnet !== initial,
|
||||
didChange: didChange || finalMagnet !== initial,
|
||||
};
|
||||
}
|
||||
+116
-35
@@ -1,4 +1,54 @@
|
||||
import parseMagnet from "https://esm.sh/[email protected].7";
|
||||
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
function decodeBase32ToHex(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
const bytes = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
return "";
|
||||
}
|
||||
buffer = (buffer << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
const byte = (buffer >> bits) & 0xff;
|
||||
bytes.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function extractInfoHashFromXt(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!lower.startsWith("urn:btih:")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const payload = trimmed.slice(9);
|
||||
return normalizeInfoHash(payload);
|
||||
}
|
||||
|
||||
function normalizeInfoHash(candidate) {
|
||||
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
||||
@@ -9,13 +59,8 @@ function normalizeInfoHash(candidate) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
|
||||
try {
|
||||
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
} catch (err) {
|
||||
console.warn("Failed to normalize base32 info hash", err);
|
||||
}
|
||||
const hex = decodeBase32ToHex(trimmed);
|
||||
return hex ? hex.toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -28,39 +73,75 @@ export function infoHashFromMagnet(magnet) {
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
try {
|
||||
const parsed = parseMagnet(magnet);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse magnet for info hash", err);
|
||||
return null;
|
||||
}
|
||||
const parsed = parseMagnetLite(magnet);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function trackersFromMagnet(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = parseMagnet(magnet);
|
||||
if (!parsed || !Array.isArray(parsed.announce)) {
|
||||
return [];
|
||||
}
|
||||
const deduped = new Set();
|
||||
parsed.announce.forEach((url) => {
|
||||
if (typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
deduped.add(trimmed);
|
||||
});
|
||||
return Array.from(deduped);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse magnet trackers", err);
|
||||
const parsed = parseMagnetLite(magnet);
|
||||
if (!parsed || !Array.isArray(parsed.announce)) {
|
||||
return [];
|
||||
}
|
||||
const deduped = new Set();
|
||||
parsed.announce.forEach((url) => {
|
||||
if (typeof url !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
deduped.add(trimmed);
|
||||
});
|
||||
return Array.from(deduped);
|
||||
}
|
||||
|
||||
function parseMagnetLite(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return { infoHash: "", announce: [] };
|
||||
}
|
||||
|
||||
const trimmed = magnet.trim();
|
||||
if (!trimmed) {
|
||||
return { infoHash: "", announce: [] };
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const queryIndex = lower.indexOf("?");
|
||||
const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : "";
|
||||
const params = paramsPart.split("&");
|
||||
const announce = [];
|
||||
let infoHash = "";
|
||||
|
||||
for (const param of params) {
|
||||
if (!param) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, ...rawValueParts] = param.split("=");
|
||||
const rawValue = rawValueParts.join("=");
|
||||
const key = decodeURIComponent((rawKey || "").replace(/\+/g, " ")).toLowerCase();
|
||||
const value = decodeURIComponent((rawValue || "").replace(/\+/g, " "));
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "xt") {
|
||||
const derived = extractInfoHashFromXt(value);
|
||||
if (derived && !infoHash) {
|
||||
infoHash = derived;
|
||||
}
|
||||
} else if (key === "tr") {
|
||||
if (value) {
|
||||
announce.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { infoHash, announce };
|
||||
}
|
||||
+8059
-193
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,875 @@
|
||||
import {
|
||||
ADMIN_LIST_NAMESPACE,
|
||||
isDevMode,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
} from "./config.js";
|
||||
|
||||
export const NOTE_TYPES = Object.freeze({
|
||||
VIDEO_POST: "videoPost",
|
||||
VIDEO_MIRROR: "videoMirror",
|
||||
NIP71_VIDEO: "nip71Video",
|
||||
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
||||
REPOST: "repost",
|
||||
RELAY_LIST: "relayList",
|
||||
VIEW_EVENT: "viewEvent",
|
||||
WATCH_HISTORY_INDEX: "watchHistoryIndex",
|
||||
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
USER_BLOCK_LIST: "userBlockList",
|
||||
ADMIN_MODERATION_LIST: "adminModerationList",
|
||||
ADMIN_BLACKLIST: "adminBlacklist",
|
||||
ADMIN_WHITELIST: "adminWhitelist",
|
||||
});
|
||||
|
||||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||||
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
|
||||
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
|
||||
moderation: "editors",
|
||||
editors: "editors",
|
||||
whitelist: "whitelist",
|
||||
blacklist: "blacklist",
|
||||
});
|
||||
|
||||
const DEFAULT_APPEND_TAGS = [];
|
||||
|
||||
let cachedUtf8Encoder = null;
|
||||
|
||||
function ensureValidUtf8Content(value) {
|
||||
let normalized = "";
|
||||
if (typeof value === "string") {
|
||||
normalized = value;
|
||||
} else if (value === undefined || value === null) {
|
||||
normalized = "";
|
||||
} else if (typeof value === "object") {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
normalized = typeof stringified === "string" ? stringified : "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Failed to serialize view event content:",
|
||||
error
|
||||
);
|
||||
}
|
||||
normalized = "";
|
||||
}
|
||||
} else {
|
||||
normalized = String(value);
|
||||
}
|
||||
|
||||
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
|
||||
cachedUtf8Encoder = new TextEncoder();
|
||||
}
|
||||
|
||||
if (cachedUtf8Encoder) {
|
||||
try {
|
||||
cachedUtf8Encoder.encode(normalized);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
|
||||
error
|
||||
);
|
||||
}
|
||||
const sanitized = Array.from(normalized)
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code < 0xd800 || code > 0xdfff;
|
||||
})
|
||||
.join("");
|
||||
try {
|
||||
cachedUtf8Encoder.encode(sanitized);
|
||||
normalized = sanitized;
|
||||
} catch (encodeError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
|
||||
encodeError
|
||||
);
|
||||
}
|
||||
normalized = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const BASE_SCHEMAS = {
|
||||
[NOTE_TYPES.VIDEO_POST]: {
|
||||
type: NOTE_TYPES.VIDEO_POST,
|
||||
label: "Video post",
|
||||
kind: 30078,
|
||||
topicTag: { name: "t", value: "video" },
|
||||
identifierTag: { name: "d" },
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
schemaVersion: 3,
|
||||
fields: [
|
||||
{ key: "version", type: "number", required: true },
|
||||
{ key: "title", type: "string", required: true },
|
||||
{ key: "url", type: "string", required: false },
|
||||
{ key: "magnet", type: "string", required: false },
|
||||
{ key: "thumbnail", type: "string", required: false },
|
||||
{ key: "description", type: "string", required: false },
|
||||
{ key: "mode", type: "string", required: false },
|
||||
{ key: "videoRootId", type: "string", required: true },
|
||||
{ key: "deleted", type: "boolean", required: false },
|
||||
{ key: "isPrivate", type: "boolean", required: false },
|
||||
{ key: "enableComments", type: "boolean", required: false },
|
||||
{ key: "ws", type: "string", required: false },
|
||||
{ key: "xs", type: "string", required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.VIDEO_MIRROR]: {
|
||||
type: NOTE_TYPES.VIDEO_MIRROR,
|
||||
label: "NIP-94 mirror",
|
||||
kind: 1063,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional alt text carried alongside hosted URL metadata.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.REPOST]: {
|
||||
type: NOTE_TYPES.REPOST,
|
||||
label: "NIP-18 repost",
|
||||
kind: 6,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Content field intentionally empty for pure repost events.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_VIDEO]: {
|
||||
type: NOTE_TYPES.NIP71_VIDEO,
|
||||
label: "NIP-71 video (normal)",
|
||||
kind: 21,
|
||||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Summary or description for the video body; tags carry structured metadata.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
|
||||
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
|
||||
label: "NIP-71 video (short)",
|
||||
kind: 22,
|
||||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Summary or description for the short-form video; structured fields live in tags.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.RELAY_LIST]: {
|
||||
type: NOTE_TYPES.RELAY_LIST,
|
||||
label: "Relay list metadata",
|
||||
kind: 10002,
|
||||
relayTagName: "r",
|
||||
readMarker: "read",
|
||||
writeMarker: "write",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.VIEW_EVENT]: {
|
||||
type: NOTE_TYPES.VIEW_EVENT,
|
||||
label: "View counter",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
topicTag: { name: "t", value: "view" },
|
||||
identifierTag: { name: "d" },
|
||||
sessionTag: { name: "session", value: "true" },
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional plaintext content used for diagnostics.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
|
||||
label: "Watch history index",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
},
|
||||
snapshotTagName: "snapshot",
|
||||
totalTagName: "chunks",
|
||||
chunkPointerTagName: "a",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
description:
|
||||
"JSON payload describing the active snapshot and chunk count.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_CHUNK,
|
||||
label: "Watch history snapshot",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
},
|
||||
encryptionTag: { name: "encrypted", value: "nip04" },
|
||||
snapshotTagName: "snapshot",
|
||||
chunkTagName: "chunk",
|
||||
headTag: { name: "head", value: "1" },
|
||||
headTagIndex: 2,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description:
|
||||
"Encrypted JSON payload containing chunked watch history entries.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
|
||||
type: NOTE_TYPES.SUBSCRIPTION_LIST,
|
||||
label: "Subscription list",
|
||||
kind: 30002,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
},
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description: "Encrypted JSON: { subPubkeys: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST,
|
||||
label: "User block list",
|
||||
kind: 30002,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: BLOCK_LIST_IDENTIFIER,
|
||||
},
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "nip04-json",
|
||||
description: "Encrypted JSON: { blockedPubkeys: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
|
||||
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
|
||||
label: "Admin moderation list",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_BLACKLIST]: {
|
||||
type: NOTE_TYPES.ADMIN_BLACKLIST,
|
||||
label: "Admin blacklist",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_WHITELIST]: {
|
||||
type: NOTE_TYPES.ADMIN_WHITELIST,
|
||||
label: "Admin whitelist",
|
||||
kind: 30000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
|
||||
},
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
};
|
||||
|
||||
let schemaOverrides = {};
|
||||
|
||||
function clone(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(clone);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const result = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
result[key] = clone(nested);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mergeDeep(base, override) {
|
||||
const result = clone(base);
|
||||
if (!override || typeof override !== "object") {
|
||||
return result;
|
||||
}
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
result[key] = mergeDeep(base?.[key] ?? {}, value);
|
||||
} else {
|
||||
result[key] = clone(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveOverride(type) {
|
||||
const local = schemaOverrides?.[type];
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const runtimeOverrides = window.BitVidNostrEventOverrides;
|
||||
if (runtimeOverrides && runtimeOverrides[type]) {
|
||||
return runtimeOverrides[type];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setNostrEventSchemaOverrides(overrides = {}) {
|
||||
if (overrides && typeof overrides === "object") {
|
||||
schemaOverrides = overrides;
|
||||
} else {
|
||||
schemaOverrides = {};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.BitVidNostrEventOverrides = schemaOverrides;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNostrEventSchema(type) {
|
||||
const base = BASE_SCHEMAS[type];
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
const override = resolveOverride(type);
|
||||
return mergeDeep(base, override);
|
||||
}
|
||||
|
||||
export function getAllNostrEventSchemas() {
|
||||
const entries = {};
|
||||
for (const type of Object.keys(BASE_SCHEMAS)) {
|
||||
entries[type] = getNostrEventSchema(type);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function appendSchemaTags(tags, schema) {
|
||||
if (!Array.isArray(schema?.appendTags)) {
|
||||
return tags;
|
||||
}
|
||||
schema.appendTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
dTagValue,
|
||||
content,
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
|
||||
const tags = [];
|
||||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
if (schema?.identifierTag?.name && dTagValue) {
|
||||
tags.push([schema.identifierTag.name, dTagValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 30078,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : JSON.stringify(content ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVideoMirrorEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
tags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
|
||||
const combinedTags = [];
|
||||
appendSchemaTags(combinedTags, schema);
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: schema?.kind ?? 1063,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: combinedTags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRepostEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
eventId = "",
|
||||
eventRelay = "",
|
||||
address = "",
|
||||
addressRelay = "",
|
||||
authorPubkey = "",
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
|
||||
const tags = [];
|
||||
|
||||
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
||||
const normalizedEventRelay = typeof eventRelay === "string" ? eventRelay.trim() : "";
|
||||
if (normalizedEventId) {
|
||||
if (normalizedEventRelay) {
|
||||
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
||||
} else {
|
||||
tags.push(["e", normalizedEventId]);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAddress = typeof address === "string" ? address.trim() : "";
|
||||
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
|
||||
if (normalizedAddress) {
|
||||
if (normalizedAddressRelay) {
|
||||
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
|
||||
} else {
|
||||
tags.push(["a", normalizedAddress]);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAuthorPubkey =
|
||||
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
|
||||
if (normalizedAuthorPubkey) {
|
||||
tags.push(["p", normalizedAuthorPubkey]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 6,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRelayListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
relays = [],
|
||||
additionalTags = [],
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
|
||||
const tags = [];
|
||||
const relayTagName = schema?.relayTagName || "r";
|
||||
const readMarker = schema?.readMarker || "read";
|
||||
const writeMarker = schema?.writeMarker || "write";
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
relays.forEach((entry) => {
|
||||
let url = "";
|
||||
let mode = "";
|
||||
if (typeof entry === "string") {
|
||||
url = entry;
|
||||
} else if (entry && typeof entry === "object") {
|
||||
if (typeof entry.url === "string") {
|
||||
url = entry.url;
|
||||
}
|
||||
if (typeof entry.mode === "string") {
|
||||
mode = entry.mode;
|
||||
} else if (typeof entry.marker === "string") {
|
||||
mode = entry.marker;
|
||||
} else if (entry.read === true && entry.write === false) {
|
||||
mode = "read";
|
||||
} else if (entry.write === true && entry.read === false) {
|
||||
mode = "write";
|
||||
}
|
||||
} else if (Array.isArray(entry) && entry.length >= 1) {
|
||||
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
|
||||
if (entry.length > 1 && typeof entry[1] === "string") {
|
||||
mode = entry[1];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
|
||||
if (normalizedMode === "read") {
|
||||
tags.push([relayTagName, normalizedUrl, readMarker]);
|
||||
} else if (normalizedMode === "write") {
|
||||
tags.push([relayTagName, normalizedUrl, writeMarker]);
|
||||
} else {
|
||||
tags.push([relayTagName, normalizedUrl]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 10002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildViewEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags = [],
|
||||
dedupeTag,
|
||||
includeSessionTag = false,
|
||||
additionalTags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
const tags = [];
|
||||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
|
||||
const pointerTagName = schema?.pointerTagName;
|
||||
if (pointerValue && pointerTagName) {
|
||||
tags.push([pointerTagName, pointerValue]);
|
||||
}
|
||||
const normalizedPointerTags = [];
|
||||
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(pointerTags)) {
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
normalizedPointerTags.forEach((tag) => {
|
||||
tags.push(tag);
|
||||
});
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dedupeTag && schema?.identifierTag?.name) {
|
||||
const identifierName = schema.identifierTag.name;
|
||||
const hasDedupe = tags.some(
|
||||
(tag) => tag[0] === identifierName && tag[1] === dedupeTag
|
||||
);
|
||||
if (!hasDedupe) {
|
||||
tags.push([identifierName, dedupeTag]);
|
||||
}
|
||||
}
|
||||
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
|
||||
const hasSession = tags.some(
|
||||
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
|
||||
);
|
||||
if (!hasSession) {
|
||||
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
|
||||
}
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryIndexEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
snapshotId,
|
||||
totalChunks,
|
||||
chunkAddresses = [],
|
||||
additionalTags = [],
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || WATCH_HISTORY_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
const snapshotTagName = schema?.snapshotTagName || "snapshot";
|
||||
if (snapshotTagName && snapshotId) {
|
||||
tags.push([snapshotTagName, snapshotId]);
|
||||
}
|
||||
const totalTagName = schema?.totalTagName || "chunks";
|
||||
if (totalTagName && Number.isFinite(totalChunks)) {
|
||||
tags.push([totalTagName, String(Math.max(0, Math.floor(totalChunks)))]);
|
||||
}
|
||||
const pointerTagName = schema?.chunkPointerTagName || "a";
|
||||
chunkAddresses.forEach((address) => {
|
||||
if (typeof address === "string" && address) {
|
||||
tags.push([pointerTagName, address]);
|
||||
}
|
||||
});
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let resolvedContent = content;
|
||||
if (resolvedContent === undefined) {
|
||||
const payload = {};
|
||||
if (snapshotId) {
|
||||
payload.snapshot = snapshotId;
|
||||
}
|
||||
if (Number.isFinite(totalChunks)) {
|
||||
payload.totalChunks = Math.max(0, Math.floor(totalChunks));
|
||||
}
|
||||
resolvedContent = JSON.stringify(payload);
|
||||
} else if (typeof resolvedContent !== "string") {
|
||||
resolvedContent = JSON.stringify(resolvedContent ?? {});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content:
|
||||
typeof resolvedContent === "string"
|
||||
? resolvedContent
|
||||
: String(resolvedContent ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryChunkEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
chunkIdentifier,
|
||||
snapshotId,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
pointerTags = [],
|
||||
chunkAddresses = [],
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = chunkIdentifier || schema?.identifierTag?.value;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) {
|
||||
tags.push([schema.encryptionTag.name, schema.encryptionTag.value]);
|
||||
}
|
||||
const snapshotTagName = schema?.snapshotTagName;
|
||||
if (snapshotTagName && snapshotId) {
|
||||
tags.push([snapshotTagName, snapshotId]);
|
||||
}
|
||||
const chunkTagName = schema?.chunkTagName;
|
||||
if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") {
|
||||
tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]);
|
||||
}
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
|
||||
if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) {
|
||||
const insertionIndex = Number.isInteger(schema?.headTagIndex)
|
||||
? Math.max(0, Math.min(tags.length, schema.headTagIndex))
|
||||
: Math.min(tags.length, 2);
|
||||
tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]);
|
||||
chunkAddresses.forEach((address) => {
|
||||
if (typeof address === "string" && address) {
|
||||
tags.push(["a", address]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSubscriptionListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBlockListEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAdminNoteType(listKey) {
|
||||
switch (listKey) {
|
||||
case "moderation":
|
||||
case "editors":
|
||||
return NOTE_TYPES.ADMIN_MODERATION_LIST;
|
||||
case "whitelist":
|
||||
return NOTE_TYPES.ADMIN_WHITELIST;
|
||||
case "blacklist":
|
||||
return NOTE_TYPES.ADMIN_BLACKLIST;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
|
||||
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
|
||||
if (!schema) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 30000,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: [],
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
|
||||
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
|
||||
}
|
||||
const participantTagName = schema?.participantTagName || "p";
|
||||
hexPubkeys.forEach((hex) => {
|
||||
if (typeof hex === "string" && hex.trim()) {
|
||||
tags.push([participantTagName, hex.trim()]);
|
||||
}
|
||||
});
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30000,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.BitVidNostrEvents = {
|
||||
NOTE_TYPES,
|
||||
getSchema: getNostrEventSchema,
|
||||
getAllSchemas: getAllNostrEventSchemas,
|
||||
setOverrides: setNostrEventSchemaOverrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
|
||||
export const RELAY_PUBLISH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeReason(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "publish failed";
|
||||
}
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error.trim();
|
||||
}
|
||||
if (error === undefined || error === null) {
|
||||
return "publish failed";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch (_) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export class RelayPublishError extends Error {
|
||||
constructor(message, results = [], options = {}) {
|
||||
const finalMessage =
|
||||
typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: "Failed to publish event to any relay.";
|
||||
super(finalMessage);
|
||||
this.name = "RelayPublishError";
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: null;
|
||||
if (contextValue) {
|
||||
this.context = contextValue;
|
||||
}
|
||||
|
||||
this.relayResults = Array.isArray(results)
|
||||
? results.map((result) => ({
|
||||
url: result?.url || "",
|
||||
success: !!result?.success,
|
||||
error: result?.error || null,
|
||||
reason: normalizeReason(result?.error),
|
||||
}))
|
||||
: [];
|
||||
|
||||
this.relayFailures = this.relayResults
|
||||
.filter((entry) => !entry.success)
|
||||
.map((entry) => ({
|
||||
url: entry.url,
|
||||
error: entry.error,
|
||||
reason: entry.reason,
|
||||
}));
|
||||
|
||||
this.acceptedRelays = this.relayResults
|
||||
.filter((entry) => entry.success)
|
||||
.map((entry) => entry.url);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishEventToRelay(pool, url, event, options = {}) {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
||||
? options.timeoutMs
|
||||
: RELAY_PUBLISH_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finalize = (success, error = null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({ url, success, error });
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
finalize(false, new Error("publish timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const pub = pool?.publish?.([url], event);
|
||||
|
||||
if (pub && typeof pub.on === "function") {
|
||||
const registerHandler = (eventName, handler) => {
|
||||
try {
|
||||
pub.on(eventName, handler);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[nostrPublish] Relay publish handle rejected ${eventName} listener:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleFailure = (reason) => {
|
||||
clearTimeout(timeoutId);
|
||||
const err =
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error(String(reason || "publish failed"));
|
||||
finalize(false, err);
|
||||
};
|
||||
|
||||
let handlerRegistered = false;
|
||||
handlerRegistered =
|
||||
registerHandler("ok", handleSuccess) || handlerRegistered;
|
||||
handlerRegistered =
|
||||
registerHandler("failed", handleFailure) || handlerRegistered;
|
||||
|
||||
// [email protected] removed the optional "seen" callback from relay
|
||||
// publish handles. Attempting to register it now produces an
|
||||
// asynchronous TypeError that bubbles up as an unhandled rejection and
|
||||
// prevents watch-history snapshots from ever resolving. We therefore
|
||||
// skip registering a "seen" listener and rely on "ok" (per NIP-20) for
|
||||
// success notifications. Legacy relays that only emit "seen" fall back
|
||||
// to the optimistic success path below because no handlers end up
|
||||
// registered.
|
||||
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pub && typeof pub.then === "function") {
|
||||
pub
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
finalize(false, error);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
finalize(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function publishEventToRelays(pool, urls, event, options = {}) {
|
||||
const list = Array.isArray(urls) ? urls : [];
|
||||
return Promise.all(
|
||||
list.map((url) => publishEventToRelay(pool, url, event, options)),
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizePublishResults(results = []) {
|
||||
const accepted = [];
|
||||
const failed = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result?.success) {
|
||||
accepted.push(result);
|
||||
} else {
|
||||
failed.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted, failed };
|
||||
}
|
||||
|
||||
export function assertAnyRelayAccepted(results = [], options = {}) {
|
||||
const summary = summarizePublishResults(results);
|
||||
if (summary.accepted.length > 0) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const contextValue =
|
||||
typeof options.context === "string" && options.context.trim()
|
||||
? options.context.trim()
|
||||
: "";
|
||||
const messageOverride =
|
||||
typeof options.message === "string" && options.message.trim()
|
||||
? options.message.trim()
|
||||
: "";
|
||||
|
||||
const message =
|
||||
messageOverride ||
|
||||
(contextValue
|
||||
? `Failed to publish ${contextValue} to any relay.`
|
||||
: "Failed to publish event to any relay.");
|
||||
|
||||
throw new RelayPublishError(message, results, { context: contextValue });
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
const REMOTE_IMPORT_TIMEOUT = 4500;
|
||||
const LOCAL_IMPORT_TIMEOUT = 2500;
|
||||
const SCRIPT_FALLBACK_TIMEOUT = 6000;
|
||||
const CDN_BUNDLE_URL =
|
||||
"https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js";
|
||||
const LOCAL_BUNDLE_RELATIVE_URL = "../vendor/nostr-tools.bundle.min.js";
|
||||
|
||||
const moduleUrl =
|
||||
typeof import.meta !== "undefined" && import.meta?.url ? import.meta.url : null;
|
||||
|
||||
const resolveRelativeUrl = (relativePath) => {
|
||||
if (!relativePath) {
|
||||
return relativePath;
|
||||
}
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(relativePath)) {
|
||||
return relativePath;
|
||||
}
|
||||
if (moduleUrl) {
|
||||
try {
|
||||
return new URL(relativePath, moduleUrl).href;
|
||||
} catch (error) {
|
||||
// Fall through to other resolution strategies
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined" && window?.location?.href) {
|
||||
try {
|
||||
return new URL(relativePath, window.location.href).href;
|
||||
} catch (error) {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
return relativePath;
|
||||
};
|
||||
|
||||
const LOCAL_BUNDLE_URL = resolveRelativeUrl(LOCAL_BUNDLE_RELATIVE_URL);
|
||||
|
||||
let bootstrapPromise = null;
|
||||
|
||||
const getGlobalScope = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasWorkingNip04 = (candidate) =>
|
||||
!!(
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
typeof candidate.encrypt === "function" &&
|
||||
typeof candidate.decrypt === "function"
|
||||
);
|
||||
|
||||
const toSerializableError = (error) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return { message: error };
|
||||
}
|
||||
try {
|
||||
return { ...error };
|
||||
} catch (serializationError) {
|
||||
return { message: String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const withTimeout = (factory, ms, label) =>
|
||||
new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
let settled = false;
|
||||
let timeoutId;
|
||||
const finalize = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve({
|
||||
label,
|
||||
duration: Date.now() - start,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
const runner = Promise.resolve().then(factory);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
finalize({
|
||||
ok: false,
|
||||
timedOut: true,
|
||||
error: new Error(
|
||||
`[bitvid][nostr-tools] ${label} timed out after ${ms}ms`
|
||||
),
|
||||
});
|
||||
runner.catch(() => {});
|
||||
}, ms);
|
||||
|
||||
runner.then(
|
||||
(value) => finalize({ ok: true, value, timedOut: false }),
|
||||
(error) => finalize({ ok: false, error, timedOut: false })
|
||||
);
|
||||
});
|
||||
|
||||
const discoverLocalEntrypoints = (scope) => {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entrypoints = [];
|
||||
const configuredEntrypoints = scope.__BITVID_LOCAL_NOSTR_TOOLS_ENTRY__;
|
||||
if (typeof configuredEntrypoints === "string") {
|
||||
entrypoints.push(configuredEntrypoints);
|
||||
} else if (Array.isArray(configuredEntrypoints)) {
|
||||
configuredEntrypoints
|
||||
.filter((candidate) => typeof candidate === "string" && candidate)
|
||||
.forEach((candidate) => entrypoints.push(candidate));
|
||||
}
|
||||
|
||||
if (scope.document) {
|
||||
const localScripts = scope.document.querySelectorAll(
|
||||
"script[data-nostr-tools-entry]"
|
||||
);
|
||||
localScripts.forEach((script) => {
|
||||
const candidate = script.getAttribute("data-nostr-tools-entry");
|
||||
if (candidate) {
|
||||
entrypoints.push(candidate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(new Set(entrypoints));
|
||||
};
|
||||
|
||||
const extractToolsFromModule = (moduleLike) => {
|
||||
if (!moduleLike || typeof moduleLike !== "object") {
|
||||
return null;
|
||||
}
|
||||
const { default: defaultExport, ...named } = moduleLike;
|
||||
if (defaultExport && typeof defaultExport === "object") {
|
||||
return { ...defaultExport, ...named };
|
||||
}
|
||||
return { ...named };
|
||||
};
|
||||
|
||||
const ensureGlobalReadyPromise = (scope, promise) => {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(scope, "nostrToolsReady", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: promise,
|
||||
});
|
||||
} catch (error) {
|
||||
scope.nostrToolsReady = promise;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScriptBundle = (scope, url, label) => {
|
||||
if (!scope?.document) {
|
||||
return Promise.resolve({
|
||||
label,
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
duration: 0,
|
||||
error: new Error("Document is unavailable for script loading."),
|
||||
});
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { document } = scope;
|
||||
const target = document.head || document.body || document.documentElement;
|
||||
if (!target) {
|
||||
reject(new Error("Unable to locate a DOM target for script injection."));
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = url;
|
||||
script.crossOrigin = "anonymous";
|
||||
|
||||
const cleanup = () => {
|
||||
script.onload = null;
|
||||
script.onerror = null;
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
|
||||
script.onload = () => {
|
||||
cleanup();
|
||||
resolve(scope?.NostrTools || null);
|
||||
};
|
||||
script.onerror = (event) => {
|
||||
cleanup();
|
||||
if (event?.error instanceof Error) {
|
||||
reject(event.error);
|
||||
} else {
|
||||
reject(new Error(`nostr-tools bundle failed to load: ${url}`));
|
||||
}
|
||||
};
|
||||
|
||||
target.appendChild(script);
|
||||
}),
|
||||
SCRIPT_FALLBACK_TIMEOUT,
|
||||
label
|
||||
);
|
||||
};
|
||||
|
||||
export function bootstrapNostrTools() {
|
||||
if (bootstrapPromise) {
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
const scope = getGlobalScope();
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
if (!scope) {
|
||||
const error = {
|
||||
ok: false,
|
||||
reason: "Global scope is unavailable.",
|
||||
attempts: [],
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
const attempts = [];
|
||||
|
||||
const remoteImports = [
|
||||
withTimeout(
|
||||
() => import("https://esm.sh/[email protected]"),
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-main"
|
||||
),
|
||||
withTimeout(
|
||||
() =>
|
||||
import(
|
||||
"https://esm.sh/[email protected]?target=es2022&exports=nip04"
|
||||
),
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-nip04"
|
||||
),
|
||||
];
|
||||
|
||||
const localEntrypoints = discoverLocalEntrypoints(scope);
|
||||
const localImports = localEntrypoints.map((entry, index) =>
|
||||
withTimeout(
|
||||
() => import(entry),
|
||||
LOCAL_IMPORT_TIMEOUT,
|
||||
`local-${index}`
|
||||
)
|
||||
);
|
||||
|
||||
const dynamicResults = await Promise.allSettled([
|
||||
...remoteImports,
|
||||
...localImports,
|
||||
]);
|
||||
|
||||
const resolvedModules = [];
|
||||
let resolvedNip04 = null;
|
||||
|
||||
dynamicResults.forEach((settledResult) => {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
return;
|
||||
}
|
||||
const result = settledResult.value;
|
||||
attempts.push({
|
||||
target: result.label,
|
||||
ok: result.ok,
|
||||
timedOut: !!result.timedOut,
|
||||
duration: result.duration,
|
||||
error: result.ok ? null : toSerializableError(result.error),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
if (result.label === "esm-nip04" && hasWorkingNip04(result.value?.nip04)) {
|
||||
resolvedNip04 = result.value.nip04;
|
||||
return;
|
||||
}
|
||||
const extracted = extractToolsFromModule(result.value);
|
||||
if (extracted && Object.keys(extracted).length > 0) {
|
||||
resolvedModules.push({ source: result.label, tools: extracted });
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dynamicResults.forEach((settledResult) => {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
const { reason } = settledResult;
|
||||
attempts.push({
|
||||
target: "unknown",
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
duration: 0,
|
||||
error: toSerializableError(reason),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = settledResult.value;
|
||||
if (result && !attempts.find((entry) => entry.target === result.label)) {
|
||||
attempts.push({
|
||||
target: result.label,
|
||||
ok: result.ok,
|
||||
timedOut: !!result.timedOut,
|
||||
duration: result.duration,
|
||||
error: result.ok ? null : toSerializableError(result.error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allDynamicTimedOut =
|
||||
attempts.length > 0 &&
|
||||
attempts.every(
|
||||
(attempt) =>
|
||||
attempt &&
|
||||
attempt.target &&
|
||||
attempt.target.startsWith("esm")
|
||||
? attempt.timedOut
|
||||
: true
|
||||
);
|
||||
|
||||
const shouldAttemptScriptFallback =
|
||||
!!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut);
|
||||
|
||||
if (shouldAttemptScriptFallback) {
|
||||
const scriptSources = [
|
||||
LOCAL_BUNDLE_URL
|
||||
? { url: LOCAL_BUNDLE_URL, label: "local-bundle" }
|
||||
: null,
|
||||
{ url: CDN_BUNDLE_URL, label: "cdn-bundle" },
|
||||
].filter(Boolean);
|
||||
|
||||
for (const source of scriptSources) {
|
||||
const scriptLoadResult = await loadScriptBundle(
|
||||
scope,
|
||||
source.url,
|
||||
source.label
|
||||
);
|
||||
|
||||
attempts.push({
|
||||
target: scriptLoadResult.label,
|
||||
ok: scriptLoadResult.ok,
|
||||
timedOut: !!scriptLoadResult.timedOut,
|
||||
duration: scriptLoadResult.duration,
|
||||
error: scriptLoadResult.ok
|
||||
? null
|
||||
: toSerializableError(scriptLoadResult.error),
|
||||
});
|
||||
|
||||
if (!scriptLoadResult.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extracted = extractToolsFromModule(scriptLoadResult.value);
|
||||
if (extracted && Object.keys(extracted).length > 0) {
|
||||
resolvedModules.push({ source: source.label, tools: extracted });
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingGlobalTools = scope?.NostrTools;
|
||||
if (existingGlobalTools && typeof existingGlobalTools === "object") {
|
||||
resolvedModules.push({ source: "global", tools: existingGlobalTools });
|
||||
if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) {
|
||||
resolvedNip04 = existingGlobalTools.nip04;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedModules.length === 0 && !resolvedNip04) {
|
||||
const failure = {
|
||||
ok: false,
|
||||
reason: "Failed to resolve any nostr-tools helpers.",
|
||||
attempts,
|
||||
};
|
||||
console.warn("[bitvid] nostr-tools bootstrap failed", failure);
|
||||
return failure;
|
||||
}
|
||||
|
||||
const toolSources = resolvedModules.map((entry) => entry.tools);
|
||||
const canonicalTools = Object.assign({}, ...toolSources);
|
||||
|
||||
const normalizedGeneratePrivateKey =
|
||||
typeof canonicalTools.generatePrivateKey === "function"
|
||||
? canonicalTools.generatePrivateKey
|
||||
: typeof canonicalTools.generateSecretKey === "function"
|
||||
? canonicalTools.generateSecretKey
|
||||
: undefined;
|
||||
|
||||
const normalizedGenerateSecretKey =
|
||||
typeof canonicalTools.generateSecretKey === "function"
|
||||
? canonicalTools.generateSecretKey
|
||||
: typeof canonicalTools.generatePrivateKey === "function"
|
||||
? canonicalTools.generatePrivateKey
|
||||
: undefined;
|
||||
|
||||
if (normalizedGeneratePrivateKey) {
|
||||
canonicalTools.generatePrivateKey = normalizedGeneratePrivateKey;
|
||||
}
|
||||
if (normalizedGenerateSecretKey) {
|
||||
canonicalTools.generateSecretKey = normalizedGenerateSecretKey;
|
||||
}
|
||||
|
||||
if (resolvedNip04) {
|
||||
canonicalTools.nip04 = resolvedNip04;
|
||||
console.info("[bitvid] Initialized nostr nip04 helpers.");
|
||||
} else {
|
||||
console.warn(
|
||||
"[bitvid] NIP-04 helpers unavailable after bootstrap attempts."
|
||||
);
|
||||
}
|
||||
|
||||
const frozenCanonical = Object.freeze({ ...canonicalTools });
|
||||
|
||||
try {
|
||||
Object.defineProperty(scope, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: frozenCanonical,
|
||||
});
|
||||
} catch (error) {
|
||||
scope.__BITVID_CANONICAL_NOSTR_TOOLS__ = frozenCanonical;
|
||||
}
|
||||
|
||||
let activeTools = canonicalTools;
|
||||
const mergeWithCanonical = (candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return activeTools;
|
||||
}
|
||||
const merged = Object.assign({}, frozenCanonical, candidate);
|
||||
if (!merged.nip04 && frozenCanonical.nip04) {
|
||||
merged.nip04 = frozenCanonical.nip04;
|
||||
}
|
||||
if (
|
||||
typeof merged.generatePrivateKey !== "function" &&
|
||||
typeof merged.generateSecretKey === "function"
|
||||
) {
|
||||
merged.generatePrivateKey = merged.generateSecretKey;
|
||||
}
|
||||
if (
|
||||
typeof merged.generateSecretKey !== "function" &&
|
||||
typeof merged.generatePrivateKey === "function"
|
||||
) {
|
||||
merged.generateSecretKey = merged.generatePrivateKey;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const applyActive = (candidate) => {
|
||||
activeTools = mergeWithCanonical(candidate);
|
||||
return activeTools;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(scope, "NostrTools", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return activeTools;
|
||||
},
|
||||
set(value) {
|
||||
applyActive(value);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[bitvid] Failed to install NostrTools guard.", error);
|
||||
}
|
||||
|
||||
applyActive(existingGlobalTools);
|
||||
scope.NostrTools = canonicalTools;
|
||||
|
||||
return frozenCanonical;
|
||||
})()
|
||||
.catch((error) => ({
|
||||
ok: false,
|
||||
reason: "nostr-tools bootstrap threw unexpectedly.",
|
||||
error: toSerializableError(error),
|
||||
}));
|
||||
|
||||
ensureGlobalReadyPromise(scope, bootstrapPromise);
|
||||
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
const nostrToolsReady = bootstrapNostrTools();
|
||||
|
||||
export { nostrToolsReady };
|
||||
export default nostrToolsReady;
|
||||
|
||||
await nostrToolsReady;
|
||||
@@ -0,0 +1,368 @@
|
||||
const DB_NAME = "bitvidSettings";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "kv";
|
||||
const KEY_PREFIX = "nwcSettings:";
|
||||
const LOCALSTORAGE_PREFIX = "bitvid:nwcSettings:";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const MIN_DEFAULT_ZAP = 0;
|
||||
const MAX_DEFAULT_ZAP = 100000000;
|
||||
|
||||
const DEFAULT_SETTINGS = Object.freeze({
|
||||
nwcUri: "",
|
||||
defaultZap: null,
|
||||
lastChecked: null,
|
||||
version: "",
|
||||
});
|
||||
|
||||
function createClone(value) {
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to open settings DB"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1") && typeof window !== "undefined") {
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode?.(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode errors and fall through to return null.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStorageKeys(pubkey) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid pubkey for NWC settings.");
|
||||
}
|
||||
return {
|
||||
normalized,
|
||||
dbKey: `${KEY_PREFIX}${normalized}`,
|
||||
fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeUri(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sanitizeDefaultZap(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(numeric);
|
||||
if (!Number.isFinite(rounded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(MAX_DEFAULT_ZAP, Math.max(MIN_DEFAULT_ZAP, rounded));
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (!Number.isFinite(rounded)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, rounded);
|
||||
}
|
||||
|
||||
function sanitizeVersion(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeSettings(raw) {
|
||||
const base = createClone(DEFAULT_SETTINGS);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "nwcUri")) {
|
||||
base.nwcUri = sanitizeUri(raw.nwcUri);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "defaultZap")) {
|
||||
base.defaultZap = sanitizeDefaultZap(raw.defaultZap);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "lastChecked")) {
|
||||
base.lastChecked = sanitizeTimestamp(raw.lastChecked);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "version")) {
|
||||
base.version = sanitizeVersion(raw.version);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function mergeSettings(existing, partial) {
|
||||
const source = partial && typeof partial === "object" ? partial : {};
|
||||
return normalizeSettings({ ...existing, ...source });
|
||||
}
|
||||
|
||||
function emitToast(message, { variant = "error", error } = {}) {
|
||||
const text = typeof message === "string" ? message.trim() : "";
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bitvid:toast", {
|
||||
detail: {
|
||||
message: text,
|
||||
variant,
|
||||
source: "nwc-settings",
|
||||
error,
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch (eventError) {
|
||||
console.warn("[nwcSettings] Failed to dispatch toast event", eventError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyFailure(message, error) {
|
||||
const text = message || "NWC settings storage error.";
|
||||
console.warn(`[nwcSettings] ${text}`, error);
|
||||
emitToast(text, { variant: "error", error });
|
||||
}
|
||||
|
||||
function notifyWarning(message, error) {
|
||||
const text = message || "NWC settings storage warning.";
|
||||
console.warn(`[nwcSettings] ${text}`, error);
|
||||
emitToast(text, { variant: "warning", error });
|
||||
}
|
||||
|
||||
async function readFromIndexedDb(dbKey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(dbKey);
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to load NWC settings."));
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to read wallet settings from IndexedDB.", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readFromLocalStorage(fallbackKey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const raw = localStorage.getItem(fallbackKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
notifyFailure(
|
||||
"Unable to read wallet settings from local storage fallback.",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToIndexedDb(dbKey, value) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(value, dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to persist NWC settings."));
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to save wallet settings to IndexedDB.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToLocalStorage(fallbackKey, value) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem(fallbackKey, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to save wallet settings to local storage fallback.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFromIndexedDb(dbKey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to clear NWC settings."));
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to remove wallet settings from IndexedDB.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFromLocalStorage(fallbackKey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
localStorage.removeItem(fallbackKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyWarning("Unable to remove wallet settings from local storage fallback.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultNwcSettings() {
|
||||
return createClone(DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
export async function loadNwcSettings(pubkey) {
|
||||
const { dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const stored = await readFromIndexedDb(dbKey);
|
||||
if (stored) {
|
||||
return normalizeSettings(stored);
|
||||
}
|
||||
|
||||
const fallback = readFromLocalStorage(fallbackKey);
|
||||
if (fallback) {
|
||||
return normalizeSettings(fallback);
|
||||
}
|
||||
|
||||
return normalizeSettings(null);
|
||||
}
|
||||
|
||||
export async function saveNwcSettings(pubkey, partial = {}) {
|
||||
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const existing = await readFromIndexedDb(dbKey);
|
||||
const fallbackExisting = existing || readFromLocalStorage(fallbackKey);
|
||||
const merged = mergeSettings(fallbackExisting || DEFAULT_SETTINGS, partial);
|
||||
|
||||
const idbPersisted = await writeToIndexedDb(dbKey, merged);
|
||||
const localPersisted = writeToLocalStorage(fallbackKey, merged);
|
||||
|
||||
if (!idbPersisted && !localPersisted) {
|
||||
notifyFailure(
|
||||
"Failed to persist wallet settings. Recent changes were not saved.",
|
||||
new Error(`Unable to save settings for ${normalized}`)
|
||||
);
|
||||
throw new Error("Unable to persist NWC settings.");
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function clearNwcSettings(pubkey) {
|
||||
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
|
||||
const idbCleared = await deleteFromIndexedDb(dbKey);
|
||||
const localCleared = deleteFromLocalStorage(fallbackKey);
|
||||
|
||||
if (!idbCleared && !localCleared) {
|
||||
notifyFailure(
|
||||
"Failed to clear wallet settings from browser storage.",
|
||||
new Error(`Unable to clear settings for ${normalized}`)
|
||||
);
|
||||
throw new Error("Unable to clear NWC settings.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// js/payments/lnurl.js
|
||||
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const CHARKEY = new Map(
|
||||
Array.from(CHARSET).map((char, index) => [char, index])
|
||||
);
|
||||
const GENERATORS = [
|
||||
0x3b6a57b2,
|
||||
0x26508e6d,
|
||||
0x1ea119fa,
|
||||
0x3d4233dd,
|
||||
0x2a1462b3,
|
||||
];
|
||||
|
||||
const DEFAULT_COMMENT_MAX = 0;
|
||||
|
||||
function hrpExpand(hrp) {
|
||||
const chars = Array.from(hrp);
|
||||
const left = chars.map((char) => char.charCodeAt(0) >> 5);
|
||||
const right = chars.map((char) => char.charCodeAt(0) & 31);
|
||||
return [...left, 0, ...right];
|
||||
}
|
||||
|
||||
function polymod(values) {
|
||||
let chk = 1;
|
||||
for (const value of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ value;
|
||||
for (let i = 0; i < GENERATORS.length; i += 1) {
|
||||
if (((top >> i) & 1) !== 0) {
|
||||
chk ^= GENERATORS[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
|
||||
function verifyChecksum(hrp, data) {
|
||||
return polymod([...hrpExpand(hrp), ...data]) === 1;
|
||||
}
|
||||
|
||||
function bech32Decode(input) {
|
||||
if (typeof input !== "string") {
|
||||
throw new Error("LNURL must be a string.");
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("LNURL cannot be empty.");
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const upper = trimmed.toUpperCase();
|
||||
if (trimmed !== lower && trimmed !== upper) {
|
||||
throw new Error("LNURL cannot mix upper and lower case characters.");
|
||||
}
|
||||
|
||||
const normalized = lower;
|
||||
const separatorIndex = normalized.lastIndexOf("1");
|
||||
if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) {
|
||||
throw new Error("Invalid LNURL bech32 payload.");
|
||||
}
|
||||
|
||||
const hrp = normalized.slice(0, separatorIndex);
|
||||
const dataPart = normalized.slice(separatorIndex + 1);
|
||||
|
||||
const data = [];
|
||||
for (const char of dataPart) {
|
||||
if (!CHARKEY.has(char)) {
|
||||
throw new Error("LNURL payload includes invalid characters.");
|
||||
}
|
||||
data.push(CHARKEY.get(char));
|
||||
}
|
||||
|
||||
if (!verifyChecksum(hrp, data)) {
|
||||
throw new Error("LNURL checksum is invalid.");
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: hrp,
|
||||
words: data.slice(0, -6),
|
||||
};
|
||||
}
|
||||
|
||||
function convertWords(words, fromBits, toBits, { pad = true } = {}) {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
const maxValue = (1 << toBits) - 1;
|
||||
const result = [];
|
||||
|
||||
for (const word of words) {
|
||||
if (word < 0 || word >> fromBits !== 0) {
|
||||
throw new Error("Invalid bech32 word value.");
|
||||
}
|
||||
value = (value << fromBits) | word;
|
||||
bits += fromBits;
|
||||
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits;
|
||||
result.push((value >> bits) & maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (pad) {
|
||||
if (bits > 0) {
|
||||
result.push((value << (toBits - bits)) & maxValue);
|
||||
}
|
||||
} else if (bits >= fromBits || ((value << (toBits - bits)) & maxValue) !== 0) {
|
||||
throw new Error("Excess padding in bech32 payload.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeLnurlBech32(encoded) {
|
||||
const { prefix, words } = bech32Decode(encoded);
|
||||
if (prefix !== "lnurl") {
|
||||
throw new Error("Only bech32 LNURL encodings are supported.");
|
||||
}
|
||||
|
||||
const bytes = convertWords(words, 5, 8, { pad: false });
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
return decoder.decode(Uint8Array.from(bytes));
|
||||
}
|
||||
|
||||
function ensureFetchFunction(fetcher) {
|
||||
if (typeof fetcher === "function") {
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
if (typeof fetch === "function") {
|
||||
return fetch;
|
||||
}
|
||||
|
||||
if (typeof globalThis.fetch === "function") {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
throw new Error("Fetch API is not available in this environment.");
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("LNURL endpoint is missing.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveLightningAddress(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("Lightning address is required.");
|
||||
}
|
||||
|
||||
if (trimmed.toLowerCase().startsWith("lnurl")) {
|
||||
const decoded = decodeLnurlBech32(trimmed);
|
||||
return {
|
||||
type: "lud06",
|
||||
url: sanitizeUrl(decoded),
|
||||
address: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.includes("@")) {
|
||||
const [namePart, domainPart] = trimmed.split("@");
|
||||
const name = (namePart || "").trim();
|
||||
const domain = (domainPart || "").trim();
|
||||
if (!name || !domain) {
|
||||
throw new Error("Invalid Lightning address format.");
|
||||
}
|
||||
const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(
|
||||
name.toLowerCase()
|
||||
)}`;
|
||||
return {
|
||||
type: "lud16",
|
||||
url,
|
||||
address: `${name}@${domain}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return {
|
||||
type: "url",
|
||||
url: trimmed,
|
||||
address: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unsupported Lightning address format.");
|
||||
}
|
||||
|
||||
function normalizeNumeric(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback;
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
export async function fetchPayServiceData(url, { fetcher } = {}) {
|
||||
const targetUrl = sanitizeUrl(url);
|
||||
const fetchFn = ensureFetchFunction(fetcher);
|
||||
|
||||
const response = await fetchFn(targetUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load LNURL metadata (${response.status}).`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("LNURL endpoint did not return JSON.");
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("LNURL metadata response was invalid.");
|
||||
}
|
||||
|
||||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "LNURL endpoint returned an error.";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const callback = sanitizeUrl(payload.callback);
|
||||
const minSendable = Math.max(0, normalizeNumeric(payload.minSendable, 0));
|
||||
const maxSendable = Math.max(minSendable, normalizeNumeric(payload.maxSendable, minSendable));
|
||||
const commentAllowed = Math.max(0, Math.round(normalizeNumeric(payload.commentAllowed, DEFAULT_COMMENT_MAX)));
|
||||
const allowsNostr = payload.allowsNostr === true;
|
||||
const nostrPubkey =
|
||||
typeof payload.nostrPubkey === "string" ? payload.nostrPubkey.trim() : "";
|
||||
|
||||
let metadata = [];
|
||||
if (typeof payload.metadata === "string" && payload.metadata.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(payload.metadata);
|
||||
if (Array.isArray(parsed)) {
|
||||
metadata = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore metadata parse errors – it is optional.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tag: typeof payload.tag === "string" ? payload.tag : "payRequest",
|
||||
callback,
|
||||
minSendable,
|
||||
maxSendable,
|
||||
metadata,
|
||||
commentAllowed,
|
||||
allowsNostr,
|
||||
nostrPubkey,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInvoiceAmount(metadata, amountSats) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
throw new Error("LNURL metadata is required to validate invoice amounts.");
|
||||
}
|
||||
|
||||
const amount = Math.round(Number(amountSats));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error("Zap amount must be a positive integer.");
|
||||
}
|
||||
|
||||
const amountMsats = amount * 1000;
|
||||
const min = Math.max(0, Math.round(Number(metadata.minSendable || 0)));
|
||||
const max = Math.max(min, Math.round(Number(metadata.maxSendable || amountMsats)));
|
||||
|
||||
if (min && amountMsats < min) {
|
||||
throw new Error(`Amount is below the minimum sendable value (${min / 1000} sats).`);
|
||||
}
|
||||
|
||||
if (max && amountMsats > max) {
|
||||
throw new Error(`Amount exceeds the maximum sendable value (${max / 1000} sats).`);
|
||||
}
|
||||
|
||||
return { amountMsats };
|
||||
}
|
||||
|
||||
function truncateComment(comment, limit) {
|
||||
const trimmed = typeof comment === "string" ? comment.trim() : "";
|
||||
if (!limit || limit <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= limit) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function requestInvoice(
|
||||
metadata,
|
||||
{ amountSats, amountMsats, comment, zapRequest, fetcher } = {}
|
||||
) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
throw new Error("LNURL metadata is required to request an invoice.");
|
||||
}
|
||||
|
||||
const fetchFn = ensureFetchFunction(fetcher);
|
||||
const targetAmount = Number.isFinite(amountMsats)
|
||||
? Math.round(amountMsats)
|
||||
: validateInvoiceAmount(metadata, amountSats).amountMsats;
|
||||
|
||||
const callbackUrl = new URL(metadata.callback);
|
||||
callbackUrl.searchParams.set("amount", String(targetAmount));
|
||||
|
||||
if (comment && metadata.commentAllowed > 0) {
|
||||
const truncated = truncateComment(comment, metadata.commentAllowed);
|
||||
if (truncated) {
|
||||
callbackUrl.searchParams.set("comment", truncated);
|
||||
}
|
||||
}
|
||||
|
||||
if (zapRequest) {
|
||||
callbackUrl.searchParams.set("nostr", zapRequest);
|
||||
}
|
||||
|
||||
const response = await fetchFn(callbackUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch LNURL invoice (${response.status}).`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("LNURL invoice response was not JSON.");
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("LNURL invoice response was invalid.");
|
||||
}
|
||||
|
||||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||||
const reason =
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: "LNURL invoice request failed.";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const invoice = typeof payload.pr === "string" ? payload.pr.trim() : "";
|
||||
if (!invoice) {
|
||||
throw new Error("LNURL endpoint did not return an invoice.");
|
||||
}
|
||||
|
||||
return {
|
||||
invoice,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
bech32Decode,
|
||||
decodeLnurlBech32,
|
||||
convertWords,
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
// js/payments/platformAddress.js
|
||||
|
||||
import { PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB } from "../config.js";
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { setProfileCacheEntry } from "../state/cache.js";
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
let cachedAddress = null;
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readToolkitFromScope(scope = globalScope) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
if (canonical) {
|
||||
candidates.push(canonical);
|
||||
}
|
||||
|
||||
const direct = scope.NostrTools;
|
||||
if (direct) {
|
||||
candidates.push(direct);
|
||||
}
|
||||
|
||||
const nestedWindow =
|
||||
scope.window && scope.window !== scope && typeof scope.window === "object"
|
||||
? scope.window
|
||||
: null;
|
||||
if (nestedWindow) {
|
||||
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
|
||||
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
|
||||
}
|
||||
if (nestedWindow.NostrTools) {
|
||||
candidates.push(nestedWindow.NostrTools);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const __platformNostrToolsBootstrap = await (async () => {
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
if (result && typeof result === "object" && result.ok === false) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: result,
|
||||
};
|
||||
}
|
||||
const normalized = normalizeToolkitCandidate(result);
|
||||
if (normalized) {
|
||||
return { toolkit: normalized, failure: null };
|
||||
}
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
let cachedNostrTools = __platformNostrToolsBootstrap.toolkit || null;
|
||||
const nostrToolsInitializationFailure =
|
||||
__platformNostrToolsBootstrap.failure || null;
|
||||
|
||||
if (!cachedNostrTools && nostrToolsInitializationFailure) {
|
||||
console.warn(
|
||||
"[platformAddress] nostr-tools helpers unavailable after bootstrap.",
|
||||
nostrToolsInitializationFailure
|
||||
);
|
||||
}
|
||||
|
||||
function rememberNostrTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedNostrTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedNostrTools() {
|
||||
const fallback = readToolkitFromScope();
|
||||
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
|
||||
rememberNostrTools(fallback);
|
||||
} else if (!cachedNostrTools && fallback) {
|
||||
rememberNostrTools(fallback);
|
||||
}
|
||||
return cachedNostrTools || fallback || null;
|
||||
}
|
||||
|
||||
async function ensureNostrTools() {
|
||||
if (cachedNostrTools) {
|
||||
return cachedNostrTools;
|
||||
}
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberNostrTools(result);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[platformAddress] Failed to resolve nostr-tools helpers.",
|
||||
error
|
||||
);
|
||||
}
|
||||
if (!cachedNostrTools) {
|
||||
rememberNostrTools(readToolkitFromScope());
|
||||
}
|
||||
return cachedNostrTools || null;
|
||||
}
|
||||
|
||||
function getGlobalWindow() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined") {
|
||||
return globalThis;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
const win = getGlobalWindow();
|
||||
const tools = getCachedNostrTools();
|
||||
if (tools?.nip04) {
|
||||
return tools;
|
||||
}
|
||||
const canonical = win?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
|
||||
if (canonical && tools && !tools.nip04 && canonical.nip04) {
|
||||
try {
|
||||
tools.nip04 = canonical.nip04;
|
||||
return tools;
|
||||
} catch (error) {
|
||||
return { ...canonical, ...tools, nip04: canonical.nip04 };
|
||||
}
|
||||
}
|
||||
return tools || canonical || null;
|
||||
}
|
||||
|
||||
function decodeAdminPubkey() {
|
||||
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
const tools = getNostrTools();
|
||||
const decoder = tools?.nip19?.decode;
|
||||
if (typeof decoder === "function") {
|
||||
try {
|
||||
const decoded = decoder(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to decode ADMIN_SUPER_NPUB", error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldUseOverride() {
|
||||
return typeof PLATFORM_LUD16_OVERRIDE === "string" && PLATFORM_LUD16_OVERRIDE.trim();
|
||||
}
|
||||
|
||||
function getOverrideAddress() {
|
||||
const trimmed = PLATFORM_LUD16_OVERRIDE.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function isCacheFresh(entry) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.expiresAt !== "number") {
|
||||
return false;
|
||||
}
|
||||
return entry.expiresAt > Date.now();
|
||||
}
|
||||
|
||||
async function fetchAdminMetadata(adminPubkey) {
|
||||
if (!adminPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pool;
|
||||
try {
|
||||
pool = await nostrClient.ensurePool();
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to initialize Nostr pool", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relayUrls = Array.isArray(nostrClient?.relays)
|
||||
? nostrClient.relays
|
||||
: [];
|
||||
if (relayUrls.length === 0) {
|
||||
console.warn("[platformAddress] Nostr client is not ready.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await pool.list(relayUrls, [
|
||||
{ kinds: [0], authors: [adminPubkey], limit: 1 },
|
||||
]);
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newest = null;
|
||||
for (const event of events) {
|
||||
if (!event || event.pubkey !== adminPubkey || !event.content) {
|
||||
continue;
|
||||
}
|
||||
if (!newest || event.created_at > newest.created_at) {
|
||||
newest = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newest?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(newest.content);
|
||||
const lightningAddress =
|
||||
typeof data.lud16 === "string" && data.lud16.trim()
|
||||
? data.lud16.trim()
|
||||
: typeof data.lud06 === "string" && data.lud06.trim()
|
||||
? data.lud06.trim()
|
||||
: "";
|
||||
|
||||
if (lightningAddress) {
|
||||
setProfileCacheEntry(adminPubkey, data, { persist: false });
|
||||
}
|
||||
|
||||
return lightningAddress || null;
|
||||
} catch (error) {
|
||||
console.warn("[platformAddress] Failed to fetch admin metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformLightningAddress({ forceRefresh = false } = {}) {
|
||||
if (shouldUseOverride()) {
|
||||
return getOverrideAddress();
|
||||
}
|
||||
|
||||
if (!forceRefresh && isCacheFresh(cachedAddress)) {
|
||||
return cachedAddress.value;
|
||||
}
|
||||
|
||||
const adminPubkey = decodeAdminPubkey();
|
||||
if (!adminPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (forceRefresh !== true && cachedAddress?.value) {
|
||||
return cachedAddress.value;
|
||||
}
|
||||
|
||||
const lightningAddress = await fetchAdminMetadata(adminPubkey);
|
||||
if (lightningAddress) {
|
||||
cachedAddress = {
|
||||
value: lightningAddress,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
return lightningAddress || cachedAddress?.value || null;
|
||||
}
|
||||
|
||||
export function __resetPlatformAddressCache() {
|
||||
cachedAddress = null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// js/payments/platformFee.js
|
||||
|
||||
import { PLATFORM_FEE_PERCENT } from "../config.js";
|
||||
|
||||
export function parsePercentValue(value) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : NaN;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
if (trimmed.includes("/")) {
|
||||
const [firstRaw, secondRaw] = trimmed.split("/").map((part) => part.trim());
|
||||
if (firstRaw && secondRaw) {
|
||||
const first = Number(firstRaw);
|
||||
const second = Number(secondRaw);
|
||||
if (Number.isFinite(first) && Number.isFinite(second) && first >= 0 && second >= 0) {
|
||||
const total = first + second;
|
||||
if (total > 0) {
|
||||
return (second / total) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const percentStripped = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed;
|
||||
const directNumeric = Number(percentStripped);
|
||||
if (Number.isFinite(directNumeric)) {
|
||||
return directNumeric;
|
||||
}
|
||||
|
||||
const match = percentStripped.match(/-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const fallback = Number(match[0]);
|
||||
return Number.isFinite(fallback) ? fallback : NaN;
|
||||
}
|
||||
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return Number.isFinite(value) ? Number(value) : NaN;
|
||||
}
|
||||
|
||||
export function clampPercent(value) {
|
||||
const numeric = parsePercentValue(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(0, numeric));
|
||||
}
|
||||
|
||||
export function getDefaultPlatformFeePercent() {
|
||||
const parsed = parsePercentValue(PLATFORM_FEE_PERCENT);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return clampPercent(parsed);
|
||||
}
|
||||
|
||||
export function resolvePlatformFeePercent(overrideValue) {
|
||||
const parsedOverride = parsePercentValue(overrideValue);
|
||||
if (Number.isFinite(parsedOverride)) {
|
||||
return clampPercent(parsedOverride);
|
||||
}
|
||||
return getDefaultPlatformFeePercent();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { resolvePlatformFeePercent } from "./platformFee.js";
|
||||
import {
|
||||
resolveLightningAddress as defaultResolveLightningAddress,
|
||||
fetchPayServiceData as defaultFetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
} from "./lnurl.js";
|
||||
|
||||
const METADATA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const lightningMetadataCache = new Map();
|
||||
const lightningMetadataByUrl = new Map();
|
||||
let cachedPlatformLightningAddress = "";
|
||||
|
||||
export function normalizeLightningAddressKey(address) {
|
||||
return typeof address === "string" ? address.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isMetadataEntryFresh(entry) {
|
||||
if (!entry || typeof entry.fetchedAt !== "number") {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - entry.fetchedAt < METADATA_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
export function rememberLightningMetadata(entry) {
|
||||
if (!entry || !entry.key) {
|
||||
return entry;
|
||||
}
|
||||
lightningMetadataCache.set(entry.key, entry);
|
||||
if (entry.resolved?.url) {
|
||||
lightningMetadataByUrl.set(entry.resolved.url, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getCachedLightningEntry(address) {
|
||||
const key = normalizeLightningAddressKey(address);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const entry = lightningMetadataCache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.metadata && isMetadataEntryFresh(entry)) {
|
||||
return entry;
|
||||
}
|
||||
if (entry.metadata && !entry.promise) {
|
||||
return entry;
|
||||
}
|
||||
return entry.metadata ? entry : null;
|
||||
}
|
||||
|
||||
export function getCachedMetadataByUrl(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return lightningMetadataByUrl.get(url) || null;
|
||||
}
|
||||
|
||||
export async function fetchLightningMetadata(
|
||||
address,
|
||||
{
|
||||
resolveLightningAddress = defaultResolveLightningAddress,
|
||||
fetchPayServiceData = defaultFetchPayServiceData,
|
||||
} = {}
|
||||
) {
|
||||
const key = normalizeLightningAddressKey(address);
|
||||
if (!key) {
|
||||
throw new Error("Lightning address is required.");
|
||||
}
|
||||
|
||||
const cached = lightningMetadataCache.get(key);
|
||||
if (cached && cached.metadata && isMetadataEntryFresh(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (cached?.promise) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const resolved = cached?.resolved || (await resolveLightningAddress(address));
|
||||
const metadata = await fetchPayServiceData(resolved.url);
|
||||
const entry = {
|
||||
key,
|
||||
address: resolved.address || address,
|
||||
resolved,
|
||||
metadata,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
rememberLightningMetadata(entry);
|
||||
return entry;
|
||||
})();
|
||||
|
||||
lightningMetadataCache.set(key, {
|
||||
...(cached || {}),
|
||||
key,
|
||||
promise: fetchPromise,
|
||||
});
|
||||
|
||||
try {
|
||||
return await fetchPromise;
|
||||
} catch (error) {
|
||||
const current = lightningMetadataCache.get(key);
|
||||
if (current?.promise === fetchPromise) {
|
||||
lightningMetadataCache.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateZapShares(amount, overrideFee = null) {
|
||||
const numericAmount = Math.max(0, Math.round(Number(amount) || 0));
|
||||
const feePercent = resolvePlatformFeePercent(
|
||||
typeof overrideFee === "undefined" ? null : overrideFee
|
||||
);
|
||||
const platformShare = Math.floor((numericAmount * feePercent) / 100);
|
||||
const creatorShare = numericAmount - platformShare;
|
||||
return {
|
||||
total: numericAmount,
|
||||
creatorShare,
|
||||
platformShare,
|
||||
feePercent,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeShareType(type) {
|
||||
if (type === "platform") {
|
||||
return "Platform";
|
||||
}
|
||||
if (type === "creator") {
|
||||
return "Creator";
|
||||
}
|
||||
return "Lightning";
|
||||
}
|
||||
|
||||
export function formatMinRequirement(metadata) {
|
||||
if (!metadata || typeof metadata.minSendable !== "number") {
|
||||
return null;
|
||||
}
|
||||
if (metadata.minSendable <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.ceil(metadata.minSendable / 1000);
|
||||
}
|
||||
|
||||
export function setCachedPlatformLightningAddress(address) {
|
||||
cachedPlatformLightningAddress =
|
||||
typeof address === "string" ? address.trim() : "";
|
||||
return cachedPlatformLightningAddress;
|
||||
}
|
||||
|
||||
export function getCachedPlatformLightningAddress() {
|
||||
return cachedPlatformLightningAddress;
|
||||
}
|
||||
|
||||
export function clearZapCaches() {
|
||||
lightningMetadataCache.clear();
|
||||
lightningMetadataByUrl.clear();
|
||||
cachedPlatformLightningAddress = "";
|
||||
}
|
||||
|
||||
export { validateInvoiceAmount };
|
||||
@@ -0,0 +1,464 @@
|
||||
// js/payments/zapSplit.js
|
||||
|
||||
import { ADMIN_SUPER_NPUB } from "../config.js";
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
import {
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
} from "./lnurl.js";
|
||||
import { ensureWallet, sendPayment } from "./nwcClient.js";
|
||||
import { getPlatformLightningAddress } from "./platformAddress.js";
|
||||
import {
|
||||
clampPercent,
|
||||
parsePercentValue,
|
||||
resolvePlatformFeePercent,
|
||||
} from "./platformFee.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const ZAP_KIND = 9734;
|
||||
const DEFAULT_DEPS = Object.freeze({
|
||||
lnurl: Object.freeze({
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
}),
|
||||
wallet: Object.freeze({ ensureWallet, sendPayment }),
|
||||
platformAddress: Object.freeze({ getPlatformLightningAddress }),
|
||||
});
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readToolkitFromScope(scope = globalScope) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
if (canonical) {
|
||||
candidates.push(canonical);
|
||||
}
|
||||
|
||||
const direct = scope.NostrTools;
|
||||
if (direct) {
|
||||
candidates.push(direct);
|
||||
}
|
||||
|
||||
const nestedWindow =
|
||||
scope.window && scope.window !== scope && typeof scope.window === "object"
|
||||
? scope.window
|
||||
: null;
|
||||
if (nestedWindow) {
|
||||
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
|
||||
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
|
||||
}
|
||||
if (nestedWindow.NostrTools) {
|
||||
candidates.push(nestedWindow.NostrTools);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const __zapNostrToolsBootstrap = await (async () => {
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
if (result && typeof result === "object" && result.ok === false) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: result,
|
||||
};
|
||||
}
|
||||
const normalized = normalizeToolkitCandidate(result);
|
||||
if (normalized) {
|
||||
return { toolkit: normalized, failure: null };
|
||||
}
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
let cachedNostrTools = __zapNostrToolsBootstrap.toolkit || null;
|
||||
const nostrToolsInitializationFailure = __zapNostrToolsBootstrap.failure || null;
|
||||
|
||||
if (!cachedNostrTools && nostrToolsInitializationFailure) {
|
||||
console.warn(
|
||||
"[zapSplit] nostr-tools helpers unavailable after bootstrap.",
|
||||
nostrToolsInitializationFailure
|
||||
);
|
||||
}
|
||||
|
||||
function rememberNostrTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedNostrTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedNostrTools() {
|
||||
const fallback = readToolkitFromScope();
|
||||
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
|
||||
rememberNostrTools(fallback);
|
||||
} else if (!cachedNostrTools && fallback) {
|
||||
rememberNostrTools(fallback);
|
||||
}
|
||||
return cachedNostrTools || fallback || null;
|
||||
}
|
||||
|
||||
async function ensureNostrTools() {
|
||||
if (cachedNostrTools) {
|
||||
return cachedNostrTools;
|
||||
}
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberNostrTools(result);
|
||||
} catch (error) {
|
||||
console.warn("[zapSplit] Failed to resolve nostr-tools helpers.", error);
|
||||
}
|
||||
if (!cachedNostrTools) {
|
||||
rememberNostrTools(readToolkitFromScope());
|
||||
}
|
||||
return cachedNostrTools || null;
|
||||
}
|
||||
|
||||
function getOverridePlatformFee() {
|
||||
return resolvePlatformFeePercent(globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__);
|
||||
}
|
||||
|
||||
function sanitizeAmount(amount) {
|
||||
const numeric = Number(amount);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
throw new Error("Zap amount must be a positive integer.");
|
||||
}
|
||||
const rounded = Math.round(numeric);
|
||||
if (rounded <= 0) {
|
||||
throw new Error("Zap amount must be greater than zero.");
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function sanitizeComment(comment) {
|
||||
if (typeof comment !== "string") {
|
||||
return "";
|
||||
}
|
||||
return comment.trim();
|
||||
}
|
||||
|
||||
function sanitizeAddress(address) {
|
||||
const trimmed = typeof address === "string" ? address.trim() : "";
|
||||
return trimmed || "";
|
||||
}
|
||||
|
||||
function mergeDependencies(overrides = {}) {
|
||||
const lnurlDeps = {
|
||||
...DEFAULT_DEPS.lnurl,
|
||||
...(overrides.lnurl || {}),
|
||||
};
|
||||
const walletDeps = {
|
||||
...DEFAULT_DEPS.wallet,
|
||||
...(overrides.wallet || {}),
|
||||
};
|
||||
const platformDeps = {
|
||||
...DEFAULT_DEPS.platformAddress,
|
||||
...(overrides.platformAddress || {}),
|
||||
};
|
||||
|
||||
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
const tools = getCachedNostrTools();
|
||||
if (tools?.nip04) {
|
||||
return tools;
|
||||
}
|
||||
const scope = globalScope || (typeof window !== "undefined" ? window : globalThis);
|
||||
const canonical = scope?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
|
||||
if (canonical && tools && !tools.nip04 && canonical.nip04) {
|
||||
try {
|
||||
tools.nip04 = canonical.nip04;
|
||||
return tools;
|
||||
} catch (error) {
|
||||
return { ...canonical, ...tools, nip04: canonical.nip04 };
|
||||
}
|
||||
}
|
||||
return tools || canonical || null;
|
||||
}
|
||||
|
||||
function decodeAdminPubkey() {
|
||||
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
const tools = getNostrTools();
|
||||
const decoder = tools?.nip19?.decode;
|
||||
if (typeof decoder === "function") {
|
||||
try {
|
||||
const decoded = decoder(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function derivePointerTag(videoEvent) {
|
||||
if (!videoEvent || typeof videoEvent !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(videoEvent.tags)) {
|
||||
return null;
|
||||
}
|
||||
const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]);
|
||||
if (!dTag) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`;
|
||||
return ["a", value];
|
||||
}
|
||||
|
||||
function buildZapRequest({
|
||||
wallet,
|
||||
recipientPubkey,
|
||||
videoEvent,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl,
|
||||
}) {
|
||||
const tools = getNostrTools();
|
||||
if (!tools?.getEventHash || !tools?.signEvent) {
|
||||
throw new Error("NostrTools is required to sign zap requests.");
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (recipientPubkey) {
|
||||
tags.push(["p", recipientPubkey]);
|
||||
}
|
||||
if (videoEvent?.id) {
|
||||
tags.push(["e", videoEvent.id]);
|
||||
}
|
||||
const pointerTag = derivePointerTag(videoEvent);
|
||||
if (pointerTag) {
|
||||
tags.push(pointerTag);
|
||||
}
|
||||
if (lnurl) {
|
||||
tags.push(["lnurl", lnurl]);
|
||||
}
|
||||
if (Number.isFinite(amountSats)) {
|
||||
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: ZAP_KIND,
|
||||
content: comment || "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
pubkey: wallet?.clientPubkey || "",
|
||||
};
|
||||
|
||||
event.id = tools.getEventHash(event);
|
||||
event.sig = tools.signEvent(event, wallet?.secretKey);
|
||||
|
||||
return JSON.stringify(event);
|
||||
}
|
||||
|
||||
function determineRecipientPubkey({ recipientType, metadata, videoEvent }) {
|
||||
if (metadata?.nostrPubkey && HEX64_REGEX.test(metadata.nostrPubkey)) {
|
||||
return metadata.nostrPubkey.toLowerCase();
|
||||
}
|
||||
if (recipientType === "creator" && typeof videoEvent?.pubkey === "string") {
|
||||
return videoEvent.pubkey;
|
||||
}
|
||||
if (recipientType === "platform") {
|
||||
return decodeAdminPubkey();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processShare({
|
||||
recipientType,
|
||||
address,
|
||||
amountSats,
|
||||
comment,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
}) {
|
||||
const resolved = deps.lnurl.resolveLightningAddress(address);
|
||||
const metadata = await deps.lnurl.fetchPayServiceData(resolved.url);
|
||||
const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats);
|
||||
|
||||
let zapRequest = null;
|
||||
if (metadata.allowsNostr === true || metadata.nostrPubkey) {
|
||||
const recipientPubkey = determineRecipientPubkey({
|
||||
recipientType,
|
||||
metadata,
|
||||
videoEvent,
|
||||
});
|
||||
if (recipientPubkey) {
|
||||
zapRequest = buildZapRequest({
|
||||
wallet,
|
||||
recipientPubkey,
|
||||
videoEvent,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl: resolved.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const invoice = await deps.lnurl.requestInvoice(metadata, {
|
||||
amountMsats,
|
||||
comment,
|
||||
zapRequest,
|
||||
});
|
||||
|
||||
const payment = await deps.wallet.sendPayment(invoice.invoice, {
|
||||
amountSats,
|
||||
zapRequest,
|
||||
});
|
||||
|
||||
return {
|
||||
recipientType,
|
||||
address,
|
||||
amount: amountSats,
|
||||
lnurl: resolved,
|
||||
metadata,
|
||||
invoice,
|
||||
payment,
|
||||
zapRequest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function splitAndZap(
|
||||
{ videoEvent, amountSats, comment = "", walletSettings } = {},
|
||||
dependencies = {}
|
||||
) {
|
||||
if (!videoEvent || typeof videoEvent !== "object") {
|
||||
throw new Error("A video event is required to zap.");
|
||||
}
|
||||
|
||||
const amount = sanitizeAmount(amountSats);
|
||||
const note = sanitizeComment(comment);
|
||||
const deps = mergeDependencies(dependencies);
|
||||
|
||||
const wallet = await deps.wallet.ensureWallet({ settings: walletSettings });
|
||||
|
||||
const creatorAddress = sanitizeAddress(videoEvent.lightningAddress);
|
||||
if (!creatorAddress) {
|
||||
throw new Error("This creator has not configured a Lightning address yet.");
|
||||
}
|
||||
|
||||
const platformFee = clampPercent(getOverridePlatformFee());
|
||||
const platformShare = Math.floor((amount * platformFee) / 100);
|
||||
const creatorShare = amount - platformShare;
|
||||
|
||||
let platformAddress = null;
|
||||
if (platformShare > 0) {
|
||||
platformAddress = sanitizeAddress(
|
||||
await deps.platformAddress.getPlatformLightningAddress({ forceRefresh: false })
|
||||
);
|
||||
if (!platformAddress) {
|
||||
throw new Error("Platform Lightning address is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
const receipts = [];
|
||||
|
||||
if (creatorShare > 0) {
|
||||
receipts.push(
|
||||
await processShare({
|
||||
recipientType: "creator",
|
||||
address: creatorAddress,
|
||||
amountSats: creatorShare,
|
||||
comment: note,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (platformShare > 0) {
|
||||
receipts.push(
|
||||
await processShare({
|
||||
recipientType: "platform",
|
||||
address: platformAddress,
|
||||
amountSats: platformShare,
|
||||
comment: note,
|
||||
videoEvent,
|
||||
wallet,
|
||||
deps,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
totalAmount: amount,
|
||||
creatorShare,
|
||||
platformShare,
|
||||
receipts,
|
||||
};
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
buildZapRequest,
|
||||
determineRecipientPubkey,
|
||||
derivePointerTag,
|
||||
mergeDependencies,
|
||||
parsePercentValue,
|
||||
clampPercent,
|
||||
});
|
||||
+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,705 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
|
||||
import { buildRelayListEvent } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "./nostrPublish.js";
|
||||
|
||||
const MODE_SEQUENCE = ["both", "read", "write"];
|
||||
|
||||
const FAST_RELAY_FETCH_LIMIT = 3;
|
||||
const FAST_RELAY_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_RELAY_TIMEOUT_MS = 6000;
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRelayUrl(input) {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let candidate = input.trim();
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^[a-z]+:\/\//i.test(candidate)) {
|
||||
candidate = `wss://${candidate}`;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
let pathname = parsed.pathname || "";
|
||||
if (pathname !== "/") {
|
||||
pathname = pathname.replace(/\/+$/, "");
|
||||
}
|
||||
if (pathname === "/") {
|
||||
pathname = "";
|
||||
}
|
||||
const search = parsed.search || "";
|
||||
const hash = parsed.hash || "";
|
||||
|
||||
return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
function deriveMode(marker) {
|
||||
if (typeof marker !== "string") {
|
||||
return "both";
|
||||
}
|
||||
const normalized = marker.trim().toLowerCase();
|
||||
if (normalized === "read") {
|
||||
return "read";
|
||||
}
|
||||
if (normalized === "write") {
|
||||
return "write";
|
||||
}
|
||||
return "both";
|
||||
}
|
||||
|
||||
function createEntry(url, mode = "both") {
|
||||
const normalizedMode = deriveMode(mode);
|
||||
return {
|
||||
url,
|
||||
mode: normalizedMode,
|
||||
read: normalizedMode !== "write",
|
||||
write: normalizedMode !== "read",
|
||||
};
|
||||
}
|
||||
|
||||
function cloneEntry(entry) {
|
||||
return {
|
||||
url: entry.url,
|
||||
mode: entry.mode,
|
||||
read: !!entry.read,
|
||||
write: !!entry.write,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEntries(entries) {
|
||||
return JSON.stringify(
|
||||
entries.map((entry) => ({ url: entry.url, mode: entry.mode }))
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayTags(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Map();
|
||||
const order = [];
|
||||
|
||||
tags.forEach((tag) => {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
return;
|
||||
}
|
||||
if (tag[0] !== "r") {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = normalizeRelayUrl(tag[1]);
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
if (!seen.has(normalizedUrl)) {
|
||||
seen.set(normalizedUrl, { read: false, write: false });
|
||||
order.push(normalizedUrl);
|
||||
}
|
||||
const record = seen.get(normalizedUrl);
|
||||
const mode = deriveMode(tag[2]);
|
||||
if (mode === "read") {
|
||||
record.read = true;
|
||||
} else if (mode === "write") {
|
||||
record.write = true;
|
||||
} else {
|
||||
record.read = true;
|
||||
record.write = true;
|
||||
}
|
||||
});
|
||||
|
||||
return order.map((url) => {
|
||||
const record = seen.get(url) || { read: true, write: true };
|
||||
if (record.read && record.write) {
|
||||
return createEntry(url, "both");
|
||||
}
|
||||
if (record.read) {
|
||||
return createEntry(url, "read");
|
||||
}
|
||||
if (record.write) {
|
||||
return createEntry(url, "write");
|
||||
}
|
||||
return createEntry(url, "both");
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEntryInput(entry) {
|
||||
if (typeof entry === "string") {
|
||||
return { url: entry, mode: "both" };
|
||||
}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const url = typeof entry.url === "string" ? entry.url : "";
|
||||
let mode = entry.mode || entry.marker;
|
||||
if (!mode) {
|
||||
if (entry.read === true && entry.write === false) {
|
||||
mode = "read";
|
||||
} else if (entry.write === true && entry.read === false) {
|
||||
mode = "write";
|
||||
}
|
||||
}
|
||||
return { url, mode: deriveMode(mode) };
|
||||
}
|
||||
|
||||
class RelayPreferencesManager {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
this.entryIndex = new Map();
|
||||
this.lastEvent = null;
|
||||
this.loadedPubkey = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.defaultEntries = DEFAULT_RELAY_URLS.map((url) => {
|
||||
const normalized = normalizeRelayUrl(url) || url;
|
||||
return createEntry(normalized, "both");
|
||||
});
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
getEntries() {
|
||||
return this.entries.map((entry) => cloneEntry(entry));
|
||||
}
|
||||
|
||||
getAllRelayUrls() {
|
||||
return this.entries.map((entry) => entry.url);
|
||||
}
|
||||
|
||||
getReadRelayUrls() {
|
||||
return this.entries.filter((entry) => entry.read).map((entry) => entry.url);
|
||||
}
|
||||
|
||||
getWriteRelayUrls() {
|
||||
return this.entries.filter((entry) => entry.write).map((entry) => entry.url);
|
||||
}
|
||||
|
||||
syncClient() {
|
||||
if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") {
|
||||
nostrClient.applyRelayPreferences({
|
||||
all: this.getAllRelayUrls(),
|
||||
read: this.getReadRelayUrls(),
|
||||
write: this.getWriteRelayUrls(),
|
||||
});
|
||||
} else if (nostrClient) {
|
||||
nostrClient.relays = this.getAllRelayUrls();
|
||||
}
|
||||
}
|
||||
|
||||
setEntries(entries, { allowEmpty = false, updateClient = true } = {}) {
|
||||
const nextEntries = [];
|
||||
const indexMap = new Map();
|
||||
|
||||
if (Array.isArray(entries)) {
|
||||
entries.forEach((item) => {
|
||||
const resolved = resolveEntryInput(item);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = normalizeRelayUrl(resolved.url);
|
||||
if (!normalizedUrl || indexMap.has(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = createEntry(normalizedUrl, resolved.mode);
|
||||
indexMap.set(normalizedUrl, nextEntries.length);
|
||||
nextEntries.push(normalizedEntry);
|
||||
});
|
||||
}
|
||||
|
||||
if (!nextEntries.length && !allowEmpty) {
|
||||
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
if (updateClient) {
|
||||
this.syncClient();
|
||||
}
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
this.entries = nextEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
|
||||
if (!this.entries.length && !allowEmpty) {
|
||||
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
|
||||
this.entryIndex = new Map(
|
||||
this.entries.map((entry, idx) => [entry.url, idx])
|
||||
);
|
||||
}
|
||||
|
||||
if (updateClient) {
|
||||
this.syncClient();
|
||||
}
|
||||
|
||||
return this.getEntries();
|
||||
}
|
||||
|
||||
addRelay(url, mode = "both") {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid WSS relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (this.entryIndex.has(normalizedUrl)) {
|
||||
return {
|
||||
changed: false,
|
||||
reason: "duplicate",
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries();
|
||||
nextEntries.push({ url: normalizedUrl, mode });
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
updateRelayMode(url, mode = "both") {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
return { changed: false, reason: "missing" };
|
||||
}
|
||||
|
||||
const normalizedMode = deriveMode(mode);
|
||||
const index = this.entryIndex.get(normalizedUrl);
|
||||
const current = this.entries[index];
|
||||
if (current.mode === normalizedMode) {
|
||||
return { changed: false, entry: cloneEntry(current) };
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries();
|
||||
nextEntries[index].mode = normalizedMode;
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
|
||||
};
|
||||
}
|
||||
|
||||
cycleRelayMode(url) {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
const error = new Error("Relay not found.");
|
||||
error.code = "missing";
|
||||
throw error;
|
||||
}
|
||||
const index = this.entryIndex.get(normalizedUrl);
|
||||
const currentMode = this.entries[index].mode || "both";
|
||||
const currentIdx = MODE_SEQUENCE.indexOf(currentMode);
|
||||
const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length];
|
||||
return this.updateRelayMode(normalizedUrl, nextMode);
|
||||
}
|
||||
|
||||
removeRelay(url) {
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error("Enter a valid relay URL to remove.");
|
||||
error.code = "invalid";
|
||||
throw error;
|
||||
}
|
||||
if (!this.entryIndex.has(normalizedUrl)) {
|
||||
return { changed: false, reason: "missing" };
|
||||
}
|
||||
if (this.entries.length <= 1) {
|
||||
const error = new Error("At least one relay is required.");
|
||||
error.code = "minimum";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl);
|
||||
this.setEntries(nextEntries, { allowEmpty: false });
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
restoreDefaults() {
|
||||
const before = serializeEntries(this.entries);
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
const after = serializeEntries(this.entries);
|
||||
return { changed: before !== after };
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.lastEvent = null;
|
||||
this.loadedPubkey = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
}
|
||||
|
||||
getPublishTargets(customTargets = null) {
|
||||
const targets = new Set();
|
||||
const list =
|
||||
Array.isArray(customTargets) && customTargets.length
|
||||
? customTargets
|
||||
: this.getAllRelayUrls();
|
||||
|
||||
list.forEach((candidate) => {
|
||||
const normalized = normalizeRelayUrl(candidate);
|
||||
if (normalized) {
|
||||
targets.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
DEFAULT_RELAY_URLS.forEach((url) => {
|
||||
const normalized = normalizeRelayUrl(url) || url;
|
||||
targets.add(normalized);
|
||||
});
|
||||
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
async loadRelayList(pubkey) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
this.loadedPubkey = normalizedPubkey;
|
||||
|
||||
if (!normalizedPubkey) {
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: false, reason: "invalid-pubkey" };
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: false, reason: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 };
|
||||
const targetRelays = this.getPublishTargets();
|
||||
const applyEvents = (events, { skipIfEmpty = false } = {}) => {
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
if (skipIfEmpty) {
|
||||
return null;
|
||||
}
|
||||
this.lastEvent = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", events: [] };
|
||||
}
|
||||
|
||||
const sorted = events
|
||||
.filter((event) => event && event.pubkey === normalizedPubkey)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
|
||||
if (!sorted.length) {
|
||||
if (skipIfEmpty) {
|
||||
return null;
|
||||
}
|
||||
this.lastEvent = null;
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", events: [] };
|
||||
}
|
||||
|
||||
const newest = sorted[0];
|
||||
this.lastEvent = newest;
|
||||
|
||||
const parsed = parseRelayTags(newest?.tags);
|
||||
if (parsed.length) {
|
||||
this.setEntries(parsed, { allowEmpty: false });
|
||||
this.lastLoadSource = "event";
|
||||
return { ok: true, source: "event", event: newest };
|
||||
}
|
||||
|
||||
this.lastLoadSource = "default";
|
||||
this.setEntries(this.defaultEntries, { allowEmpty: false });
|
||||
return { ok: true, source: "default", event: newest };
|
||||
};
|
||||
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching relay list from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => nostrClient.pool.list([relayUrl], [filter]))
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalizedPubkey)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No relay list events returned from ${relayUrl}`
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
reject(emptyError);
|
||||
return;
|
||||
}
|
||||
resolve({ relayUrl, events });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
reject(wrapped);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fastRelays = targetRelays.slice(0, FAST_RELAY_FETCH_LIMIT);
|
||||
const backgroundRelays = targetRelays.slice(fastRelays.length);
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_RELAY_TIMEOUT_MS, true)
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_RELAY_TIMEOUT_MS, false)
|
||||
);
|
||||
|
||||
const background = Promise.allSettled([
|
||||
...fastPromises,
|
||||
...backgroundPromises,
|
||||
])
|
||||
.then((outcomes) => {
|
||||
const aggregated = [];
|
||||
outcomes.forEach((outcome) => {
|
||||
if (outcome.status === "fulfilled") {
|
||||
const events = Array.isArray(outcome.value?.events)
|
||||
? outcome.value.events
|
||||
: [];
|
||||
if (events.length) {
|
||||
aggregated.push(...events);
|
||||
}
|
||||
} else {
|
||||
const reason = outcome.reason;
|
||||
if (reason?.code === "timeout") {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${reason.relay} timed out while loading relay list (${reason.timeoutMs}ms)`
|
||||
);
|
||||
}
|
||||
} else if (isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${reason?.relay || "unknown"} failed while loading relay list:`,
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!aggregated.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyEvents(aggregated, { skipIfEmpty: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[relayManager] Background relay refresh failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
let fastResult = null;
|
||||
if (fastPromises.length) {
|
||||
try {
|
||||
fastResult = await Promise.any(fastPromises);
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors?.forEach((err) => {
|
||||
if (err?.code === "timeout" && isDevMode) {
|
||||
console.warn(
|
||||
`[relayManager] Relay ${err.relay} timed out while loading relay list (${err.timeoutMs}ms)`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (isDevMode) {
|
||||
console.warn("[relayManager] Fast relay fetch failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fastResult?.events?.length) {
|
||||
const result = applyEvents(fastResult.events);
|
||||
background.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const fallback = applyEvents([], { skipIfEmpty: false });
|
||||
background.catch(() => {});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async publishRelayList(pubkey, options = {}) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
const error = new Error(
|
||||
"A valid pubkey is required to publish relay preferences."
|
||||
);
|
||||
error.code = "invalid-pubkey";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.signEvent) {
|
||||
const error = new Error(
|
||||
"A NIP-07 extension is required to publish relay preferences."
|
||||
);
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
const error = new Error(
|
||||
"Nostr is not connected yet. Please try again once relays are ready."
|
||||
);
|
||||
error.code = "nostr-uninitialized";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries = this.getEntries();
|
||||
if (!entries.length) {
|
||||
const error = new Error("Add at least one relay before publishing.");
|
||||
error.code = "empty";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const event = buildRelayListEvent({
|
||||
pubkey: normalizedPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
relays: entries,
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const targets = this.getPublishTargets(options?.relayUrls);
|
||||
|
||||
if (!targets.length) {
|
||||
const error = new Error("No relay targets are available for publishing.");
|
||||
error.code = "no-targets";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
targets,
|
||||
signedEvent
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "relay preferences update",
|
||||
message: "No relays accepted the update.",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
console.error(
|
||||
`[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
console.warn(
|
||||
`[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const accepted = publishSummary.accepted.map(({ url }) => url);
|
||||
const failed = publishSummary.failed.map(({ url, error: relayError }) => ({
|
||||
url,
|
||||
reason: relayError || null,
|
||||
}));
|
||||
|
||||
this.lastEvent = signedEvent;
|
||||
|
||||
return { ok: true, event: signedEvent, accepted, failed, targets };
|
||||
}
|
||||
|
||||
getLastLoadSource() {
|
||||
return this.lastLoadSource;
|
||||
}
|
||||
}
|
||||
|
||||
export const relayManager = new RelayPreferencesManager();
|
||||
|
||||
export function normalizeRelayUrlForDisplay(value) {
|
||||
return normalizeRelayUrl(value);
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
// js/services/authService.js
|
||||
|
||||
import {
|
||||
setPubkey,
|
||||
setCurrentUserNpub,
|
||||
getPubkey,
|
||||
getCurrentUserNpub,
|
||||
} from "../state/appState.js";
|
||||
import {
|
||||
getSavedProfiles,
|
||||
getActiveProfilePubkey,
|
||||
setActiveProfilePubkey,
|
||||
mutateSavedProfiles,
|
||||
persistSavedProfiles,
|
||||
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
|
||||
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
|
||||
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
|
||||
persistProfileCacheToStorage as persistProfileCacheState,
|
||||
getProfileCacheEntry as getCachedProfileEntry,
|
||||
setProfileCacheEntry as setCachedProfileEntry,
|
||||
} from "../state/cache.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
this.logger(`AuthService listener for "${eventName}" threw`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const FALLBACK_PROFILE = {
|
||||
name: "Unknown",
|
||||
picture: "assets/svg/default-profile.svg",
|
||||
about: "",
|
||||
website: "",
|
||||
banner: "",
|
||||
lud16: "",
|
||||
lud06: "",
|
||||
};
|
||||
|
||||
const FAST_PROFILE_RELAY_LIMIT = 3;
|
||||
const FAST_PROFILE_TIMEOUT_MS = 2500;
|
||||
const BACKGROUND_PROFILE_TIMEOUT_MS = 6000;
|
||||
|
||||
export default class AuthService {
|
||||
constructor({ nostrClient, userBlocks, relayManager, logger } = {}) {
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.userBlocks = userBlocks || null;
|
||||
this.relayManager = relayManager || null;
|
||||
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
} else if (logger && typeof logger.log === "function") {
|
||||
this.logger = (...args) => logger.log(...args);
|
||||
} else {
|
||||
this.logger = () => {};
|
||||
}
|
||||
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.logger(message, error);
|
||||
} catch (logError) {
|
||||
console.warn("[AuthService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (error) {
|
||||
console.warn("[AuthService] logger threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
hydrateFromStorage() {
|
||||
hydrateProfileCacheFromStorage();
|
||||
const { profiles } = hydrateSavedProfilesFromStorage();
|
||||
if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) {
|
||||
const first = profiles[0]?.pubkey || null;
|
||||
if (first) {
|
||||
setActiveProfilePubkey(first, { persist: true });
|
||||
}
|
||||
}
|
||||
this.emitProfileList("hydrate");
|
||||
}
|
||||
|
||||
persistProfileCache() {
|
||||
persistProfileCacheState();
|
||||
}
|
||||
|
||||
getProfileCacheEntry(pubkey) {
|
||||
return getCachedProfileEntry(pubkey);
|
||||
}
|
||||
|
||||
setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) {
|
||||
const entry = setCachedProfileEntry(pubkey, profile, { persist });
|
||||
if (entry) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (normalized) {
|
||||
const updated = syncSavedProfileFromCacheState(normalized, {
|
||||
persist,
|
||||
});
|
||||
if (updated) {
|
||||
this.emitProfileList("cache-sync");
|
||||
}
|
||||
this.emit("profile:updated", {
|
||||
pubkey: normalized,
|
||||
profile: entry.profile || FALLBACK_PROFILE,
|
||||
reason,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
cloneSavedProfiles() {
|
||||
const saved = getSavedProfiles();
|
||||
return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : [];
|
||||
}
|
||||
|
||||
emitProfileList(reason = "update") {
|
||||
this.emit("profile:updated", {
|
||||
reason,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
}
|
||||
|
||||
safeEncodeNpub(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestLogin(options = {}) {
|
||||
if (!this.nostrClient || typeof this.nostrClient.login !== "function") {
|
||||
throw new Error("Nostr login is not available.");
|
||||
}
|
||||
|
||||
const result = await this.nostrClient.login(options);
|
||||
const pubkey =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result && typeof result === "object"
|
||||
? result.pubkey || result.publicKey || ""
|
||||
: "";
|
||||
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
if (!trimmed) {
|
||||
return { pubkey: null };
|
||||
}
|
||||
|
||||
if (options?.autoApply === false) {
|
||||
return { pubkey: trimmed };
|
||||
}
|
||||
|
||||
const detail = await this.login(trimmed, {
|
||||
persistActive: options?.persistActive !== false,
|
||||
});
|
||||
|
||||
return { pubkey: trimmed, detail };
|
||||
}
|
||||
|
||||
async handleUploadSubmit(payload, { publish } = {}) {
|
||||
if (!this.getActivePubkey()) {
|
||||
await this.requestLogin({ allowAccountSelection: true });
|
||||
if (!this.getActivePubkey()) {
|
||||
throw new Error("Login required to publish videos.");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof publish !== "function") {
|
||||
throw new Error("A publish callback must be provided to handleUploadSubmit.");
|
||||
}
|
||||
|
||||
return publish(payload);
|
||||
}
|
||||
|
||||
getActivePubkey() {
|
||||
return getPubkey();
|
||||
}
|
||||
|
||||
async login(pubkey, options = {}) {
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? options : { persistActive: true };
|
||||
const persistActive =
|
||||
normalizedOptions.persistActive === false ? false : true;
|
||||
|
||||
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
const nextPubkey = normalized || trimmed || null;
|
||||
if (!nextPubkey) {
|
||||
throw new Error("A valid pubkey is required for login.");
|
||||
}
|
||||
|
||||
const identityChanged = previousPubkey !== nextPubkey;
|
||||
|
||||
if (normalized) {
|
||||
setPubkey(normalized);
|
||||
if (this.nostrClient && typeof this.nostrClient === "object") {
|
||||
this.nostrClient.pubkey = normalized;
|
||||
}
|
||||
} else {
|
||||
setPubkey(nextPubkey);
|
||||
if (this.nostrClient && typeof this.nostrClient === "object") {
|
||||
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
|
||||
const npub = this.safeEncodeNpub(nextPubkey);
|
||||
setCurrentUserNpub(npub);
|
||||
|
||||
let savedProfilesMutated = false;
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const draft = Array.isArray(profiles) ? profiles.slice() : [];
|
||||
const existingIndex = draft.findIndex((entry) => {
|
||||
const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey);
|
||||
return normalizedEntry && normalizedEntry === normalized;
|
||||
});
|
||||
|
||||
const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey);
|
||||
const cachedProfile = cacheEntry?.profile || {};
|
||||
|
||||
const nextEntry = {
|
||||
pubkey: normalized || nextPubkey,
|
||||
npub: npub || (cachedProfile.npub ?? null),
|
||||
name: cachedProfile.name || draft[existingIndex]?.name || "",
|
||||
picture: cachedProfile.picture || draft[existingIndex]?.picture || "",
|
||||
authType: "nip07",
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const currentEntry = draft[existingIndex] || {};
|
||||
const changed =
|
||||
currentEntry.npub !== nextEntry.npub ||
|
||||
currentEntry.name !== nextEntry.name ||
|
||||
currentEntry.picture !== nextEntry.picture ||
|
||||
currentEntry.authType !== nextEntry.authType;
|
||||
if (changed) {
|
||||
draft[existingIndex] = nextEntry;
|
||||
savedProfilesMutated = true;
|
||||
}
|
||||
} else {
|
||||
draft.push(nextEntry);
|
||||
savedProfilesMutated = true;
|
||||
}
|
||||
|
||||
return draft;
|
||||
}, { persist: false, persistActive: false });
|
||||
|
||||
if (persistActive) {
|
||||
setActiveProfilePubkey(normalized || nextPubkey, { persist: true });
|
||||
}
|
||||
|
||||
if (!persistActive && savedProfilesMutated) {
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
} else if (persistActive || savedProfilesMutated) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
|
||||
if (savedProfilesMutated) {
|
||||
this.emitProfileList("login");
|
||||
}
|
||||
|
||||
const activeLoginPubkey = getPubkey();
|
||||
const normalizedLoginPubkey =
|
||||
this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null;
|
||||
|
||||
const detail = {
|
||||
pubkey: activeLoginPubkey,
|
||||
npub: getCurrentUserNpub(),
|
||||
previousPubkey: previousPubkey || null,
|
||||
identityChanged,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
postLogin: {
|
||||
pubkey: normalizedLoginPubkey,
|
||||
blocksLoaded: false,
|
||||
relaysLoaded: false,
|
||||
profile: null,
|
||||
},
|
||||
postLoginPromise: null,
|
||||
postLoginError: null,
|
||||
};
|
||||
|
||||
const postLoginPromise = Promise.resolve()
|
||||
.then(() => this.applyPostLoginState())
|
||||
.then((postLogin) => {
|
||||
detail.postLogin = postLogin;
|
||||
detail.postLoginError = null;
|
||||
|
||||
try {
|
||||
this.emitProfileList("post-login");
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to emit profile list after login", error);
|
||||
}
|
||||
|
||||
this.emit("auth:post-login", {
|
||||
...detail,
|
||||
postLogin,
|
||||
postLoginError: null,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
|
||||
return postLogin;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.log("[AuthService] applyPostLoginState failed", error);
|
||||
detail.postLoginError = error;
|
||||
|
||||
this.emit("auth:post-login", {
|
||||
...detail,
|
||||
postLogin: detail.postLogin,
|
||||
postLoginError: error,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
detail.postLoginPromise = postLoginPromise;
|
||||
|
||||
try {
|
||||
Object.defineProperty(detail, "__handled", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore descriptor errors (e.g., frozen objects) and fall back to direct assignment.
|
||||
detail.__handled = false;
|
||||
}
|
||||
|
||||
this.emit("auth:login", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async applyPostLoginState() {
|
||||
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
const detail = {
|
||||
pubkey: activePubkey || null,
|
||||
blocksLoaded: false,
|
||||
relaysLoaded: false,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
if (!activePubkey) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const schedule = (callback) => Promise.resolve().then(() => callback());
|
||||
|
||||
const operations = [];
|
||||
|
||||
if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") {
|
||||
operations.push({
|
||||
name: "blocksLoaded",
|
||||
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey)),
|
||||
onFulfilled: () => true,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load block list", error);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
|
||||
operations.push({
|
||||
name: "relaysLoaded",
|
||||
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
|
||||
onFulfilled: () => true,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load relay list", error);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
operations.push({
|
||||
name: "profile",
|
||||
promise: schedule(() => this.loadOwnProfile(activePubkey)),
|
||||
onFulfilled: (value) => value,
|
||||
onRejected: (error) => {
|
||||
this.log("[AuthService] Failed to load own profile", error);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
operations.map((operation) => operation.promise)
|
||||
);
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
const operation = operations[index];
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "fulfilled") {
|
||||
detail[operation.name] = operation.onFulfilled(result.value);
|
||||
} else {
|
||||
detail[operation.name] = operation.onRejected(result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.nostrClient && typeof this.nostrClient.logout === "function") {
|
||||
try {
|
||||
this.nostrClient.logout();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] nostrClient.logout threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
|
||||
setPubkey(null);
|
||||
setCurrentUserNpub(null);
|
||||
setActiveProfilePubkey(null, { persist: true });
|
||||
|
||||
if (this.userBlocks && typeof this.userBlocks.reset === "function") {
|
||||
try {
|
||||
this.userBlocks.reset();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] userBlocks.reset threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.relayManager && typeof this.relayManager.reset === "function") {
|
||||
try {
|
||||
this.relayManager.reset();
|
||||
} catch (error) {
|
||||
this.log("[AuthService] relayManager.reset threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
|
||||
const detail = {
|
||||
previousPubkey: previousPubkey || null,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
};
|
||||
|
||||
this.emit("auth:logout", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
removeSavedProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return { removed: false };
|
||||
}
|
||||
|
||||
const { changed } = mutateSavedProfiles((profiles) =>
|
||||
profiles.filter((entry) => entry?.pubkey !== normalized),
|
||||
{
|
||||
persist: true,
|
||||
persistActive: true,
|
||||
});
|
||||
|
||||
if (changed && getActiveProfilePubkey() === normalized) {
|
||||
setActiveProfilePubkey(null, { persist: true });
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.emitProfileList("remove-saved-profile");
|
||||
}
|
||||
|
||||
return { removed: changed };
|
||||
}
|
||||
|
||||
async switchProfile(pubkey) {
|
||||
const normalizedTarget = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalizedTarget) {
|
||||
throw new Error("Unable to switch profiles: invalid account.");
|
||||
}
|
||||
|
||||
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
|
||||
if (normalizedActive && normalizedActive === normalizedTarget) {
|
||||
return { switched: false, reason: "already-active" };
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
allowAccountSelection: true,
|
||||
expectPubkey: normalizedTarget,
|
||||
persistActive: true,
|
||||
};
|
||||
const autoApply = requestOptions.autoApply !== false;
|
||||
|
||||
let requestResult;
|
||||
try {
|
||||
requestResult = await this.requestLogin(requestOptions);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const requestedPubkey =
|
||||
this.normalizeHexPubkey(requestResult?.pubkey) ||
|
||||
(typeof requestResult?.pubkey === "string"
|
||||
? requestResult.pubkey.trim()
|
||||
: "");
|
||||
|
||||
let detail = requestResult?.detail || null;
|
||||
|
||||
if (!detail && !autoApply) {
|
||||
const fallbackTarget =
|
||||
this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget;
|
||||
detail = await this.login(fallbackTarget, { persistActive: true });
|
||||
}
|
||||
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const draft = profiles.slice();
|
||||
const index = draft.findIndex(
|
||||
(entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget
|
||||
);
|
||||
if (index > 0) {
|
||||
const [moved] = draft.splice(index, 1);
|
||||
draft.unshift(moved);
|
||||
}
|
||||
return draft;
|
||||
}, { persist: true, persistActive: true });
|
||||
|
||||
const savedProfiles = this.cloneSavedProfiles();
|
||||
const activeProfilePubkey = getActiveProfilePubkey();
|
||||
|
||||
if (detail && typeof detail === "object") {
|
||||
detail.savedProfiles = savedProfiles;
|
||||
detail.activeProfilePubkey = activeProfilePubkey;
|
||||
}
|
||||
|
||||
this.emitProfileList("switch-profile");
|
||||
|
||||
return { switched: true, detail };
|
||||
}
|
||||
|
||||
async loadOwnProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
const relays = Array.isArray(this.nostrClient.relays)
|
||||
? this.nostrClient.relays.filter((url) => typeof url === "string" && url)
|
||||
: [];
|
||||
|
||||
if (!relays.length) {
|
||||
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fallback",
|
||||
});
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
const filter = [{ kinds: [0], authors: [normalized], limit: 1 }];
|
||||
|
||||
const selectNewestEvent = (eventList) => {
|
||||
if (!Array.isArray(eventList) || !eventList.length) {
|
||||
return null;
|
||||
}
|
||||
const sorted = eventList
|
||||
.filter((event) => event && event.pubkey === normalized)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
return sorted.length ? sorted[0] : null;
|
||||
};
|
||||
|
||||
const buildProfileFromEvent = (event) => {
|
||||
if (!event?.content) {
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(event.content);
|
||||
return {
|
||||
name: data.display_name || data.name || FALLBACK_PROFILE.name,
|
||||
picture: data.picture || FALLBACK_PROFILE.picture,
|
||||
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
||||
website:
|
||||
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
|
||||
banner:
|
||||
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
|
||||
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
|
||||
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to parse profile metadata", error);
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.nostrClient.pool.list([relayUrl], filter))
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalized)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No profile events returned from ${relayUrl}`
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
reject(emptyError);
|
||||
return;
|
||||
}
|
||||
resolve({ relayUrl, events });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
reject(wrapped);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
|
||||
const backgroundRelays = relays.slice(fastRelays.length);
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
|
||||
);
|
||||
|
||||
const background = Promise.allSettled([
|
||||
...fastPromises,
|
||||
...backgroundPromises,
|
||||
])
|
||||
.then((outcomes) => {
|
||||
const aggregated = [];
|
||||
outcomes.forEach((outcome) => {
|
||||
if (outcome.status === "fulfilled") {
|
||||
const events = Array.isArray(outcome.value?.events)
|
||||
? outcome.value.events
|
||||
: [];
|
||||
if (events.length) {
|
||||
aggregated.push(...events);
|
||||
}
|
||||
} else if (outcome.status === "rejected") {
|
||||
const reason = outcome.reason;
|
||||
if (reason?.code === "timeout") {
|
||||
this.log(
|
||||
`[AuthService] Relay ${reason.relay} timed out loading profile (background)`
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`,
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newest = selectNewestEvent(aggregated);
|
||||
if (!newest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = buildProfileFromEvent(newest);
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:background",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.log("[AuthService] Background profile refresh failed", error);
|
||||
});
|
||||
|
||||
let fastResult = null;
|
||||
if (fastPromises.length) {
|
||||
try {
|
||||
fastResult = await Promise.any(fastPromises);
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors?.forEach((err) => {
|
||||
if (err?.code === "timeout") {
|
||||
this.log(
|
||||
`[AuthService] Relay ${err.relay} timed out loading profile`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log("[AuthService] Failed to load profile from fast relays", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fastResult?.events?.length) {
|
||||
const newest = selectNewestEvent(fastResult.events);
|
||||
if (newest) {
|
||||
const profile = buildProfileFromEvent(newest);
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fast",
|
||||
});
|
||||
background.catch(() => {});
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
||||
persist: true,
|
||||
reason: "load-own-profile:fallback",
|
||||
});
|
||||
|
||||
background.catch(() => {});
|
||||
|
||||
return FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
async fetchProfile(pubkey, { forceRefresh = false } = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheEntry = this.getProfileCacheEntry(normalized);
|
||||
if (cacheEntry && !forceRefresh) {
|
||||
this.emit("profile:updated", {
|
||||
pubkey: normalized,
|
||||
profile: cacheEntry.profile || FALLBACK_PROFILE,
|
||||
reason: "cache-hit",
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
});
|
||||
return cacheEntry.profile;
|
||||
}
|
||||
|
||||
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return cacheEntry?.profile || FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
this.nostrClient.relays.map((relayUrl) =>
|
||||
this.nostrClient.pool.list([relayUrl], [
|
||||
{ kinds: [0], authors: [normalized], limit: 1 },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const events = results.flat();
|
||||
let newest = null;
|
||||
for (const event of events) {
|
||||
if (!event || event.pubkey !== normalized || !event.content) {
|
||||
continue;
|
||||
}
|
||||
if (!newest || event.created_at > newest.created_at) {
|
||||
newest = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (newest?.content) {
|
||||
const data = JSON.parse(newest.content);
|
||||
const profile = {
|
||||
name: data.display_name || data.name || FALLBACK_PROFILE.name,
|
||||
picture: data.picture || FALLBACK_PROFILE.picture,
|
||||
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
||||
website:
|
||||
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
|
||||
banner:
|
||||
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
|
||||
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
|
||||
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
|
||||
};
|
||||
this.setProfileCacheEntry(normalized, profile, {
|
||||
persist: true,
|
||||
reason: forceRefresh ? "force-refresh" : "fetch-profile",
|
||||
});
|
||||
return profile;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to fetch profile", error);
|
||||
}
|
||||
|
||||
return cacheEntry?.profile || FALLBACK_PROFILE;
|
||||
}
|
||||
|
||||
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
||||
return this.fetchProfile(pubkey, { forceRefresh });
|
||||
}
|
||||
|
||||
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated = syncSavedProfileFromCacheState(normalized, { persist });
|
||||
if (updated) {
|
||||
this.emitProfileList("cache-sync");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
loadProfileCacheFromStorage() {
|
||||
hydrateProfileCacheFromStorage();
|
||||
}
|
||||
|
||||
loadSavedProfilesFromStorage() {
|
||||
const result = hydrateSavedProfilesFromStorage();
|
||||
this.emitProfileList("hydrate");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
|
||||
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export default class DiscussionCountService {
|
||||
constructor({
|
||||
maxVideos = DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS,
|
||||
logger = typeof console !== "undefined" ? console : null,
|
||||
} = {}) {
|
||||
this.maxVideos = toPositiveInteger(maxVideos, DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS);
|
||||
this.logger = logger;
|
||||
this.videoDiscussionCountCache = new Map();
|
||||
this.inFlightDiscussionCounts = new Map();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.videoDiscussionCountCache.clear();
|
||||
this.inFlightDiscussionCounts.clear();
|
||||
}
|
||||
|
||||
getCachedCount(videoId) {
|
||||
if (!videoId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.videoDiscussionCountCache.get(videoId);
|
||||
}
|
||||
|
||||
refreshCounts(videos = [], options = {}) {
|
||||
const { videoListRoot = null, nostrClient = null } = options;
|
||||
|
||||
if (!Array.isArray(videos) || !videos.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root =
|
||||
videoListRoot && typeof videoListRoot.querySelector === "function"
|
||||
? videoListRoot
|
||||
: null;
|
||||
if (!root || !nostrClient?.pool || typeof nostrClient.countEventsAcrossRelays !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleVideos = videos
|
||||
.filter(
|
||||
(video) =>
|
||||
video &&
|
||||
typeof video.id === "string" &&
|
||||
video.id &&
|
||||
video.enableComments !== false
|
||||
)
|
||||
.slice(0, this.maxVideos);
|
||||
|
||||
eligibleVideos.forEach((video) => {
|
||||
const container = root.querySelector(`[data-discussion-count="${video.id}"]`);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedCount = this.getCachedCount(video.id);
|
||||
if (typeof cachedCount === "number") {
|
||||
this.updateDiscussionCountElement(container, cachedCount);
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = this.buildDiscussionCountFilters(video);
|
||||
if (!filters.length) {
|
||||
this.markDiscussionCountError(container, { unsupported: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inFlightDiscussionCounts.has(video.id)) {
|
||||
this.setDiscussionCountPending(container);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setDiscussionCountPending(container);
|
||||
|
||||
const request = Promise.resolve()
|
||||
.then(() => nostrClient.countEventsAcrossRelays(filters))
|
||||
.then((result) => {
|
||||
const perRelay = Array.isArray(result?.perRelay)
|
||||
? result.perRelay.filter((entry) => entry && entry.ok)
|
||||
: [];
|
||||
|
||||
if (!perRelay.length) {
|
||||
this.markDiscussionCountError(container, { unsupported: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
const total = Number(result?.total);
|
||||
const normalized =
|
||||
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
|
||||
this.videoDiscussionCountCache.set(video.id, normalized);
|
||||
this.updateDiscussionCountElement(container, normalized);
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode && this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
`[counts] Failed to fetch discussion count for ${video.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
this.markDiscussionCountError(container);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlightDiscussionCounts.delete(video.id);
|
||||
});
|
||||
|
||||
this.inFlightDiscussionCounts.set(video.id, request);
|
||||
});
|
||||
}
|
||||
|
||||
buildDiscussionCountFilters(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filters = [];
|
||||
const eventId = typeof video.id === "string" ? video.id.trim() : "";
|
||||
if (eventId) {
|
||||
filters.push({ kinds: [1], "#e": [eventId] });
|
||||
}
|
||||
|
||||
const address = this.getVideoAddressPointer(video);
|
||||
if (address) {
|
||||
filters.push({ kinds: [1], "#a": [address] });
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
getVideoAddressPointer(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||||
const dTag = tags.find(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag.length >= 2 &&
|
||||
tag[0] === "d" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].trim()
|
||||
);
|
||||
|
||||
if (!dTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
if (!pubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const identifier = dTag[1].trim();
|
||||
if (!identifier) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const kind =
|
||||
Number.isFinite(video.kind) && video.kind > 0
|
||||
? Math.floor(video.kind)
|
||||
: 30078;
|
||||
|
||||
return `${kind}:${pubkey}:${identifier}`;
|
||||
}
|
||||
|
||||
setDiscussionCountPending(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.countState = "pending";
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (valueEl) {
|
||||
valueEl.textContent = "…";
|
||||
}
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
updateDiscussionCountElement(element, count) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (!valueEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = Number(count);
|
||||
const safeValue =
|
||||
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||||
element.dataset.countState = "ready";
|
||||
valueEl.textContent = safeValue.toLocaleString();
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
markDiscussionCountError(element, { unsupported = false } = {}) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||||
if (valueEl) {
|
||||
valueEl.textContent = "—";
|
||||
}
|
||||
|
||||
element.dataset.countState = unsupported ? "unsupported" : "error";
|
||||
|
||||
if (unsupported) {
|
||||
element.title = COUNT_UNSUPPORTED_TITLE;
|
||||
} else {
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { COUNT_UNSUPPORTED_TITLE };
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo,
|
||||
} from "../nostr.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
getVideoSubscription as getStoredVideoSubscription,
|
||||
setVideoSubscription as setStoredVideoSubscription,
|
||||
} from "../state/appState.js";
|
||||
|
||||
const VIDEO_KIND = 30078;
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
try {
|
||||
this.logger(`nostrService listener for "${eventName}" threw`, error);
|
||||
} catch (logError) {
|
||||
console.warn("[nostrService] listener logger threw", logError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLogger(logger) {
|
||||
if (typeof logger === "function") {
|
||||
return logger;
|
||||
}
|
||||
if (logger && typeof logger.log === "function") {
|
||||
return (...args) => logger.log(...args);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function ensureSet(value) {
|
||||
if (value instanceof Set) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return new Set(value);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeUntil(timestamp) {
|
||||
const value = Number(timestamp);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const seconds = Math.floor(value);
|
||||
return seconds > 0 ? seconds : 0;
|
||||
}
|
||||
|
||||
export class NostrService {
|
||||
constructor({ logger } = {}) {
|
||||
this.nostrClient = nostrClient;
|
||||
this.accessControl = accessControl;
|
||||
this.logger = normalizeLogger(logger);
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.logger(message, error);
|
||||
} catch (logError) {
|
||||
console.warn("[nostrService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
this.videosMap = null;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] logger threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
ensureVideosMap() {
|
||||
if (this.videosMap instanceof Map) {
|
||||
return this.videosMap;
|
||||
}
|
||||
|
||||
let map = getStoredVideosMap();
|
||||
if (!(map instanceof Map)) {
|
||||
map = new Map();
|
||||
setStoredVideosMap(map);
|
||||
}
|
||||
|
||||
this.videosMap = map;
|
||||
return this.videosMap;
|
||||
}
|
||||
|
||||
getVideosMap() {
|
||||
return this.ensureVideosMap();
|
||||
}
|
||||
|
||||
getVideoSubscription() {
|
||||
return getStoredVideoSubscription();
|
||||
}
|
||||
|
||||
setVideoSubscription(subscription) {
|
||||
setStoredVideoSubscription(subscription || null);
|
||||
this.emit("subscription:changed", { subscription: subscription || null });
|
||||
}
|
||||
|
||||
clearVideoSubscription() {
|
||||
const current = getStoredVideoSubscription();
|
||||
if (current && typeof current.unsub === "function") {
|
||||
try {
|
||||
current.unsub();
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] Failed to unsubscribe from video feed:", error);
|
||||
}
|
||||
}
|
||||
this.setVideoSubscription(null);
|
||||
}
|
||||
|
||||
cacheVideos(videos = []) {
|
||||
if (!Array.isArray(videos) || !videos.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = this.ensureVideosMap();
|
||||
for (const video of videos) {
|
||||
if (video && typeof video.id === "string" && video.id) {
|
||||
map.set(video.id, video);
|
||||
}
|
||||
}
|
||||
setStoredVideosMap(map);
|
||||
this.emit("videos:cache", { size: map.size });
|
||||
}
|
||||
|
||||
shouldIncludeVideo(video, {
|
||||
blacklistedEventIds = new Set(),
|
||||
isAuthorBlocked = () => false,
|
||||
} = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blacklistedEventIds.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof video.pubkey === "string" && video.pubkey) {
|
||||
try {
|
||||
if (isAuthorBlocked(video.pubkey)) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] isAuthorBlocked handler threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (video.isPrivate === true) {
|
||||
const normalizedVideoPubkey = normalizeHexPubkey(video.pubkey);
|
||||
const normalizedViewerPubkey = normalizeHexPubkey(
|
||||
this.nostrClient?.pubkey
|
||||
);
|
||||
|
||||
if (
|
||||
!normalizedViewerPubkey ||
|
||||
!normalizedVideoPubkey ||
|
||||
normalizedVideoPubkey !== normalizedViewerPubkey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.accessControl && typeof this.accessControl.canAccess === "function") {
|
||||
try {
|
||||
if (!this.accessControl.canAccess(video)) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] access control check failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
filterVideos(videos = [], options = {}) {
|
||||
const blacklist = ensureSet(options.blacklistedEventIds);
|
||||
const isAuthorBlocked =
|
||||
typeof options.isAuthorBlocked === "function"
|
||||
? options.isAuthorBlocked
|
||||
: () => false;
|
||||
|
||||
return videos.filter((video) =>
|
||||
this.shouldIncludeVideo(video, {
|
||||
blacklistedEventIds: blacklist,
|
||||
isAuthorBlocked,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async ensureAccessControlReady() {
|
||||
if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[nostrService] Failed to ensure access control lists are ready:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredActiveVideos(options = {}) {
|
||||
const all = this.nostrClient.getActiveVideos();
|
||||
return this.filterVideos(all, options);
|
||||
}
|
||||
|
||||
async loadVideos({
|
||||
forceFetch = false,
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
onVideos,
|
||||
} = {}) {
|
||||
await this.ensureAccessControlReady();
|
||||
|
||||
if (forceFetch) {
|
||||
this.clearVideoSubscription();
|
||||
}
|
||||
|
||||
const applyAndNotify = (videos, reason) => {
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
this.cacheVideos(filtered);
|
||||
if (typeof onVideos === "function") {
|
||||
try {
|
||||
onVideos(filtered, { reason });
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] onVideos handler threw", error);
|
||||
}
|
||||
}
|
||||
this.emit("videos:updated", { videos: filtered, reason });
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const cached = this.nostrClient.getActiveVideos();
|
||||
const initial = applyAndNotify(cached, "cache");
|
||||
|
||||
if (!getStoredVideoSubscription()) {
|
||||
const subscription = this.nostrClient.subscribeVideos(() => {
|
||||
const updated = this.nostrClient.getActiveVideos();
|
||||
applyAndNotify(updated, "subscription");
|
||||
});
|
||||
this.setVideoSubscription(subscription);
|
||||
this.emit("subscription:started", { subscription });
|
||||
}
|
||||
|
||||
return initial;
|
||||
}
|
||||
|
||||
async fetchVideos(options = {}) {
|
||||
try {
|
||||
const videos = await this.nostrClient.fetchVideos();
|
||||
const filtered = this.filterVideos(videos, options);
|
||||
this.cacheVideos(filtered);
|
||||
this.emit("videos:fetched", { videos: filtered });
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
console.error("[nostrService] Failed to fetch videos:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async loadOlderVideos(lastTimestamp, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
limit = 150,
|
||||
} = {}) {
|
||||
const until = normalizeUntil(lastTimestamp) - 1;
|
||||
if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [VIDEO_KIND],
|
||||
"#t": ["video"],
|
||||
until,
|
||||
limit,
|
||||
};
|
||||
|
||||
const collected = new Map();
|
||||
|
||||
try {
|
||||
const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]);
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
try {
|
||||
const video = convertEventToVideo(event);
|
||||
if (video.invalid) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
this.nostrClient &&
|
||||
typeof this.nostrClient.applyRootCreatedAt === "function"
|
||||
) {
|
||||
this.nostrClient.applyRootCreatedAt(video);
|
||||
}
|
||||
if (collected.has(video.id)) {
|
||||
continue;
|
||||
}
|
||||
collected.set(video.id, video);
|
||||
this.nostrClient.allEvents.set(video.id, video);
|
||||
} catch (error) {
|
||||
console.warn("[nostrService] Failed to convert older event", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[nostrService] Failed to load older videos:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const videos = Array.from(collected.values());
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
|
||||
this.cacheVideos(filtered);
|
||||
this.emit("videos:older", { videos: filtered, until });
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async publishVideoNote(publishPayload, pubkey) {
|
||||
const detail = { pubkey };
|
||||
if (publishPayload && typeof publishPayload === "object") {
|
||||
detail.payload = publishPayload;
|
||||
if (publishPayload.legacyFormData) {
|
||||
detail.legacyFormData = publishPayload.legacyFormData;
|
||||
}
|
||||
if (publishPayload.nip71) {
|
||||
detail.nip71 = publishPayload.nip71;
|
||||
}
|
||||
} else {
|
||||
detail.formData = publishPayload;
|
||||
}
|
||||
|
||||
let nip71Result = null;
|
||||
let legacyResult = null;
|
||||
|
||||
try {
|
||||
legacyResult = await this.nostrClient.publishVideo(
|
||||
publishPayload,
|
||||
pubkey
|
||||
);
|
||||
} catch (error) {
|
||||
detail.error = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let shouldAttemptNip71 = true;
|
||||
const pointerIdentifiers = {
|
||||
eventId: legacyResult?.id,
|
||||
};
|
||||
|
||||
if (Array.isArray(legacyResult?.tags)) {
|
||||
const dTag = legacyResult.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string"
|
||||
);
|
||||
if (dTag) {
|
||||
pointerIdentifiers.dTag = dTag[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyResult?.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(legacyResult.content);
|
||||
if (parsed && typeof parsed.videoRootId === "string") {
|
||||
pointerIdentifiers.videoRootId = parsed.videoRootId;
|
||||
}
|
||||
if (parsed && parsed.isPrivate) {
|
||||
shouldAttemptNip71 = false;
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.log("[nostrService] Failed to parse legacy publish payload", parseError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pointerIdentifiers.videoRootId) {
|
||||
shouldAttemptNip71 = false;
|
||||
}
|
||||
|
||||
if (shouldAttemptNip71) {
|
||||
try {
|
||||
nip71Result = await this.nostrClient.publishNip71Video(
|
||||
publishPayload,
|
||||
pubkey,
|
||||
pointerIdentifiers
|
||||
);
|
||||
} catch (nip71Error) {
|
||||
detail.nip71Error = nip71Error;
|
||||
this.log("[nostrService] NIP-71 publish failed", nip71Error);
|
||||
}
|
||||
}
|
||||
|
||||
const result = { legacy: legacyResult, nip71: nip71Result };
|
||||
detail.result = result;
|
||||
this.emit("videos:published", detail);
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) {
|
||||
const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey);
|
||||
this.emit("videos:edited", { originalEvent, updatedData, pubkey, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleFullDeleteVideo({ videoRootId, pubkey, confirm = true } = {}) {
|
||||
const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, {
|
||||
confirm,
|
||||
});
|
||||
if (result) {
|
||||
this.emit("videos:deleted", { videoRootId, pubkey });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getOldEventById(eventId) {
|
||||
const map = this.ensureVideosMap();
|
||||
if (map.has(eventId)) {
|
||||
return map.get(eventId);
|
||||
}
|
||||
|
||||
const cached = this.nostrClient.allEvents.get(eventId);
|
||||
if (cached && !cached.deleted) {
|
||||
map.set(eventId, cached);
|
||||
setStoredVideosMap(map);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fetched = await this.nostrClient.getEventById(eventId);
|
||||
if (fetched && !fetched.deleted) {
|
||||
map.set(eventId, fetched);
|
||||
setStoredVideosMap(map);
|
||||
return fetched;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrService = new NostrService();
|
||||
|
||||
export default nostrService;
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
loadNwcSettings,
|
||||
saveNwcSettings,
|
||||
clearNwcSettings,
|
||||
createDefaultNwcSettings,
|
||||
} from "../nwcSettings.js";
|
||||
import { MAX_WALLET_DEFAULT_ZAP } from "../config.js";
|
||||
|
||||
const NWC_URI_SCHEME = "nostr+walletconnect://";
|
||||
|
||||
function isFunction(value) {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
export default class NwcSettingsService {
|
||||
constructor({
|
||||
normalizeHexPubkey,
|
||||
getActivePubkey,
|
||||
payments = null,
|
||||
logger = console,
|
||||
notifyError,
|
||||
maxWalletDefaultZap = MAX_WALLET_DEFAULT_ZAP,
|
||||
loadSettings = loadNwcSettings,
|
||||
saveSettings = saveNwcSettings,
|
||||
clearSettings = clearNwcSettings,
|
||||
createDefaultSettings = createDefaultNwcSettings,
|
||||
} = {}) {
|
||||
this.normalizeHexPubkey = isFunction(normalizeHexPubkey)
|
||||
? normalizeHexPubkey
|
||||
: (value) => (typeof value === "string" ? value.trim() : null);
|
||||
this.getActivePubkey = isFunction(getActivePubkey)
|
||||
? getActivePubkey
|
||||
: () => null;
|
||||
this.payments = payments || null;
|
||||
this.logger = logger || console;
|
||||
this.notifyError = isFunction(notifyError) ? notifyError : null;
|
||||
this.maxWalletDefaultZap =
|
||||
Number.isFinite(maxWalletDefaultZap) && maxWalletDefaultZap > 0
|
||||
? maxWalletDefaultZap
|
||||
: MAX_WALLET_DEFAULT_ZAP;
|
||||
this.loadSettings = isFunction(loadSettings)
|
||||
? loadSettings
|
||||
: loadNwcSettings;
|
||||
this.saveSettings = isFunction(saveSettings)
|
||||
? saveSettings
|
||||
: saveNwcSettings;
|
||||
this.clearSettings = isFunction(clearSettings)
|
||||
? clearSettings
|
||||
: clearNwcSettings;
|
||||
this.createDefaultSettings = isFunction(createDefaultSettings)
|
||||
? createDefaultSettings
|
||||
: () => createDefaultNwcSettings();
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
cloneSettings(settings) {
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
setPayments(payments) {
|
||||
this.payments = payments || null;
|
||||
}
|
||||
|
||||
setActivePubkeyGetter(fn) {
|
||||
if (isFunction(fn)) {
|
||||
this.getActivePubkey = fn;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
getNormalizedPubkey(pubkey) {
|
||||
try {
|
||||
return this.normalizeHexPubkey(pubkey);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn("[nwcSettings] Failed to normalize pubkey:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateNwcSettingsForPubkey(pubkey) {
|
||||
const normalized = this.getNormalizedPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.loadSettings(normalized);
|
||||
const record =
|
||||
settings && typeof settings === "object"
|
||||
? { ...settings }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to load settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const fallback = this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, fallback);
|
||||
return this.cloneSettings(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveNwcSettings() {
|
||||
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
|
||||
if (!normalized) {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
createDefaultNwcSettings() {
|
||||
return this.createDefaultSettings();
|
||||
}
|
||||
|
||||
hasActiveWalletConnection() {
|
||||
const settings = this.getActiveNwcSettings();
|
||||
const candidate =
|
||||
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
||||
return candidate.length > 0;
|
||||
}
|
||||
|
||||
validateWalletUri(uri, { requireValue = false } = {}) {
|
||||
const value = typeof uri === "string" ? uri.trim() : "";
|
||||
|
||||
if (!value) {
|
||||
if (requireValue) {
|
||||
return {
|
||||
valid: false,
|
||||
sanitized: "",
|
||||
message: "Enter a wallet connect URI before continuing.",
|
||||
};
|
||||
}
|
||||
return { valid: true, sanitized: "" };
|
||||
}
|
||||
|
||||
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
|
||||
return {
|
||||
valid: false,
|
||||
sanitized: value,
|
||||
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, sanitized: value };
|
||||
}
|
||||
|
||||
async updateActiveNwcSettings(partial = {}) {
|
||||
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
|
||||
if (!normalized) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
"[nwcSettings] Cannot update settings without an active pubkey.",
|
||||
);
|
||||
}
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await this.saveSettings(normalized, partial);
|
||||
const record =
|
||||
updated && typeof updated === "object"
|
||||
? { ...updated }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to save settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async handleProfileWalletPersist({
|
||||
nwcUri,
|
||||
defaultZap,
|
||||
lastChecked,
|
||||
} = {}) {
|
||||
const partial = {};
|
||||
if (nwcUri !== undefined) {
|
||||
partial.nwcUri = nwcUri;
|
||||
}
|
||||
if (defaultZap !== undefined) {
|
||||
partial.defaultZap = defaultZap;
|
||||
}
|
||||
if (lastChecked !== undefined) {
|
||||
partial.lastChecked = lastChecked;
|
||||
}
|
||||
|
||||
if (!Object.keys(partial).length) {
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
|
||||
return this.updateActiveNwcSettings(partial);
|
||||
}
|
||||
|
||||
async ensureWallet({ nwcUri, defaultZap } = {}) {
|
||||
const activeSettings = this.getActiveNwcSettings();
|
||||
const candidateUri =
|
||||
typeof nwcUri === "string" && nwcUri.trim()
|
||||
? nwcUri.trim()
|
||||
: activeSettings.nwcUri || "";
|
||||
const { valid, sanitized, message } = this.validateWalletUri(candidateUri, {
|
||||
requireValue: true,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new Error(message || "Invalid wallet URI provided.");
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...activeSettings,
|
||||
nwcUri: sanitized,
|
||||
};
|
||||
|
||||
if (typeof defaultZap === "number" && Number.isFinite(defaultZap)) {
|
||||
const rounded = Math.max(0, Math.round(defaultZap));
|
||||
merged.defaultZap = Math.min(this.maxWalletDefaultZap, rounded);
|
||||
} else if (defaultZap === null) {
|
||||
merged.defaultZap = null;
|
||||
}
|
||||
|
||||
if (this.payments && isFunction(this.payments.ensureWallet)) {
|
||||
return this.payments.ensureWallet({ settings: merged });
|
||||
}
|
||||
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
"[wallet] Falling back to stub ensureWallet implementation. Returning settings without performing a connection test.",
|
||||
);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async clearStoredNwcSettings(pubkey, { silent = false } = {}) {
|
||||
const normalized = this.getNormalizedPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.clearSettings(normalized);
|
||||
} catch (error) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to clear settings for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!silent && this.notifyError) {
|
||||
this.notifyError("Failed to clear wallet settings for this account.");
|
||||
}
|
||||
try {
|
||||
await this.saveSettings(normalized, this.createDefaultNwcSettings());
|
||||
} catch (persistError) {
|
||||
if (this.logger && isFunction(this.logger.warn)) {
|
||||
this.logger.warn(
|
||||
`[nwcSettings] Failed to overwrite settings for ${normalized}:`,
|
||||
persistError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.cache.delete(normalized);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cache.delete(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
async onLogin({ pubkey, previousPubkey, identityChanged } = {}) {
|
||||
if (identityChanged) {
|
||||
this.onIdentityChanged();
|
||||
}
|
||||
|
||||
const normalizedActive = this.getNormalizedPubkey(
|
||||
pubkey !== undefined ? pubkey : this.getActivePubkey(),
|
||||
);
|
||||
const normalizedPrevious = this.getNormalizedPubkey(previousPubkey);
|
||||
|
||||
if (
|
||||
normalizedPrevious &&
|
||||
(!normalizedActive || normalizedPrevious !== normalizedActive)
|
||||
) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: true });
|
||||
}
|
||||
|
||||
if (normalizedActive) {
|
||||
await this.hydrateNwcSettingsForPubkey(normalizedActive);
|
||||
}
|
||||
|
||||
return this.getActiveNwcSettings();
|
||||
}
|
||||
|
||||
async onLogout({ pubkey, previousPubkey } = {}) {
|
||||
const normalizedPrevious = this.getNormalizedPubkey(
|
||||
previousPubkey !== undefined ? previousPubkey : pubkey,
|
||||
);
|
||||
if (normalizedPrevious) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: false });
|
||||
}
|
||||
this.clearCache();
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
onIdentityChanged() {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
// js/services/playbackService.js
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (err) {
|
||||
if (this.logger) {
|
||||
this.logger("[PlaybackService] Listener error", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
|
||||
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
|
||||
"This magnet link is missing a compatible BitTorrent v1 info hash.";
|
||||
|
||||
export class PlaybackService {
|
||||
constructor({
|
||||
logger,
|
||||
torrentClient,
|
||||
deriveTorrentPlaybackConfig,
|
||||
isValidMagnetUri,
|
||||
urlFirstEnabled = true,
|
||||
analyticsCallbacks = {},
|
||||
} = {}) {
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
} else if (logger && typeof logger.log === "function") {
|
||||
this.logger = (...args) => logger.log(...args);
|
||||
} else {
|
||||
this.logger = () => {};
|
||||
}
|
||||
|
||||
this.torrentClient = torrentClient;
|
||||
this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig;
|
||||
this.isValidMagnetUri = isValidMagnetUri;
|
||||
this.urlFirstEnabled = !!urlFirstEnabled;
|
||||
this.analyticsCallbacks = analyticsCallbacks || {};
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (err) {
|
||||
console.warn("[PlaybackService] logger threw", err);
|
||||
}
|
||||
}
|
||||
|
||||
handleAnalyticsEvent(eventName, detail) {
|
||||
const callbacks = this.analyticsCallbacks || {};
|
||||
if (typeof callbacks.onEvent === "function") {
|
||||
try {
|
||||
callbacks.onEvent(eventName, detail);
|
||||
} catch (err) {
|
||||
this.log("[PlaybackService] onEvent callback threw", err);
|
||||
}
|
||||
}
|
||||
const specific = callbacks[eventName];
|
||||
if (typeof specific === "function") {
|
||||
try {
|
||||
specific(detail);
|
||||
} catch (err) {
|
||||
this.log(`[PlaybackService] ${eventName} callback threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareVideoElement(videoElement) {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||||
const userWantsUnmuted = storedUnmuted === "true";
|
||||
videoElement.muted = !userWantsUnmuted;
|
||||
|
||||
if (!videoElement.dataset.autoplayBound) {
|
||||
videoElement.addEventListener("volumechange", () => {
|
||||
localStorage.setItem(
|
||||
"unmutedAutoplay",
|
||||
(!videoElement.muted).toString()
|
||||
);
|
||||
});
|
||||
videoElement.dataset.autoplayBound = "true";
|
||||
}
|
||||
}
|
||||
|
||||
registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
{ stallMs = 8000, onSuccess, onFallback } = {}
|
||||
) {
|
||||
if (!videoElement || typeof onFallback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
|
||||
let active = true;
|
||||
let stallTimerId = null;
|
||||
const listeners = [];
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
for (const [eventName, handler] of listeners) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFallback = (reason) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
cleanup();
|
||||
onFallback(reason);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
|
||||
);
|
||||
cleanup();
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
if (!active || !normalizedStallMs) {
|
||||
return;
|
||||
}
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
}
|
||||
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
|
||||
};
|
||||
|
||||
const addListener = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
listeners.push([eventName, handler]);
|
||||
};
|
||||
|
||||
addListener("error", () => triggerFallback("error"));
|
||||
addListener("abort", () => triggerFallback("abort"));
|
||||
addListener("stalled", () => triggerFallback("stalled"));
|
||||
addListener("playing", handleSuccess);
|
||||
addListener("ended", handleSuccess);
|
||||
|
||||
const timerEvents = [
|
||||
"timeupdate",
|
||||
"progress",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"suspend",
|
||||
"waiting",
|
||||
];
|
||||
for (const eventName of timerEvents) {
|
||||
addListener(eventName, resetTimer);
|
||||
}
|
||||
|
||||
if (normalizedStallMs) {
|
||||
resetTimer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.cleanupWatchdog();
|
||||
}
|
||||
}
|
||||
|
||||
createSession(options = {}) {
|
||||
const session = new PlaybackSession(this, options);
|
||||
this.currentSession = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
class PlaybackSession extends SimpleEventEmitter {
|
||||
constructor(service, options = {}) {
|
||||
super((message, ...args) => service.log(message, ...args));
|
||||
this.service = service;
|
||||
this.options = options;
|
||||
this.watchdogCleanup = null;
|
||||
this.result = { source: null };
|
||||
this.finished = false;
|
||||
this.startPromise = null;
|
||||
|
||||
this.sanitizedUrl =
|
||||
typeof options.url === "string" ? options.url.trim() : "";
|
||||
this.trimmedMagnet =
|
||||
typeof options.magnet === "string" ? options.magnet.trim() : "";
|
||||
this.requestSignature =
|
||||
typeof options.requestSignature === "string"
|
||||
? options.requestSignature
|
||||
: JSON.stringify({
|
||||
url: this.sanitizedUrl,
|
||||
magnet: this.trimmedMagnet,
|
||||
});
|
||||
|
||||
if (typeof service.deriveTorrentPlaybackConfig === "function") {
|
||||
this.playbackConfig = service.deriveTorrentPlaybackConfig({
|
||||
magnet: this.trimmedMagnet,
|
||||
url: this.sanitizedUrl,
|
||||
logger: (message) => service.log(message),
|
||||
appProtocol: options.appProtocol,
|
||||
});
|
||||
} else {
|
||||
this.playbackConfig = {
|
||||
magnet: this.trimmedMagnet,
|
||||
fallbackMagnet: "",
|
||||
provided: !!this.trimmedMagnet,
|
||||
usedInfoHash: false,
|
||||
originalInput: this.trimmedMagnet,
|
||||
didMutate: false,
|
||||
infoHash: "",
|
||||
};
|
||||
}
|
||||
|
||||
const magnetIsUsable =
|
||||
typeof service.isValidMagnetUri === "function"
|
||||
? service.isValidMagnetUri(this.playbackConfig.magnet)
|
||||
: false;
|
||||
|
||||
this.magnetForPlayback = magnetIsUsable
|
||||
? this.playbackConfig.magnet
|
||||
: "";
|
||||
this.fallbackMagnet = magnetIsUsable
|
||||
? this.playbackConfig.fallbackMagnet
|
||||
: "";
|
||||
this.magnetProvided = !!this.playbackConfig.provided;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return !this.finished;
|
||||
}
|
||||
|
||||
matchesRequestSignature(signature) {
|
||||
if (!this.isActive()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof signature !== "string" || !signature) {
|
||||
return false;
|
||||
}
|
||||
return signature === this.requestSignature;
|
||||
}
|
||||
|
||||
getResult() {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
getPlaybackConfig() {
|
||||
return this.playbackConfig;
|
||||
}
|
||||
|
||||
getMagnetForPlayback() {
|
||||
return this.magnetForPlayback;
|
||||
}
|
||||
|
||||
getFallbackMagnet() {
|
||||
return this.fallbackMagnet;
|
||||
}
|
||||
|
||||
getMagnetProvided() {
|
||||
return this.magnetProvided;
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (typeof this.watchdogCleanup === "function") {
|
||||
try {
|
||||
this.watchdogCleanup();
|
||||
} catch (err) {
|
||||
this.service.log("[PlaybackSession] Watchdog cleanup threw", err);
|
||||
} finally {
|
||||
this.watchdogCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerWatchdogs(videoElement, options) {
|
||||
this.cleanupWatchdog();
|
||||
this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
attachDebugListeners(videoElement) {
|
||||
if (!videoElement) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const debugEvents = [
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"play",
|
||||
"playing",
|
||||
"pause",
|
||||
"stalled",
|
||||
"suspend",
|
||||
"waiting",
|
||||
"ended",
|
||||
"error",
|
||||
];
|
||||
|
||||
const handlers = [];
|
||||
for (const eventName of debugEvents) {
|
||||
const handler = () => {
|
||||
const { readyState, networkState, currentTime, paused, error } =
|
||||
videoElement;
|
||||
let suffix =
|
||||
`readyState=${readyState} networkState=${networkState} currentTime=${
|
||||
Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime
|
||||
} paused=${paused}`;
|
||||
if (eventName === "error" && error) {
|
||||
const errorMessage = error.message || "";
|
||||
suffix += ` code=${error.code || ""} message=${errorMessage}`;
|
||||
}
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
|
||||
);
|
||||
};
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
handlers.push([eventName, handler]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!handlers.length) {
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler] of handlers) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
handlers.length = 0;
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
this.startPromise = this.execute();
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const {
|
||||
videoElement,
|
||||
waitForCleanup,
|
||||
cancelPendingViewLogging,
|
||||
clearActiveIntervals,
|
||||
showModalWithPoster,
|
||||
teardownVideoElement,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
autoplay,
|
||||
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
|
||||
} = this.options;
|
||||
|
||||
this.result = { source: null };
|
||||
this.finished = false;
|
||||
|
||||
const detail = {
|
||||
urlProvided: !!this.sanitizedUrl,
|
||||
magnetProvided: this.magnetProvided,
|
||||
magnetUsable: !!this.magnetForPlayback,
|
||||
};
|
||||
this.service.handleAnalyticsEvent("session-start", detail);
|
||||
this.emit("session-start", detail);
|
||||
|
||||
try {
|
||||
if (typeof waitForCleanup === "function") {
|
||||
await waitForCleanup();
|
||||
}
|
||||
if (typeof cancelPendingViewLogging === "function") {
|
||||
cancelPendingViewLogging();
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
if (typeof showModalWithPoster === "function") {
|
||||
await Promise.resolve(showModalWithPoster());
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Preparing video..." });
|
||||
|
||||
if (typeof clearActiveIntervals === "function") {
|
||||
clearActiveIntervals();
|
||||
}
|
||||
|
||||
this.service.prepareVideoElement(videoElement);
|
||||
|
||||
if (
|
||||
this.service.torrentClient &&
|
||||
typeof this.service.torrentClient.cleanup === "function"
|
||||
) {
|
||||
await this.service.torrentClient.cleanup();
|
||||
}
|
||||
|
||||
const activeVideoEl =
|
||||
typeof teardownVideoElement === "function"
|
||||
? teardownVideoElement(videoElement) || videoElement
|
||||
: videoElement;
|
||||
|
||||
if (!activeVideoEl) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
this.options.videoElement = activeVideoEl;
|
||||
this.emit("video-prepared", { videoElement: activeVideoEl });
|
||||
this.emit("view-logging-request", { videoElement: activeVideoEl });
|
||||
|
||||
const cleanupDebugListeners = this.attachDebugListeners(activeVideoEl);
|
||||
let cleanupHostedUrlStatusListeners = () => {};
|
||||
|
||||
const httpsUrl = this.sanitizedUrl;
|
||||
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
|
||||
|
||||
let fallbackStarted = false;
|
||||
const startTorrentFallback = async (reason) => {
|
||||
if (fallbackStarted) {
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fallbackStarted = true;
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
|
||||
if (activeVideoEl) {
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// ignore attribute removal errors
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.magnetForPlayback) {
|
||||
const message =
|
||||
"Hosted playback failed and no magnet fallback is available.";
|
||||
this.emit("status", { message });
|
||||
this.emit("sourcechange", { source: null });
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Switching to WebTorrent..." });
|
||||
this.service.handleAnalyticsEvent("fallback", { reason });
|
||||
this.emit("fallback", { reason });
|
||||
|
||||
if (typeof playViaWebTorrent !== "function") {
|
||||
const error = new Error("No torrent playback handler provided.");
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message: error.message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
|
||||
this.emit("sourcechange", { source: "torrent" });
|
||||
if (typeof autoplay === "function") {
|
||||
autoplay();
|
||||
}
|
||||
this.result = { source: "torrent", torrentInstance };
|
||||
return torrentInstance;
|
||||
};
|
||||
|
||||
if (this.service.urlFirstEnabled && httpsUrl) {
|
||||
this.emit("status", { message: "Checking hosted URL..." });
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
);
|
||||
|
||||
let hostedStatusResolved = false;
|
||||
const hostedStatusHandlers = [];
|
||||
const addHostedStatusListener = (eventName, handler, options) => {
|
||||
if (!activeVideoEl) {
|
||||
return;
|
||||
}
|
||||
activeVideoEl.addEventListener(eventName, handler, options);
|
||||
hostedStatusHandlers.push([eventName, handler, options]);
|
||||
};
|
||||
const markHostedUrlAsLive = () => {
|
||||
if (hostedStatusResolved) {
|
||||
return;
|
||||
}
|
||||
hostedStatusResolved = true;
|
||||
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
};
|
||||
const maybeMarkHostedUrl = () => {
|
||||
if (
|
||||
hostedStatusResolved ||
|
||||
!activeVideoEl ||
|
||||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
|
||||
markHostedUrlAsLive();
|
||||
}
|
||||
};
|
||||
cleanupHostedUrlStatusListeners = () => {
|
||||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler, options] of hostedStatusHandlers) {
|
||||
activeVideoEl.removeEventListener(eventName, handler, options);
|
||||
}
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
};
|
||||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||||
addHostedStatusListener(
|
||||
"error",
|
||||
() => {
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const probeResult =
|
||||
typeof probeUrl === "function" ? await probeUrl(httpsUrl) : null;
|
||||
const probeOutcome = probeResult?.outcome || "error";
|
||||
const shouldAttemptHosted =
|
||||
probeOutcome !== "bad" && probeOutcome !== "error";
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
|
||||
);
|
||||
|
||||
if (shouldAttemptHosted) {
|
||||
let outcomeResolved = false;
|
||||
let outcomeResolver = () => {};
|
||||
let autoplayBlocked = false;
|
||||
|
||||
const playbackOutcomePromise = new Promise((resolve) => {
|
||||
outcomeResolver = (value) => {
|
||||
if (outcomeResolved) {
|
||||
return;
|
||||
}
|
||||
outcomeResolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||||
onFallback: (reason) => {
|
||||
if (autoplayBlocked && reason === "stall") {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.emit("autoplay-blocked", { reason });
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
err
|
||||
);
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
try {
|
||||
activeVideoEl.src = httpsUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
autoplayBlocked = true;
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||||
err
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.cleanupWatchdog();
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
const restoreOnPlay = () => {
|
||||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||||
this.cleanupWatchdog();
|
||||
autoplayBlocked = false;
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
};
|
||||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFatalPlaybackError(err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
const playbackOutcome = await playbackOutcomePromise;
|
||||
if (playbackOutcome?.status === "success") {
|
||||
this.emit("poster-remove", { reason: "http-success" });
|
||||
this.service.handleAnalyticsEvent("sourcechange", {
|
||||
source: "url",
|
||||
});
|
||||
this.emit("sourcechange", { source: "url" });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
this.result = { source: "url" };
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const fallbackReason =
|
||||
playbackOutcome?.reason || "watchdog-triggered";
|
||||
const torrentInstance = await startTorrentFallback(fallbackReason);
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||||
);
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
}
|
||||
|
||||
if (this.magnetForPlayback) {
|
||||
const torrentInstance = await startTorrentFallback("magnet-primary");
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const message = this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
this.emit("status", { message });
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: null });
|
||||
this.emit("sourcechange", { source: null });
|
||||
cleanupDebugListeners();
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} catch (error) {
|
||||
this.service.log("Error in playVideoWithFallback:", error);
|
||||
const message = error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} finally {
|
||||
this.cleanupWatchdog();
|
||||
this.finished = true;
|
||||
if (this.result) {
|
||||
this.emit("finished", this.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaybackService;
|
||||
@@ -0,0 +1,821 @@
|
||||
import {
|
||||
loadR2Settings,
|
||||
saveR2Settings,
|
||||
clearR2Settings,
|
||||
buildR2Key,
|
||||
buildPublicUrl,
|
||||
mergeBucketEntry,
|
||||
sanitizeBaseDomain,
|
||||
} from "../r2.js";
|
||||
import {
|
||||
sanitizeBucketName,
|
||||
ensureBucket,
|
||||
putCors,
|
||||
attachCustomDomainAndWait,
|
||||
setManagedDomain,
|
||||
deriveShortSubdomain,
|
||||
} from "../storage/r2-mgmt.js";
|
||||
import {
|
||||
makeR2Client,
|
||||
multipartUpload,
|
||||
ensureBucketCors,
|
||||
} from "../storage/r2-s3.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
accountId: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiToken: "",
|
||||
zoneId: "",
|
||||
baseDomain: "",
|
||||
buckets: {},
|
||||
};
|
||||
}
|
||||
|
||||
class R2Service {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.cloudflareSettings = null;
|
||||
this.cloudflareAdvancedVisible = false;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(event);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(event, detail) {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (err) {
|
||||
console.error("[r2Service] Listener error for", event, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
return this.cloudflareSettings || createDefaultSettings();
|
||||
}
|
||||
|
||||
setSettings(settings) {
|
||||
this.cloudflareSettings = settings ? { ...settings } : null;
|
||||
this.emit("settingsChanged", { settings: this.getSettings() });
|
||||
}
|
||||
|
||||
getCloudflareAdvancedVisibility() {
|
||||
return Boolean(this.cloudflareAdvancedVisible);
|
||||
}
|
||||
|
||||
setCloudflareAdvancedVisibility(visible) {
|
||||
const isVisible = Boolean(visible);
|
||||
if (this.cloudflareAdvancedVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
this.cloudflareAdvancedVisible = isVisible;
|
||||
this.emit("advancedVisibilityChange", { visible: isVisible });
|
||||
}
|
||||
|
||||
normalizeStatusVariant(variant) {
|
||||
if (STATUS_VARIANTS.has(variant)) {
|
||||
return variant;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
setCloudflareSettingsStatus(message = "", variant = "info") {
|
||||
this.emit("settingsStatus", {
|
||||
message: message || "",
|
||||
variant: this.normalizeStatusVariant(variant),
|
||||
});
|
||||
}
|
||||
|
||||
setCloudflareUploadStatus(message = "", variant = "info") {
|
||||
this.emit("uploadStatus", {
|
||||
message: message || "",
|
||||
variant: this.normalizeStatusVariant(variant),
|
||||
});
|
||||
}
|
||||
|
||||
setCloudflareUploading(isUploading) {
|
||||
this.emit("uploadStateChange", { isUploading: Boolean(isUploading) });
|
||||
}
|
||||
|
||||
updateCloudflareProgress(fraction) {
|
||||
this.emit("uploadProgress", { fraction });
|
||||
}
|
||||
|
||||
buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) {
|
||||
const base =
|
||||
existingMetadata && typeof existingMetadata === "object"
|
||||
? { ...existingMetadata }
|
||||
: {};
|
||||
|
||||
const cloneImetaVariant = (variant) => {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return {
|
||||
m: "",
|
||||
dim: "",
|
||||
url: "",
|
||||
x: "",
|
||||
image: [],
|
||||
fallback: [],
|
||||
service: [],
|
||||
autoGenerated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...variant,
|
||||
image: Array.isArray(variant.image) ? [...variant.image] : [],
|
||||
fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [],
|
||||
service: Array.isArray(variant.service) ? [...variant.service] : [],
|
||||
};
|
||||
};
|
||||
|
||||
const imetaList = Array.isArray(base.imeta)
|
||||
? base.imeta.map((variant) => cloneImetaVariant(variant))
|
||||
: [];
|
||||
|
||||
if (!imetaList.length) {
|
||||
imetaList.push({
|
||||
m: "",
|
||||
dim: "",
|
||||
url: "",
|
||||
x: "",
|
||||
image: [],
|
||||
fallback: [],
|
||||
service: [],
|
||||
autoGenerated: true,
|
||||
});
|
||||
} else {
|
||||
const primary = imetaList[0];
|
||||
if (!Array.isArray(primary.image)) primary.image = [];
|
||||
if (!Array.isArray(primary.fallback)) primary.fallback = [];
|
||||
if (!Array.isArray(primary.service)) primary.service = [];
|
||||
}
|
||||
|
||||
const primaryVariant = imetaList[0];
|
||||
const normalizedUrl =
|
||||
typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim();
|
||||
if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) {
|
||||
primaryVariant.url = normalizedUrl;
|
||||
}
|
||||
|
||||
const mimeType =
|
||||
file && typeof file.type === "string" ? file.type.trim() : "";
|
||||
if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) {
|
||||
primaryVariant.m = mimeType;
|
||||
}
|
||||
|
||||
base.imeta = imetaList;
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
populateCloudflareSettingsInputs(settings) {
|
||||
const data = {
|
||||
accountId: settings?.accountId || "",
|
||||
accessKeyId: settings?.accessKeyId || "",
|
||||
secretAccessKey: settings?.secretAccessKey || "",
|
||||
apiToken: settings?.apiToken || "",
|
||||
zoneId: settings?.zoneId || "",
|
||||
baseDomain: settings?.baseDomain || "",
|
||||
};
|
||||
|
||||
const hasAdvancedValues = Boolean(
|
||||
(data.apiToken && data.apiToken.length > 0) ||
|
||||
(data.zoneId && data.zoneId.length > 0) ||
|
||||
(data.baseDomain && data.baseDomain.length > 0)
|
||||
);
|
||||
|
||||
if (hasAdvancedValues) {
|
||||
this.setCloudflareAdvancedVisibility(true);
|
||||
} else if (!this.cloudflareAdvancedVisible) {
|
||||
this.setCloudflareAdvancedVisibility(false);
|
||||
}
|
||||
|
||||
this.setCloudflareSettingsStatus("");
|
||||
this.emit("settingsPopulated", { settings: data });
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const settings = await loadR2Settings();
|
||||
this.setSettings(settings);
|
||||
this.populateCloudflareSettingsInputs(settings);
|
||||
return settings;
|
||||
} catch (err) {
|
||||
console.error("Failed to load Cloudflare settings:", err);
|
||||
this.setSettings(createDefaultSettings());
|
||||
this.populateCloudflareSettingsInputs(this.getSettings());
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to load saved settings.",
|
||||
"error"
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) {
|
||||
const accountId = String(formValues.accountId || "").trim();
|
||||
const accessKeyId = String(formValues.accessKeyId || "").trim();
|
||||
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
|
||||
const apiToken = String(formValues.apiToken || "").trim();
|
||||
const zoneId = String(formValues.zoneId || "").trim();
|
||||
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || "");
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Account ID, Access Key ID, and Secret are required.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let buckets = { ...(this.getSettings().buckets || {}) };
|
||||
const previousAccount = this.getSettings().accountId || "";
|
||||
const previousBaseDomain = this.getSettings().baseDomain || "";
|
||||
const previousZoneId = this.getSettings().zoneId || "";
|
||||
if (
|
||||
previousAccount !== accountId ||
|
||||
previousBaseDomain !== baseDomain ||
|
||||
previousZoneId !== zoneId
|
||||
) {
|
||||
buckets = {};
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
apiToken,
|
||||
zoneId,
|
||||
baseDomain,
|
||||
buckets,
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = await saveR2Settings(updatedSettings);
|
||||
this.setSettings(saved);
|
||||
this.populateCloudflareSettingsInputs(saved);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to save Cloudflare settings:", err);
|
||||
if (!quiet) {
|
||||
this.setCloudflareSettingsStatus(
|
||||
"Failed to save settings. Check console for details.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(formValues = {}, options = {}) {
|
||||
return this.handleCloudflareSettingsSubmit(formValues, options);
|
||||
}
|
||||
|
||||
async handleCloudflareClearSettings() {
|
||||
try {
|
||||
await clearR2Settings();
|
||||
const refreshed = await loadR2Settings();
|
||||
this.setSettings(refreshed);
|
||||
this.populateCloudflareSettingsInputs(refreshed);
|
||||
this.setCloudflareAdvancedVisibility(false);
|
||||
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to clear Cloudflare settings:", err);
|
||||
this.setCloudflareSettingsStatus("Failed to clear settings.", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clearSettings() {
|
||||
return this.handleCloudflareClearSettings();
|
||||
}
|
||||
|
||||
getCorsOrigins() {
|
||||
const origins = new Set();
|
||||
if (typeof window !== "undefined" && window.location) {
|
||||
const origin = window.location.origin;
|
||||
if (origin && origin !== "null") {
|
||||
origins.add(origin);
|
||||
}
|
||||
if (origin && origin.startsWith("http://localhost")) {
|
||||
origins.add(origin.replace("http://", "https://"));
|
||||
}
|
||||
}
|
||||
return Array.from(origins);
|
||||
}
|
||||
|
||||
deriveSubdomainForNpub(npub) {
|
||||
try {
|
||||
return deriveShortSubdomain(npub);
|
||||
} catch (err) {
|
||||
console.warn("Failed to derive short subdomain, falling back:", err);
|
||||
}
|
||||
|
||||
const base = String(npub || "user")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/^-+|[-]+$/g, "");
|
||||
return base.slice(0, 32) || "user";
|
||||
}
|
||||
|
||||
async ensureBucketConfigForNpub(npub) {
|
||||
if (!npub || !this.cloudflareSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountId = (this.cloudflareSettings.accountId || "").trim();
|
||||
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
|
||||
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim();
|
||||
const corsOrigins = this.getCorsOrigins();
|
||||
const baseDomain = this.cloudflareSettings.baseDomain || "";
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error("Cloudflare account ID is missing.");
|
||||
}
|
||||
|
||||
let entry = this.cloudflareSettings.buckets?.[npub] || null;
|
||||
|
||||
if (entry && entry.publicBaseUrl) {
|
||||
if (apiToken) {
|
||||
try {
|
||||
await ensureBucket({
|
||||
accountId,
|
||||
bucket: entry.bucket,
|
||||
token: apiToken,
|
||||
});
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket: entry.bucket,
|
||||
token: apiToken,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to refresh bucket configuration:", err);
|
||||
}
|
||||
} else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
||||
try {
|
||||
const s3 = makeR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
await ensureBucketCors({
|
||||
s3,
|
||||
bucket: entry.bucket,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to refresh bucket CORS via access keys:", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entry,
|
||||
usedManagedFallback: entry.domainType !== "custom",
|
||||
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiToken) {
|
||||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||||
const manualCustomDomain = baseDomain
|
||||
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
|
||||
: "";
|
||||
|
||||
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
|
||||
if (!publicBaseUrl) {
|
||||
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
throw new Error(
|
||||
"No public bucket domain configured. Add an API token or configure the domain manually."
|
||||
);
|
||||
}
|
||||
|
||||
const manualEntry = {
|
||||
bucket: bucketName,
|
||||
publicBaseUrl,
|
||||
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
||||
try {
|
||||
const s3 = makeR2Client({
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
});
|
||||
await ensureBucketCors({
|
||||
s3,
|
||||
bucket: bucketName,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (corsErr) {
|
||||
console.warn(
|
||||
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
|
||||
corsErr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let savedEntry = entry;
|
||||
if (
|
||||
!entry ||
|
||||
entry.bucket !== manualEntry.bucket ||
|
||||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
|
||||
entry.domainType !== manualEntry.domainType
|
||||
) {
|
||||
const updatedSettings = await saveR2Settings(
|
||||
mergeBucketEntry(this.getSettings(), npub, manualEntry)
|
||||
);
|
||||
this.setSettings(updatedSettings);
|
||||
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
|
||||
}
|
||||
|
||||
return {
|
||||
entry: savedEntry,
|
||||
usedManagedFallback: manualEntry.domainType !== "custom",
|
||||
customDomainStatus:
|
||||
manualEntry.domainType === "custom" ? "manual" : "managed",
|
||||
};
|
||||
}
|
||||
|
||||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||||
|
||||
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
|
||||
|
||||
try {
|
||||
await putCors({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
origins: corsOrigins,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to apply R2 CORS rules:", err);
|
||||
}
|
||||
|
||||
let publicBaseUrl = entry?.publicBaseUrl || "";
|
||||
let domainType = entry?.domainType || "managed";
|
||||
let usedManagedFallback = false;
|
||||
let customDomainStatus = "skipped";
|
||||
|
||||
if (baseDomain && zoneId) {
|
||||
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
|
||||
try {
|
||||
const custom = await attachCustomDomainAndWait({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
zoneId,
|
||||
domain,
|
||||
pollInterval: 2500,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
customDomainStatus = custom?.status || "unknown";
|
||||
if (custom?.active && custom?.url) {
|
||||
publicBaseUrl = custom.url;
|
||||
domainType = "custom";
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Failed to disable managed domain:", disableErr);
|
||||
}
|
||||
} else {
|
||||
usedManagedFallback = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (/already exists/i.test(err.message || "")) {
|
||||
publicBaseUrl = `https://${domain}`;
|
||||
domainType = "custom";
|
||||
customDomainStatus = "active";
|
||||
try {
|
||||
await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: false,
|
||||
});
|
||||
} catch (disableErr) {
|
||||
console.warn("Failed to disable managed domain:", disableErr);
|
||||
}
|
||||
} else {
|
||||
console.warn("Failed to attach custom domain, falling back:", err);
|
||||
usedManagedFallback = true;
|
||||
customDomainStatus = "error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
const managed = await setManagedDomain({
|
||||
accountId,
|
||||
bucket: bucketName,
|
||||
token: apiToken,
|
||||
enabled: true,
|
||||
});
|
||||
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
|
||||
domainType = "managed";
|
||||
usedManagedFallback = true;
|
||||
customDomainStatus =
|
||||
customDomainStatus === "skipped" ? "managed" : customDomainStatus;
|
||||
}
|
||||
|
||||
const mergedEntry = {
|
||||
bucket: bucketName,
|
||||
publicBaseUrl,
|
||||
domainType,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
const updatedSettings = await saveR2Settings(
|
||||
mergeBucketEntry(this.getSettings(), npub, mergedEntry)
|
||||
);
|
||||
this.setSettings(updatedSettings);
|
||||
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
|
||||
}
|
||||
|
||||
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
|
||||
if (!this.cloudflareSettings) {
|
||||
const detail = {
|
||||
text: "Save your credentials to configure R2.",
|
||||
title: "",
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (!hasPubkey) {
|
||||
const detail = { text: "Login to preview your R2 bucket.", title: "" };
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (!npub) {
|
||||
const detail = { text: "Unable to encode npub.", title: "" };
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
const entry = this.cloudflareSettings.buckets?.[npub];
|
||||
if (!entry || !entry.publicBaseUrl) {
|
||||
const detail = {
|
||||
text: "Bucket will be auto-created on your next upload.",
|
||||
title: "",
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
|
||||
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
|
||||
const fullPreview = `${entry.bucket} • ${publicUrl}`;
|
||||
|
||||
let displayHostAndPath = truncateMiddle(publicUrl, 72);
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
const cleanPath = parsed.pathname.replace(/^\//, "");
|
||||
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
|
||||
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
|
||||
} catch (err) {
|
||||
// ignore URL parse issues and fall back to the raw string
|
||||
}
|
||||
|
||||
const truncatedBucket = truncateMiddle(entry.bucket, 28);
|
||||
const detail = {
|
||||
text: `${truncatedBucket} • ${displayHostAndPath}`,
|
||||
title: fullPreview,
|
||||
};
|
||||
this.emit("bucketPreview", detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async handleCloudflareUploadSubmit({
|
||||
npub = "",
|
||||
file = null,
|
||||
metadata = {},
|
||||
settingsInput = null,
|
||||
publishVideoNote,
|
||||
onReset,
|
||||
} = {}) {
|
||||
if (settingsInput) {
|
||||
const saved = await this.handleCloudflareSettingsSubmit(settingsInput, {
|
||||
quiet: true,
|
||||
});
|
||||
if (!saved) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Fix your R2 settings before uploading.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!npub) {
|
||||
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = String(metadata.title || "").trim();
|
||||
if (!title) {
|
||||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Select a video or HLS file to upload.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = String(metadata.description || "").trim();
|
||||
const thumbnail = String(metadata.thumbnail || "").trim();
|
||||
const magnet = String(metadata.magnet || "").trim();
|
||||
const ws = String(metadata.ws || "").trim();
|
||||
const xs = String(metadata.xs || "").trim();
|
||||
const enableComments = metadata.enableComments !== false;
|
||||
|
||||
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Missing R2 credentials. Save them before uploading.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
|
||||
this.updateCloudflareProgress(0);
|
||||
this.setCloudflareUploading(true);
|
||||
|
||||
let bucketResult = null;
|
||||
try {
|
||||
bucketResult = await this.ensureBucketConfigForNpub(npub);
|
||||
} catch (err) {
|
||||
console.error("Failed to prepare R2 bucket:", err);
|
||||
this.setCloudflareUploadStatus(
|
||||
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
|
||||
"error"
|
||||
);
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bucketEntry =
|
||||
bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
|
||||
|
||||
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
|
||||
this.setCloudflareUploadStatus(
|
||||
"Bucket is missing a public domain. Check your Cloudflare settings.",
|
||||
"error"
|
||||
);
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
return false;
|
||||
}
|
||||
|
||||
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
|
||||
if (bucketResult?.usedManagedFallback) {
|
||||
const baseDomain = this.cloudflareSettings?.baseDomain || "";
|
||||
if (baseDomain) {
|
||||
const customStatus = bucketResult?.customDomainStatus
|
||||
? ` (custom domain status: ${bucketResult.customDomainStatus})`
|
||||
: "";
|
||||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
|
||||
} else {
|
||||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setCloudflareUploadStatus(
|
||||
statusMessage,
|
||||
bucketResult?.usedManagedFallback ? "warning" : "info"
|
||||
);
|
||||
|
||||
const key = buildR2Key(npub, file);
|
||||
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
|
||||
|
||||
try {
|
||||
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
bucket: bucketEntry.bucket,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type,
|
||||
onProgress: (fraction) => {
|
||||
this.updateCloudflareProgress(fraction);
|
||||
},
|
||||
});
|
||||
|
||||
let publishOutcome = true;
|
||||
|
||||
if (typeof publishVideoNote !== "function") {
|
||||
console.warn(
|
||||
"publishVideoNote handler missing; skipping publish step."
|
||||
);
|
||||
publishOutcome = false;
|
||||
} else {
|
||||
const payload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
ws,
|
||||
xs,
|
||||
enableComments,
|
||||
};
|
||||
|
||||
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
|
||||
publicUrl,
|
||||
file,
|
||||
});
|
||||
if (mergedNip71 && Object.keys(mergedNip71).length) {
|
||||
payload.nip71 = mergedNip71;
|
||||
}
|
||||
|
||||
const published = await publishVideoNote(payload, {
|
||||
onSuccess: () => {
|
||||
if (typeof onReset === "function") {
|
||||
onReset();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
publishOutcome = Boolean(published);
|
||||
if (publishOutcome) {
|
||||
this.setCloudflareUploadStatus(
|
||||
`Published ${publicUrl}`,
|
||||
"success"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return publishOutcome;
|
||||
} catch (err) {
|
||||
console.error("Cloudflare upload failed:", err);
|
||||
this.setCloudflareUploadStatus(
|
||||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||||
"error"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
this.setCloudflareUploading(false);
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
await this.updateCloudflareBucketPreview({ hasPubkey: true, npub });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadVideo(params = {}) {
|
||||
return this.handleCloudflareUploadSubmit(params);
|
||||
}
|
||||
}
|
||||
|
||||
const r2Service = new R2Service();
|
||||
|
||||
export default r2Service;
|
||||
@@ -0,0 +1,599 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import { pointerArrayToKey } from "../utils/pointer.js";
|
||||
import {
|
||||
ingestLocalViewEvent as defaultIngestLocalViewEvent,
|
||||
} from "../viewCounter.js";
|
||||
|
||||
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sanitizeVideoMetadata(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAtCandidates = [
|
||||
video.rootCreatedAt,
|
||||
video.created_at,
|
||||
video.createdAt,
|
||||
video.publishedAt,
|
||||
];
|
||||
|
||||
let createdAt = null;
|
||||
for (const candidate of createdAtCandidates) {
|
||||
if (Number.isFinite(candidate)) {
|
||||
createdAt = Math.floor(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof video.id === "string" ? video.id : "",
|
||||
title: typeof video.title === "string" ? video.title : "",
|
||||
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
||||
created_at: createdAt,
|
||||
url: typeof video.url === "string" ? video.url : "",
|
||||
magnet: typeof video.magnet === "string" ? video.magnet : "",
|
||||
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
|
||||
legacyInfoHash:
|
||||
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
|
||||
};
|
||||
}
|
||||
|
||||
export default class WatchHistoryTelemetry {
|
||||
constructor({
|
||||
watchHistoryService = null,
|
||||
watchHistoryController = null,
|
||||
nostrClient = null,
|
||||
log,
|
||||
normalizeHexPubkey,
|
||||
getActiveUserPubkey,
|
||||
ingestLocalViewEvent = defaultIngestLocalViewEvent,
|
||||
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
|
||||
} = {}) {
|
||||
this.watchHistoryService = watchHistoryService;
|
||||
this.watchHistoryController = watchHistoryController;
|
||||
this.nostrClient = nostrClient;
|
||||
this.log = typeof log === "function" ? log : () => {};
|
||||
this.normalizeHexPubkey =
|
||||
typeof normalizeHexPubkey === "function"
|
||||
? normalizeHexPubkey
|
||||
: () => null;
|
||||
this.getActiveUserPubkey =
|
||||
typeof getActiveUserPubkey === "function" ? getActiveUserPubkey : () => null;
|
||||
this.ingestLocalViewEvent =
|
||||
typeof ingestLocalViewEvent === "function"
|
||||
? ingestLocalViewEvent
|
||||
: defaultIngestLocalViewEvent;
|
||||
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
|
||||
? Math.max(0, viewThresholdSeconds)
|
||||
: DEFAULT_VIEW_THRESHOLD_SECONDS;
|
||||
|
||||
this.watchHistoryMetadataEnabled = null;
|
||||
this.watchHistoryPreferenceUnsubscribe = null;
|
||||
this.playbackTelemetryState = null;
|
||||
this.loggedViewPointerKeys = new Set();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resetPlaybackLoggingState();
|
||||
this._clearPreferenceSubscription();
|
||||
this.watchHistoryMetadataEnabled = null;
|
||||
}
|
||||
|
||||
async initPreferenceSync() {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.watchHistoryMetadataEnabled = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.refreshPreferenceSettings();
|
||||
|
||||
if (
|
||||
typeof this.watchHistoryService.subscribe === "function" &&
|
||||
!this.watchHistoryPreferenceUnsubscribe
|
||||
) {
|
||||
try {
|
||||
const unsubscribe = this.watchHistoryService.subscribe(
|
||||
"metadata-preference",
|
||||
(payload) => {
|
||||
const previous = this.watchHistoryMetadataEnabled;
|
||||
const enabled = payload?.enabled !== false;
|
||||
this.watchHistoryMetadataEnabled = enabled;
|
||||
if (previous === true && enabled === false) {
|
||||
try {
|
||||
this.watchHistoryService?.clearLocalMetadata?.();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to subscribe to metadata preference changes:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.watchHistoryPreferenceUnsubscribe || null;
|
||||
}
|
||||
|
||||
refreshPreferenceSettings() {
|
||||
if (!this.watchHistoryService?.isEnabled?.()) {
|
||||
this.watchHistoryMetadataEnabled = false;
|
||||
return this.watchHistoryMetadataEnabled;
|
||||
}
|
||||
|
||||
const previous = this.watchHistoryMetadataEnabled;
|
||||
let enabled = true;
|
||||
|
||||
try {
|
||||
if (typeof this.watchHistoryService.getSettings === "function") {
|
||||
const settings = this.watchHistoryService.getSettings();
|
||||
enabled = settings?.metadata?.storeLocally !== false;
|
||||
} else if (
|
||||
typeof this.watchHistoryService.shouldStoreMetadata === "function"
|
||||
) {
|
||||
enabled = this.watchHistoryService.shouldStoreMetadata() !== false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to read metadata settings:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
this.watchHistoryMetadataEnabled = enabled;
|
||||
|
||||
if (enabled === false && previous !== false) {
|
||||
try {
|
||||
this.watchHistoryService?.clearLocalMetadata?.();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to purge metadata cache while preference disabled:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.watchHistoryMetadataEnabled;
|
||||
}
|
||||
|
||||
isMetadataPreferenceEnabled() {
|
||||
return this.watchHistoryMetadataEnabled !== false;
|
||||
}
|
||||
|
||||
persistMetadataForVideo(video, pointerInfo) {
|
||||
if (
|
||||
!this.watchHistoryMetadataEnabled ||
|
||||
!pointerInfo ||
|
||||
!pointerInfo.key ||
|
||||
typeof this.watchHistoryService?.setLocalMetadata !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video || typeof video !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
video: sanitizeVideoMetadata(video),
|
||||
};
|
||||
|
||||
try {
|
||||
this.watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to persist local metadata for pointer:",
|
||||
pointerInfo.key,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropMetadata(pointerKey) {
|
||||
if (!pointerKey || typeof pointerKey !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.watchHistoryService?.removeLocalMetadata !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.watchHistoryService.removeLocalMetadata(pointerKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to remove cached metadata for pointer:",
|
||||
pointerKey,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoval(payload = {}) {
|
||||
if (!this.watchHistoryController) {
|
||||
const error = new Error("watch-history-disabled");
|
||||
error.handled = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
|
||||
}
|
||||
|
||||
flush(reason = "session-end", context = "watch-history") {
|
||||
if (!this.watchHistoryController) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
return this.watchHistoryController.flush(reason, context);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
cancelPlaybackLogging({
|
||||
reason = "session-end",
|
||||
context = "cancelPendingViewLogging",
|
||||
} = {}) {
|
||||
const state = this.playbackTelemetryState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewTimerId) {
|
||||
this._getTimerHost().clearTimeout(state.viewTimerId);
|
||||
}
|
||||
|
||||
if (Array.isArray(state.handlers) && state.videoEl) {
|
||||
for (const { eventName, handler } of state.handlers) {
|
||||
try {
|
||||
state.videoEl.removeEventListener(eventName, handler);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[watchHistoryTelemetry] Failed to detach ${eventName} listener:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.playbackTelemetryState = null;
|
||||
|
||||
this.flush(reason, context).catch((error) => {
|
||||
const message = normalizeString(error?.message) || String(error ?? "unknown");
|
||||
this.log(
|
||||
`[watchHistoryTelemetry] Watch history flush failed: ${message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resetPlaybackLoggingState() {
|
||||
this.cancelPlaybackLogging();
|
||||
if (this.loggedViewPointerKeys.size > 0) {
|
||||
this.loggedViewPointerKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
preparePlaybackLogging({
|
||||
videoElement,
|
||||
pointer,
|
||||
pointerKey: explicitPointerKey,
|
||||
video,
|
||||
} = {}) {
|
||||
this.cancelPlaybackLogging();
|
||||
|
||||
if (!videoElement || typeof videoElement.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerKey = explicitPointerKey || pointerArrayToKey(pointer);
|
||||
if (!pointer || !pointerKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewerIdentityKey = this.getActiveViewIdentityKey();
|
||||
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
|
||||
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
videoEl: videoElement,
|
||||
pointer,
|
||||
pointerKey,
|
||||
viewerIdentityKey,
|
||||
handlers: [],
|
||||
viewTimerId: null,
|
||||
viewFired: false,
|
||||
videoMetadata: sanitizeVideoMetadata(video),
|
||||
};
|
||||
|
||||
const clearTimer = (timerKey) => {
|
||||
const property = `${timerKey}TimerId`;
|
||||
if (state[property]) {
|
||||
this._getTimerHost().clearTimeout(state[property]);
|
||||
state[property] = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeView = () => {
|
||||
if (state.viewFired) {
|
||||
return;
|
||||
}
|
||||
state.viewFired = true;
|
||||
clearTimer("view");
|
||||
this.cancelPlaybackLogging();
|
||||
|
||||
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
|
||||
state;
|
||||
|
||||
(async () => {
|
||||
let viewResult;
|
||||
try {
|
||||
const canUseWatchHistoryService =
|
||||
typeof this.watchHistoryService?.publishView === "function";
|
||||
const resolveWatchActor = () => {
|
||||
const normalizedUser = this.normalizeHexPubkey(
|
||||
this.getActiveUserPubkey(),
|
||||
);
|
||||
if (normalizedUser) {
|
||||
return normalizedUser;
|
||||
}
|
||||
|
||||
const normalizedClient = this.normalizeHexPubkey(
|
||||
this.nostrClient?.pubkey,
|
||||
);
|
||||
if (normalizedClient) {
|
||||
return normalizedClient;
|
||||
}
|
||||
|
||||
const normalizedSession = this.normalizeHexPubkey(
|
||||
this.nostrClient?.sessionActor?.pubkey,
|
||||
);
|
||||
if (normalizedSession) {
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
if (normalizeString(this.nostrClient?.pubkey)) {
|
||||
return normalizeString(this.nostrClient.pubkey).toLowerCase();
|
||||
}
|
||||
|
||||
if (normalizeString(this.nostrClient?.sessionActor?.pubkey)) {
|
||||
return normalizeString(
|
||||
this.nostrClient.sessionActor.pubkey,
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const activeWatchActor = resolveWatchActor();
|
||||
const watchMetadata = {};
|
||||
if (activeWatchActor) {
|
||||
watchMetadata.actor = activeWatchActor;
|
||||
}
|
||||
if (state.videoMetadata) {
|
||||
watchMetadata.video = state.videoMetadata;
|
||||
}
|
||||
const metadataPayload = Object.keys(watchMetadata).length
|
||||
? watchMetadata
|
||||
: undefined;
|
||||
|
||||
if (canUseWatchHistoryService) {
|
||||
viewResult = await this.watchHistoryService.publishView(
|
||||
thresholdPointer,
|
||||
undefined,
|
||||
metadataPayload,
|
||||
);
|
||||
} else if (typeof this.nostrClient?.recordVideoView === "function") {
|
||||
viewResult = await this.nostrClient.recordVideoView(
|
||||
thresholdPointer,
|
||||
);
|
||||
} else {
|
||||
viewResult = { ok: false, error: "view-logging-unavailable" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Exception while recording video view:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const viewOk = !!viewResult?.ok;
|
||||
if (viewOk) {
|
||||
const eventIdentityKey =
|
||||
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
|
||||
state.viewerIdentityKey ||
|
||||
this.getActiveViewIdentityKey();
|
||||
const keyToPersist = this.buildViewCooldownKey(
|
||||
thresholdPointerKey,
|
||||
eventIdentityKey,
|
||||
);
|
||||
if (keyToPersist) {
|
||||
this.loggedViewPointerKeys.add(keyToPersist);
|
||||
}
|
||||
if (viewResult?.event && this.ingestLocalViewEvent) {
|
||||
try {
|
||||
this.ingestLocalViewEvent({
|
||||
event: viewResult.event,
|
||||
pointer: thresholdPointer,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to ingest local view event:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isDevMode && viewResult) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] View event rejected by relays:",
|
||||
viewResult,
|
||||
);
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Unexpected error while recording video view:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
|
||||
const firedKey = `${timerKey}Fired`;
|
||||
const idKey = `${timerKey}TimerId`;
|
||||
if (state[firedKey] || state[idKey]) {
|
||||
return;
|
||||
}
|
||||
const currentSeconds = Number.isFinite(videoElement.currentTime)
|
||||
? videoElement.currentTime
|
||||
: 0;
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
Math.ceil((thresholdSeconds - currentSeconds) * 1000),
|
||||
);
|
||||
if (remainingMs <= 0) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
state[idKey] = this._getTimerHost().setTimeout(callback, remainingMs);
|
||||
};
|
||||
|
||||
const registerHandler = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
state.handlers.push({ eventName, handler });
|
||||
};
|
||||
|
||||
registerHandler("timeupdate", () => {
|
||||
if (!state.viewFired && videoElement.currentTime >= this.viewThresholdSeconds) {
|
||||
finalizeView();
|
||||
}
|
||||
});
|
||||
|
||||
const cancelOnPause = () => {
|
||||
if (!state.viewFired) {
|
||||
clearTimer("view");
|
||||
}
|
||||
};
|
||||
|
||||
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
|
||||
registerHandler(event, cancelOnPause),
|
||||
);
|
||||
|
||||
const resumeIfNeeded = () => {
|
||||
if (state.viewFired) {
|
||||
return;
|
||||
}
|
||||
scheduleTimer("view", this.viewThresholdSeconds, finalizeView);
|
||||
};
|
||||
|
||||
["play", "playing"].forEach((event) =>
|
||||
registerHandler(event, resumeIfNeeded),
|
||||
);
|
||||
|
||||
this.playbackTelemetryState = state;
|
||||
|
||||
if (!videoElement.paused && videoElement.currentTime > 0) {
|
||||
resumeIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveViewIdentityKey() {
|
||||
const normalizedUser = this.normalizeHexPubkey(this.getActiveUserPubkey());
|
||||
if (normalizedUser) {
|
||||
return `actor:${normalizedUser}`;
|
||||
}
|
||||
|
||||
const sessionActorPubkey = this.normalizeHexPubkey(
|
||||
this.nostrClient?.sessionActor?.pubkey,
|
||||
);
|
||||
if (sessionActorPubkey) {
|
||||
return `actor:${sessionActorPubkey}`;
|
||||
}
|
||||
|
||||
return "actor:anonymous";
|
||||
}
|
||||
|
||||
deriveViewIdentityKeyFromEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `actor:${normalizedPubkey}`;
|
||||
}
|
||||
|
||||
buildViewCooldownKey(pointerKey, identityKey) {
|
||||
const normalizedPointerKey = normalizeString(pointerKey);
|
||||
if (!normalizedPointerKey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedIdentity = normalizeString(identityKey).toLowerCase();
|
||||
|
||||
return normalizedIdentity
|
||||
? `${normalizedPointerKey}::${normalizedIdentity}`
|
||||
: normalizedPointerKey;
|
||||
}
|
||||
|
||||
_clearPreferenceSubscription() {
|
||||
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
|
||||
try {
|
||||
this.watchHistoryPreferenceUnsubscribe();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.watchHistoryPreferenceUnsubscribe = null;
|
||||
}
|
||||
|
||||
_getTimerHost() {
|
||||
if (typeof window !== "undefined" && window) {
|
||||
return window;
|
||||
}
|
||||
return globalThis;
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -1,7 +1,7 @@
|
||||
// sidebar.js
|
||||
import { setHashView } from "./index.js";
|
||||
|
||||
export function setupSidebarNavigation() {
|
||||
export function setupSidebarNavigation({ closeSidebar } = {}) {
|
||||
const sidebarLinks = document.querySelectorAll('#sidebar a[href^="#view="]');
|
||||
sidebarLinks.forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
@@ -16,13 +16,21 @@ export function setupSidebarNavigation() {
|
||||
setHashView(viewName); // This changes the hash and loads the view.
|
||||
|
||||
// --- NEW: if on mobile, close the sidebar automatically. ---
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const app = document.getElementById("app");
|
||||
if (sidebar && app) {
|
||||
sidebar.classList.remove("sidebar-open");
|
||||
app.classList.remove("sidebar-open");
|
||||
const isMobile =
|
||||
typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(max-width: 767px)").matches
|
||||
: window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
if (typeof closeSidebar === "function") {
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove("sidebar-open");
|
||||
}
|
||||
document.body.classList.remove("sidebar-open");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// js/sidebarLoading.js
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function getSidebarLoadingMarkup(message = "", options = {}) {
|
||||
const trimmed = typeof message === "string" ? message.trim() : "";
|
||||
const copy = trimmed || "Fetching data…";
|
||||
const safeCopy = escapeHtml(copy);
|
||||
const showSpinner = options?.showSpinner !== false;
|
||||
const spinnerMarkup = showSpinner
|
||||
? '<span class="status-spinner status-spinner--inline" aria-hidden="true"></span>'
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
|
||||
<div class="sidebar-loading-indicator">
|
||||
${spinnerMarkup}
|
||||
<span class="sidebar-loading-text">${safeCopy}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
const state = {
|
||||
pubkey: null,
|
||||
currentUserNpub: null,
|
||||
currentVideo: null,
|
||||
modals: Object.create(null),
|
||||
videosMap: null,
|
||||
videoSubscription: null,
|
||||
};
|
||||
|
||||
const globalSubscribers = new Set();
|
||||
const keySubscribers = new Map();
|
||||
const modalSubscribers = new Map();
|
||||
|
||||
function createSnapshot() {
|
||||
return {
|
||||
pubkey: state.pubkey,
|
||||
currentUserNpub: state.currentUserNpub,
|
||||
currentVideo: state.currentVideo,
|
||||
modals: { ...state.modals },
|
||||
videosMap: state.videosMap,
|
||||
videoSubscription: state.videoSubscription,
|
||||
};
|
||||
}
|
||||
|
||||
function notifyKey(key, value, previousValue) {
|
||||
const subscribers = keySubscribers.get(key);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] subscriber for key "${key}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSubscribers.size) {
|
||||
const snapshot = createSnapshot();
|
||||
for (const callback of globalSubscribers) {
|
||||
try {
|
||||
callback(snapshot, { key, value, previousValue });
|
||||
} catch (error) {
|
||||
console.warn("[appState] global subscriber threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToKey(key, callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!keySubscribers.has(key)) {
|
||||
keySubscribers.set(key, new Set());
|
||||
}
|
||||
const subscribers = keySubscribers.get(key);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
keySubscribers.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppState(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
globalSubscribers.add(callback);
|
||||
return () => {
|
||||
globalSubscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppStateKey(key, callback) {
|
||||
return subscribeToKey(key, callback);
|
||||
}
|
||||
|
||||
function updateKey(key, value) {
|
||||
const previousValue = state[key];
|
||||
if (previousValue === value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
state[key] = value;
|
||||
notifyKey(key, value, previousValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getAppState() {
|
||||
return createSnapshot();
|
||||
}
|
||||
|
||||
export function getPubkey() {
|
||||
return state.pubkey;
|
||||
}
|
||||
|
||||
export function setPubkey(value) {
|
||||
return updateKey("pubkey", typeof value === "string" ? value : value ?? null);
|
||||
}
|
||||
|
||||
export function getCurrentUserNpub() {
|
||||
return state.currentUserNpub;
|
||||
}
|
||||
|
||||
export function setCurrentUserNpub(value) {
|
||||
return updateKey(
|
||||
"currentUserNpub",
|
||||
typeof value === "string" && value ? value : value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getCurrentVideo() {
|
||||
return state.currentVideo;
|
||||
}
|
||||
|
||||
export function setCurrentVideo(value) {
|
||||
return updateKey("currentVideo", value ?? null);
|
||||
}
|
||||
|
||||
export function getVideosMap() {
|
||||
return state.videosMap;
|
||||
}
|
||||
|
||||
export function setVideosMap(value) {
|
||||
if (value instanceof Map) {
|
||||
return updateKey("videosMap", value);
|
||||
}
|
||||
return updateKey("videosMap", value ?? null);
|
||||
}
|
||||
|
||||
export function getVideoSubscription() {
|
||||
return state.videoSubscription;
|
||||
}
|
||||
|
||||
export function setVideoSubscription(value) {
|
||||
return updateKey("videoSubscription", value ?? null);
|
||||
}
|
||||
|
||||
export function getModalState(name) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(state.modals[name]);
|
||||
}
|
||||
|
||||
function notifyModal(name, value, previousValue) {
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] modal subscriber for "${name}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setModalState(name, isOpen) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = Boolean(isOpen);
|
||||
const previous = Boolean(state.modals[name]);
|
||||
if (previous === normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const previousModals = { ...state.modals };
|
||||
state.modals[name] = normalized;
|
||||
notifyModal(name, normalized, previous);
|
||||
notifyKey("modals", { ...state.modals }, previousModals);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function subscribeToModalState(name, callback) {
|
||||
if (typeof name !== "string" || !name || typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!modalSubscribers.has(name)) {
|
||||
modalSubscribers.set(name, new Set());
|
||||
}
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
modalSubscribers.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAppState() {
|
||||
const previous = createSnapshot();
|
||||
state.pubkey = null;
|
||||
state.currentUserNpub = null;
|
||||
state.currentVideo = null;
|
||||
state.modals = Object.create(null);
|
||||
state.videosMap = null;
|
||||
state.videoSubscription = null;
|
||||
notifyKey("pubkey", state.pubkey, previous.pubkey);
|
||||
notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub);
|
||||
notifyKey("currentVideo", state.currentVideo, previous.currentVideo);
|
||||
notifyKey("modals", { ...state.modals }, previous.modals);
|
||||
notifyKey("videosMap", state.videosMap, previous.videosMap);
|
||||
notifyKey(
|
||||
"videoSubscription",
|
||||
state.videoSubscription,
|
||||
previous.videoSubscription
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import {
|
||||
readUrlHealthFromStorage,
|
||||
removeUrlHealthFromStorage,
|
||||
writeUrlHealthToStorage,
|
||||
} from "../utils/storage.js";
|
||||
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||||
|
||||
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
|
||||
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
|
||||
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function sanitizeProfileString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let savedProfiles = [];
|
||||
let activeProfilePubkey = null;
|
||||
const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(previousProfiles) || !Array.isArray(nextProfiles)) {
|
||||
return true;
|
||||
}
|
||||
if (previousProfiles.length !== nextProfiles.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousProfiles.length; index += 1) {
|
||||
const prev = previousProfiles[index];
|
||||
const next = nextProfiles[index];
|
||||
if (prev === next) {
|
||||
continue;
|
||||
}
|
||||
if (!prev || !next || typeof prev !== "object" || typeof next !== "object") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded && decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSavedProfiles() {
|
||||
return savedProfiles;
|
||||
}
|
||||
|
||||
export function getProfileCacheMap() {
|
||||
return profileCache;
|
||||
}
|
||||
|
||||
export function getActiveProfilePubkey() {
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function setActiveProfilePubkey(pubkey, { persist = true } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
const nextValue =
|
||||
normalized || (typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : null);
|
||||
if (activeProfilePubkey === nextValue) {
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
activeProfilePubkey = nextValue;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function mutateSavedProfiles(mutator, { persist = true, persistActive = true } = {}) {
|
||||
if (typeof mutator !== "function") {
|
||||
return { changed: false, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
const draft = savedProfiles.slice();
|
||||
const result = mutator(draft);
|
||||
const nextProfiles = Array.isArray(result) ? result : draft;
|
||||
const changed = hasSavedProfilesChanged(savedProfiles, nextProfiles);
|
||||
|
||||
if (changed) {
|
||||
savedProfiles = nextProfiles;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive });
|
||||
}
|
||||
} else if (persist && persistActive === false) {
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
|
||||
return { changed, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
export function setSavedProfiles(nextProfiles, options) {
|
||||
return mutateSavedProfiles(
|
||||
() => (Array.isArray(nextProfiles) ? nextProfiles.slice() : []),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function readSavedProfilesPayloadFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.readSavedProfilesPayloadFromStorage] Failed to parse payload:",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSavedProfilesPayloadToStorage(payload) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(SAVED_PROFILES_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
const isQuotaError =
|
||||
error &&
|
||||
(error.name === "QuotaExceededError" ||
|
||||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
error.code === 22 ||
|
||||
error.code === 1014);
|
||||
if (isQuotaError) {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Storage quota exceeded; keeping in-memory copy only.",
|
||||
error
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSavedProfiles({ persistActive = true } = {}) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedProfiles.length && !activeProfilePubkey) {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistSavedProfiles] Failed to remove empty payload:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let activePubkeyToPersist = activeProfilePubkey || null;
|
||||
if (!persistActive) {
|
||||
const storedPayload = readSavedProfilesPayloadFromStorage();
|
||||
if (storedPayload && typeof storedPayload === "object") {
|
||||
const candidate =
|
||||
typeof storedPayload.activePubkey === "string"
|
||||
? storedPayload.activePubkey
|
||||
: typeof storedPayload.activePubKey === "string"
|
||||
? storedPayload.activePubKey
|
||||
: null;
|
||||
const normalizedStored = normalizeHexPubkey(candidate);
|
||||
if (normalizedStored) {
|
||||
activePubkeyToPersist = normalizedStored;
|
||||
} else if (candidate === null) {
|
||||
activePubkeyToPersist = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||||
entries: savedProfiles.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
npub:
|
||||
typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture: typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
})),
|
||||
activePubkey: activePubkeyToPersist,
|
||||
};
|
||||
|
||||
writeSavedProfilesPayloadToStorage(payload);
|
||||
}
|
||||
|
||||
export function loadSavedProfilesFromStorage() {
|
||||
savedProfiles = [];
|
||||
activeProfilePubkey = null;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
let needsRewrite = false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) {
|
||||
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
const seenPubkeys = new Set();
|
||||
for (const candidate of entries) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizeHexPubkey(candidate.pubkey);
|
||||
if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPubkeys.add(normalizedPubkey);
|
||||
const storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
const npub =
|
||||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||||
? candidate.npub.trim()
|
||||
: null;
|
||||
|
||||
const entry = {
|
||||
pubkey: normalizedPubkey,
|
||||
npub,
|
||||
name: typeof candidate.name === "string" ? candidate.name : "",
|
||||
picture: typeof candidate.picture === "string" ? candidate.picture : "",
|
||||
authType: storedAuthType,
|
||||
};
|
||||
|
||||
if (
|
||||
typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub
|
||||
) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
savedProfiles.push(entry);
|
||||
}
|
||||
|
||||
const activeCandidate =
|
||||
typeof parsed.activePubkey === "string"
|
||||
? parsed.activePubkey
|
||||
: typeof parsed.activePubKey === "string"
|
||||
? parsed.activePubKey
|
||||
: null;
|
||||
const normalizedActive = normalizeHexPubkey(activeCandidate);
|
||||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||||
activeProfilePubkey = normalizedActive;
|
||||
} else if (activeCandidate) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} else if (raw) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error);
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (!activeProfilePubkey && savedProfiles.length) {
|
||||
activeProfilePubkey = savedProfiles[0].pubkey;
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (needsRewrite) {
|
||||
persistSavedProfiles();
|
||||
}
|
||||
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
export function syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheEntry = profileCache.get(normalized);
|
||||
if (!cacheEntry || typeof cacheEntry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const next = profiles.slice();
|
||||
const index = next.findIndex((entry) => entry && entry.pubkey === normalized);
|
||||
if (index < 0) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
const existing = next[index] || {};
|
||||
const profile = cacheEntry.profile || {};
|
||||
const nextEntry = {
|
||||
...existing,
|
||||
name: profile.name || existing.name || "",
|
||||
picture: profile.picture || existing.picture || "",
|
||||
};
|
||||
|
||||
if (
|
||||
existing.name === nextEntry.name &&
|
||||
existing.picture === nextEntry.picture
|
||||
) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
next[index] = nextEntry;
|
||||
updated = true;
|
||||
return next;
|
||||
}, { persist, persistActive: false });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parsed.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||||
if (!pubkey || !entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = entry.profile;
|
||||
if (!profile || typeof profile !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const about = sanitizeProfileString(profile.about || profile.aboutMe);
|
||||
if (about) {
|
||||
normalized.about = about;
|
||||
}
|
||||
|
||||
const website = sanitizeProfileString(profile.website || profile.url);
|
||||
if (website) {
|
||||
normalized.website = website;
|
||||
}
|
||||
|
||||
const banner = sanitizeProfileString(profile.banner || profile.header);
|
||||
if (banner) {
|
||||
normalized.banner = banner;
|
||||
}
|
||||
|
||||
const lud16 = sanitizeProfileString(profile.lud16);
|
||||
if (lud16) {
|
||||
normalized.lud16 = lud16;
|
||||
}
|
||||
|
||||
const lud06 = sanitizeProfileString(profile.lud06);
|
||||
if (lud06) {
|
||||
normalized.lud06 = lud06;
|
||||
}
|
||||
|
||||
const lightningCandidates = [
|
||||
sanitizeProfileString(profile.lightningAddress),
|
||||
lud16,
|
||||
lud06,
|
||||
].filter(Boolean);
|
||||
if (lightningCandidates.length) {
|
||||
normalized.lightningAddress = lightningCandidates[0];
|
||||
}
|
||||
|
||||
profileCache.set(pubkey, {
|
||||
profile: normalized,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadProfileCacheFromStorage] Failed to parse payload:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistProfileCacheToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
|
||||
for (const [pubkey, entry] of profileCache.entries()) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[pubkey] = {
|
||||
profile: entry.profile,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: PROFILE_CACHE_VERSION,
|
||||
savedAt: now,
|
||||
entries,
|
||||
};
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistProfileCacheToStorage] Failed to clear storage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn("[cache.persistProfileCacheToStorage] Failed to persist cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileCacheEntry(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = profileCache.get(normalized);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(normalized);
|
||||
persistProfileCacheToStorage();
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey || !profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const about = sanitizeProfileString(profile.about || profile.aboutMe);
|
||||
if (about) {
|
||||
normalized.about = about;
|
||||
}
|
||||
|
||||
const website = sanitizeProfileString(profile.website || profile.url);
|
||||
if (website) {
|
||||
normalized.website = website;
|
||||
}
|
||||
|
||||
const banner = sanitizeProfileString(profile.banner || profile.header);
|
||||
if (banner) {
|
||||
normalized.banner = banner;
|
||||
}
|
||||
|
||||
const lud16 = sanitizeProfileString(profile.lud16);
|
||||
if (lud16) {
|
||||
normalized.lud16 = lud16;
|
||||
}
|
||||
|
||||
const lud06 = sanitizeProfileString(profile.lud06);
|
||||
if (lud06) {
|
||||
normalized.lud06 = lud06;
|
||||
}
|
||||
|
||||
const lightningCandidates = [
|
||||
sanitizeProfileString(profile.lightningAddress),
|
||||
lud16,
|
||||
lud06,
|
||||
].filter(Boolean);
|
||||
if (lightningCandidates.length) {
|
||||
normalized.lightningAddress = lightningCandidates[0];
|
||||
}
|
||||
|
||||
const entry = {
|
||||
profile: normalized,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
profileCache.set(normalizedPubkey, entry);
|
||||
if (persist) {
|
||||
persistProfileCacheToStorage();
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
return `${trimmed}::${mode}`;
|
||||
}
|
||||
|
||||
function isUrlHealthEntryFresh(entry, url) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url && entry.url && entry.url !== url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedUrlHealth(eventId, url) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthCache.get(eventId);
|
||||
if (isUrlHealthEntryFresh(entry, url)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
urlHealthCache.delete(eventId);
|
||||
}
|
||||
|
||||
const stored = readUrlHealthFromStorage(eventId);
|
||||
if (!isUrlHealthEntryFresh(stored, url)) {
|
||||
if (stored) {
|
||||
removeUrlHealthFromStorage(eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
urlHealthCache.set(eventId, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function storeUrlHealth(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
status: result?.status || "checking",
|
||||
message: result?.message || "Checking hosted URL…",
|
||||
url: url || result?.url || "",
|
||||
expiresAt: now + ttl,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
urlHealthCache.set(eventId, entry);
|
||||
writeUrlHealthToStorage(eventId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setInFlightUrlProbe(eventId, url, promise, options = {}) {
|
||||
if (!eventId || !promise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
urlHealthInFlight.set(eventId, { promise, key });
|
||||
promise.finally(() => {
|
||||
const current = urlHealthInFlight.get(eventId);
|
||||
if (current && current.promise === promise) {
|
||||
urlHealthInFlight.delete(eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getInFlightUrlProbe(eventId, url, options = {}) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthInFlight.get(eventId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
if (entry.key && entry.key !== key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export const urlHealthConstants = {
|
||||
URL_HEALTH_TTL_MS,
|
||||
URL_HEALTH_TIMEOUT_RETRY_MS,
|
||||
URL_PROBE_TIMEOUT_RETRY_MS,
|
||||
};
|
||||
@@ -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,311 @@
|
||||
// js/storage/r2-s3.js
|
||||
|
||||
const AWS_SDK_SPECIFIER =
|
||||
"https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
|
||||
|
||||
const disableNetworkImports = Boolean(
|
||||
globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__
|
||||
);
|
||||
|
||||
let awsSdk = null;
|
||||
let awsSdkLoadError = null;
|
||||
let awsSdkLoadPromise = null;
|
||||
|
||||
function assignAwsSdk(module) {
|
||||
if (!module) {
|
||||
return;
|
||||
}
|
||||
|
||||
awsSdk = {
|
||||
S3Client: module.S3Client,
|
||||
CreateBucketCommand: module.CreateBucketCommand,
|
||||
CreateMultipartUploadCommand: module.CreateMultipartUploadCommand,
|
||||
UploadPartCommand: module.UploadPartCommand,
|
||||
CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand: module.AbortMultipartUploadCommand,
|
||||
HeadBucketCommand: module.HeadBucketCommand,
|
||||
PutBucketCorsCommand: module.PutBucketCorsCommand,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAwsSdkModule() {
|
||||
if (awsSdk || awsSdkLoadError) {
|
||||
return awsSdk;
|
||||
}
|
||||
|
||||
if (!awsSdkLoadPromise) {
|
||||
if (disableNetworkImports) {
|
||||
awsSdkLoadError = new Error(
|
||||
"Cloudflare R2 SDK network import has been disabled."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
awsSdkLoadPromise = import(AWS_SDK_SPECIFIER)
|
||||
.then((module) => {
|
||||
assignAwsSdk(module);
|
||||
return awsSdk;
|
||||
})
|
||||
.catch((error) => {
|
||||
awsSdkLoadError = error || new Error("Failed to load R2 SDK module");
|
||||
throw awsSdkLoadError;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await awsSdkLoadPromise;
|
||||
} catch (error) {
|
||||
awsSdkLoadError = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requireAwsSdk() {
|
||||
if (awsSdk) {
|
||||
return awsSdk;
|
||||
}
|
||||
|
||||
if (awsSdkLoadError) {
|
||||
throw awsSdkLoadError;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Cloudflare R2 SDK has not finished loading. Call ensureR2SdkLoaded() first."
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureR2SdkLoaded() {
|
||||
return loadAwsSdkModule();
|
||||
}
|
||||
|
||||
export function getR2SdkLoadError() {
|
||||
return awsSdkLoadError;
|
||||
}
|
||||
|
||||
export function isR2SdkAvailable() {
|
||||
return Boolean(awsSdk);
|
||||
}
|
||||
|
||||
if (!disableNetworkImports) {
|
||||
// Begin loading immediately in environments that support network imports.
|
||||
loadAwsSdkModule().catch(() => {
|
||||
// Errors are captured for later inspection by getR2SdkLoadError().
|
||||
});
|
||||
}
|
||||
|
||||
function computeCacheControl(key) {
|
||||
if (!key) {
|
||||
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");
|
||||
}
|
||||
|
||||
const { S3Client } = requireAwsSdk();
|
||||
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBucketExists({ s3, bucket }) {
|
||||
const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk();
|
||||
|
||||
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 { PutBucketCorsCommand } = requireAwsSdk();
|
||||
|
||||
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 {
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
} = requireAwsSdk();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+506
-425
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,885 @@
|
||||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../../magnet.js";
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
|
||||
export class EditModal {
|
||||
constructor({
|
||||
removeTrackingScripts,
|
||||
setGlobalModalState,
|
||||
sanitizers = {},
|
||||
escapeHtml,
|
||||
showError,
|
||||
getMode,
|
||||
eventTarget,
|
||||
container,
|
||||
} = {}) {
|
||||
this.removeTrackingScripts =
|
||||
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
|
||||
this.setGlobalModalState =
|
||||
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
||||
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
||||
this.showError = typeof showError === "function" ? showError : () => {};
|
||||
this.getMode = typeof getMode === "function" ? getMode : () => "live";
|
||||
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
this.container = container || null;
|
||||
|
||||
this.sanitizers = {
|
||||
text:
|
||||
typeof sanitizers.text === "function"
|
||||
? sanitizers.text
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
url:
|
||||
typeof sanitizers.url === "function"
|
||||
? sanitizers.url
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
magnet:
|
||||
typeof sanitizers.magnet === "function"
|
||||
? sanitizers.magnet
|
||||
: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
checkbox:
|
||||
typeof sanitizers.checkbox === "function"
|
||||
? sanitizers.checkbox
|
||||
: (value) => !!value,
|
||||
};
|
||||
|
||||
this.root = null;
|
||||
this.overlay = null;
|
||||
this.form = null;
|
||||
this.closeButton = null;
|
||||
this.cancelButton = null;
|
||||
this.submitButton = null;
|
||||
this.fieldButtons = [];
|
||||
this.fields = {};
|
||||
this.visibility = {
|
||||
container: null,
|
||||
buttons: [],
|
||||
};
|
||||
|
||||
this.activeVideo = null;
|
||||
this.isVisible = false;
|
||||
this.eventsBound = false;
|
||||
this.loadPromise = null;
|
||||
|
||||
this.pendingSubmit = false;
|
||||
this.pendingSubmitVideo = null;
|
||||
|
||||
this.nip71FormManager = new Nip71FormManager();
|
||||
this.nip71SectionKey = "edit";
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
this.eventTarget.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener, options) {
|
||||
this.eventTarget.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
emit(type, detail) {
|
||||
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
async load({ container } = {}) {
|
||||
if (this.root) {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
if (this.loadPromise) {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
this.loadPromise = (async () => {
|
||||
const targetContainer =
|
||||
container || this.container || document.getElementById("modalContainer");
|
||||
if (!targetContainer) {
|
||||
throw new Error("Modal container element not found!");
|
||||
}
|
||||
|
||||
let modal = targetContainer.querySelector("#editVideoModal");
|
||||
if (!modal) {
|
||||
const response = await fetch("components/edit-video-modal.html");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
this.removeTrackingScripts(wrapper);
|
||||
targetContainer.appendChild(wrapper);
|
||||
modal = wrapper.querySelector("#editVideoModal");
|
||||
}
|
||||
|
||||
if (!modal) {
|
||||
throw new Error("Edit video modal markup missing after load.");
|
||||
}
|
||||
|
||||
this.container = targetContainer;
|
||||
this.root = modal;
|
||||
|
||||
this.cacheElements(modal);
|
||||
if (!this.eventsBound) {
|
||||
this.bindEvents();
|
||||
this.eventsBound = true;
|
||||
}
|
||||
this.reset();
|
||||
|
||||
return this.root;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this.loadPromise;
|
||||
} finally {
|
||||
this.loadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
cacheElements(context) {
|
||||
this.overlay = context.querySelector("#editVideoModalOverlay") || null;
|
||||
this.form = context.querySelector("#editVideoForm") || null;
|
||||
this.closeButton = context.querySelector("#closeEditVideoModal") || null;
|
||||
this.cancelButton = context.querySelector("#cancelEditVideo") || null;
|
||||
this.submitButton = context.querySelector("#submitEditVideo") || null;
|
||||
|
||||
this.updateSubmitButtonState();
|
||||
|
||||
this.fieldButtons = Array.from(context.querySelectorAll("[data-edit-target]"));
|
||||
|
||||
this.fields = {
|
||||
title: context.querySelector("#editVideoTitle") || null,
|
||||
url: context.querySelector("#editVideoUrl") || null,
|
||||
magnet: context.querySelector("#editVideoMagnet") || null,
|
||||
ws: context.querySelector("#editVideoWs") || null,
|
||||
xs: context.querySelector("#editVideoXs") || null,
|
||||
thumbnail: context.querySelector("#editVideoThumbnail") || null,
|
||||
description: context.querySelector("#editVideoDescription") || null,
|
||||
isPrivate: context.querySelector("#editVideoIsPrivate") || null,
|
||||
enableComments: context.querySelector("#editEnableComments") || null,
|
||||
};
|
||||
|
||||
this.visibility = {
|
||||
container: context.querySelector("[data-visibility-toggle]") || null,
|
||||
buttons: Array.from(
|
||||
context.querySelectorAll("[data-visibility-option]") || []
|
||||
),
|
||||
helper: context.querySelector("[data-visibility-helper]") || null,
|
||||
helperDefault: "",
|
||||
};
|
||||
|
||||
if (this.visibility.helper) {
|
||||
this.visibility.helperDefault = this.visibility.helper.textContent || "";
|
||||
}
|
||||
|
||||
const nip71Context = this.form || context;
|
||||
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.form) {
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
});
|
||||
}
|
||||
|
||||
const cancelHandler = (event) => {
|
||||
event?.preventDefault?.();
|
||||
this.close({ emitCancel: true });
|
||||
};
|
||||
|
||||
if (this.overlay) {
|
||||
this.overlay.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (this.closeButton) {
|
||||
this.closeButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (this.cancelButton) {
|
||||
this.cancelButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (Array.isArray(this.fieldButtons)) {
|
||||
this.fieldButtons.forEach((button) => {
|
||||
if (!button || button.dataset.editListenerAttached === "true") {
|
||||
return;
|
||||
}
|
||||
button.addEventListener("click", (event) => {
|
||||
this.handleEditFieldToggle(event);
|
||||
});
|
||||
button.dataset.editListenerAttached = "true";
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(this.visibility?.buttons)) {
|
||||
this.visibility.buttons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
const option = event?.currentTarget?.dataset?.visibilityOption;
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
this.setVisibility(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fields.isPrivate) {
|
||||
this.fields.isPrivate.addEventListener("change", () => {
|
||||
this.handleIsPrivateChange({ emit: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fields.enableComments) {
|
||||
this.fields.enableComments.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
const value = this.sanitizers.checkbox(target?.checked);
|
||||
const detail = {
|
||||
field: "enableComments",
|
||||
value,
|
||||
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
||||
};
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
});
|
||||
}
|
||||
|
||||
this.nip71FormManager.bindSection(this.nip71SectionKey);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.root) {
|
||||
this.activeVideo = null;
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
this.setSubmitState({ pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = Object.values(this.fields);
|
||||
inputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (input.type === "checkbox") {
|
||||
const defaultAttr = input.dataset?.defaultChecked;
|
||||
const defaultChecked =
|
||||
defaultAttr === "true"
|
||||
? true
|
||||
: defaultAttr === "false"
|
||||
? false
|
||||
: input.defaultChecked;
|
||||
input.checked = defaultChecked;
|
||||
input.disabled = false;
|
||||
input.removeAttribute("disabled");
|
||||
delete input.dataset.isEditing;
|
||||
} else {
|
||||
input.value = "";
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
delete input.dataset.isEditing;
|
||||
}
|
||||
delete input.dataset.originalValue;
|
||||
});
|
||||
|
||||
if (Array.isArray(this.fieldButtons)) {
|
||||
this.fieldButtons.forEach((button) => {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
});
|
||||
}
|
||||
|
||||
this.handleIsPrivateChange({ emit: false });
|
||||
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
this.activeVideo = null;
|
||||
this.setSubmitState({ pending: false });
|
||||
}
|
||||
|
||||
async open(video) {
|
||||
await this.load();
|
||||
|
||||
if (!video) {
|
||||
throw new Error("No video provided for editing.");
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
const magnetSource = video.magnet || video.rawMagnet || "";
|
||||
const magnetHints = extractMagnetHints(magnetSource);
|
||||
const effectiveWs = video.ws || magnetHints.ws || "";
|
||||
const effectiveXs = video.xs || magnetHints.xs || "";
|
||||
const enableCommentsValue =
|
||||
typeof video.enableComments === "boolean" ? video.enableComments : true;
|
||||
const isPrivateValue = video.isPrivate === true;
|
||||
|
||||
const editContext = {
|
||||
...video,
|
||||
ws: effectiveWs,
|
||||
xs: effectiveXs,
|
||||
enableComments: enableCommentsValue,
|
||||
isPrivate: isPrivateValue,
|
||||
};
|
||||
|
||||
this.applyVideoToForm(editContext);
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
if (video.nip71 && typeof video.nip71 === "object") {
|
||||
this.nip71FormManager.hydrateSection(this.nip71SectionKey, video.nip71);
|
||||
}
|
||||
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
|
||||
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
|
||||
this.activeVideo = editContext;
|
||||
|
||||
if (this.root) {
|
||||
this.root.classList.remove("hidden");
|
||||
this.isVisible = true;
|
||||
this.setGlobalModalState("editVideo", true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
applyVideoToForm(editContext) {
|
||||
if (!editContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldMap = {
|
||||
title: editContext.title || "",
|
||||
url: editContext.url || "",
|
||||
magnet: editContext.magnet || "",
|
||||
ws: editContext.ws || "",
|
||||
xs: editContext.xs || "",
|
||||
thumbnail: editContext.thumbnail || "",
|
||||
description: editContext.description || "",
|
||||
isPrivate: editContext.isPrivate,
|
||||
enableComments: editContext.enableComments,
|
||||
};
|
||||
|
||||
Object.entries(fieldMap).forEach(([key, rawValue]) => {
|
||||
const input = this.fields[key];
|
||||
const targetId = this.fieldIdForKey(key);
|
||||
const button = this.fieldButtons.find(
|
||||
(item) => item?.dataset?.editTarget === targetId
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
if (button) {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
input.id = targetId;
|
||||
}
|
||||
|
||||
const isCheckbox = input.type === "checkbox";
|
||||
if (isCheckbox) {
|
||||
const hasValue = rawValue !== undefined;
|
||||
const boolValue = rawValue === true;
|
||||
input.checked = boolValue;
|
||||
input.disabled = hasValue;
|
||||
if (hasValue) {
|
||||
input.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
input.removeAttribute("disabled");
|
||||
}
|
||||
input.dataset.originalValue = boolValue ? "true" : "false";
|
||||
input.dataset.isEditing = hasValue ? "false" : "true";
|
||||
if (button) {
|
||||
if (hasValue) {
|
||||
button.classList.remove("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const value = typeof rawValue === "string" ? rawValue : "";
|
||||
const hasValue = value.trim().length > 0;
|
||||
|
||||
input.value = value;
|
||||
input.dataset.originalValue = value;
|
||||
input.dataset.isEditing = hasValue ? "false" : "true";
|
||||
if (hasValue) {
|
||||
input.readOnly = true;
|
||||
input.setAttribute("readonly", "readonly");
|
||||
input.classList.add("locked-input");
|
||||
} else {
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
}
|
||||
|
||||
if (button) {
|
||||
if (hasValue) {
|
||||
button.classList.remove("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.handleIsPrivateChange({ emit: false });
|
||||
}
|
||||
|
||||
fieldIdForKey(key) {
|
||||
switch (key) {
|
||||
case "title":
|
||||
return "editVideoTitle";
|
||||
case "url":
|
||||
return "editVideoUrl";
|
||||
case "magnet":
|
||||
return "editVideoMagnet";
|
||||
case "ws":
|
||||
return "editVideoWs";
|
||||
case "xs":
|
||||
return "editVideoXs";
|
||||
case "thumbnail":
|
||||
return "editVideoThumbnail";
|
||||
case "description":
|
||||
return "editVideoDescription";
|
||||
case "isPrivate":
|
||||
return "editVideoIsPrivate";
|
||||
case "enableComments":
|
||||
return "editEnableComments";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close({ emitCancel = false } = {}) {
|
||||
if (this.root) {
|
||||
this.root.classList.add("hidden");
|
||||
}
|
||||
this.isVisible = false;
|
||||
this.setGlobalModalState("editVideo", false);
|
||||
const cancelledVideo = this.activeVideo;
|
||||
this.reset();
|
||||
|
||||
if (emitCancel) {
|
||||
this.emit("video:edit-cancel", {
|
||||
videoId: this.escapeHtml(cancelledVideo?.id || ""),
|
||||
pubkey: this.escapeHtml(cancelledVideo?.pubkey || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEditFieldToggle(event) {
|
||||
const button = event?.currentTarget;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = button.dataset?.editTarget;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = this.root?.querySelector(`#${targetId}`);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCheckbox = input.type === "checkbox";
|
||||
const isLocked = isCheckbox
|
||||
? input.disabled === true
|
||||
: input.readOnly === true;
|
||||
|
||||
if (isLocked) {
|
||||
if (isCheckbox) {
|
||||
input.disabled = false;
|
||||
input.removeAttribute("disabled");
|
||||
input.dataset.isEditing = "true";
|
||||
} else {
|
||||
input.disabled = false;
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
input.dataset.isEditing = "true";
|
||||
}
|
||||
button.dataset.mode = "editing";
|
||||
button.textContent = "Restore original";
|
||||
if (!isCheckbox && typeof input.focus === "function") {
|
||||
input.focus();
|
||||
if (typeof input.setSelectionRange === "function") {
|
||||
const length = input.value.length;
|
||||
try {
|
||||
input.setSelectionRange(length, length);
|
||||
} catch (error) {
|
||||
// Ignore selection errors (e.g. unsupported input types)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input === this.fields.isPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const originalValue = input.dataset?.originalValue || "";
|
||||
|
||||
if (isCheckbox) {
|
||||
input.checked = originalValue === "true";
|
||||
input.disabled = true;
|
||||
input.setAttribute("disabled", "disabled");
|
||||
input.dataset.isEditing = "false";
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
if (input === this.fields.isPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = originalValue;
|
||||
|
||||
if (originalValue) {
|
||||
input.readOnly = true;
|
||||
input.setAttribute("readonly", "readonly");
|
||||
input.classList.add("locked-input");
|
||||
input.dataset.isEditing = "false";
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
} else {
|
||||
input.readOnly = false;
|
||||
input.removeAttribute("readonly");
|
||||
input.classList.remove("locked-input");
|
||||
input.dataset.isEditing = "true";
|
||||
button.classList.add("hidden");
|
||||
button.dataset.mode = "locked";
|
||||
button.textContent = "Edit field";
|
||||
}
|
||||
}
|
||||
|
||||
setVisibility(option, { emit = true } = {}) {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = typeof option === "string" ? option.toLowerCase() : "";
|
||||
if (normalized !== "public" && normalized !== "private") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkbox.disabled) {
|
||||
this.updateVisibilityToggleUI();
|
||||
return;
|
||||
}
|
||||
|
||||
const wantPrivate = normalized === "private";
|
||||
if (checkbox.checked === wantPrivate) {
|
||||
this.updateVisibilityToggleUI();
|
||||
return;
|
||||
}
|
||||
|
||||
checkbox.checked = wantPrivate;
|
||||
this.handleIsPrivateChange({ emit });
|
||||
}
|
||||
|
||||
updateVisibilityToggleUI() {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
const buttons = Array.isArray(this.visibility?.buttons)
|
||||
? this.visibility.buttons
|
||||
: [];
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPrivate = checkbox.checked === true;
|
||||
const disabled = checkbox.disabled === true;
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const option = button.dataset?.visibilityOption || "";
|
||||
const normalized = option.toLowerCase();
|
||||
const isActive =
|
||||
(normalized === "private" && isPrivate) ||
|
||||
(normalized === "public" && !isPrivate);
|
||||
button.dataset.active = isActive ? "true" : "false";
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
if (disabled) {
|
||||
button.classList.add("is-disabled");
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.tabIndex = -1;
|
||||
} else {
|
||||
button.classList.remove("is-disabled");
|
||||
button.removeAttribute("aria-disabled");
|
||||
button.tabIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.visibility?.container) {
|
||||
this.visibility.container.dataset.state = isPrivate ? "private" : "public";
|
||||
if (disabled) {
|
||||
this.visibility.container.dataset.disabled = "true";
|
||||
} else {
|
||||
delete this.visibility.container.dataset.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
const helper = this.visibility?.helper;
|
||||
if (helper) {
|
||||
const privateCopy = this.visibility.helperDefault || helper.textContent;
|
||||
const publicCopy =
|
||||
"Public notes appear in feeds for everyone who can view your channel.";
|
||||
helper.textContent = isPrivate ? privateCopy : publicCopy;
|
||||
}
|
||||
}
|
||||
|
||||
handleIsPrivateChange({ emit = true } = {}) {
|
||||
const checkbox = this.fields.isPrivate;
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateVisibilityToggleUI();
|
||||
|
||||
if (!emit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this.sanitizers.checkbox(checkbox.checked);
|
||||
const detail = {
|
||||
field: "isPrivate",
|
||||
value,
|
||||
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
||||
};
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
}
|
||||
|
||||
cloneNip71Metadata(metadata) {
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(metadata));
|
||||
} catch (error) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.pendingSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeVideo || !this.root) {
|
||||
this.showError("No video selected for editing.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSubmitState({ pending: true, video: this.activeVideo });
|
||||
|
||||
const fieldValue = (key) => {
|
||||
const input = this.fields[key];
|
||||
if (!input || typeof input.value !== "string") {
|
||||
return "";
|
||||
}
|
||||
if (key === "url" || key === "ws" || key === "xs") {
|
||||
return this.sanitizers.url(input.value);
|
||||
}
|
||||
if (key === "magnet") {
|
||||
return this.sanitizers.magnet(input.value);
|
||||
}
|
||||
return this.sanitizers.text(input.value);
|
||||
};
|
||||
|
||||
const original = this.pendingSubmitVideo || this.activeVideo;
|
||||
|
||||
const titleInput = this.fields.title;
|
||||
const urlInput = this.fields.url;
|
||||
const magnetInput = this.fields.magnet;
|
||||
const wsInput = this.fields.ws;
|
||||
const xsInput = this.fields.xs;
|
||||
const thumbnailInput = this.fields.thumbnail;
|
||||
const descriptionInput = this.fields.description;
|
||||
const commentsInput = this.fields.enableComments;
|
||||
const privateInput = this.fields.isPrivate;
|
||||
|
||||
const newTitle = fieldValue("title");
|
||||
const newUrl = fieldValue("url");
|
||||
const newMagnet = fieldValue("magnet");
|
||||
const newWs = fieldValue("ws");
|
||||
const newXs = fieldValue("xs");
|
||||
const newThumbnail = fieldValue("thumbnail");
|
||||
const newDescription = fieldValue("description");
|
||||
|
||||
const isEditing = (input) => {
|
||||
if (!input) {
|
||||
return true;
|
||||
}
|
||||
if (input.dataset?.isEditing === "true") {
|
||||
return true;
|
||||
}
|
||||
if (input.type === "checkbox") {
|
||||
return input.disabled === false;
|
||||
}
|
||||
return input.readOnly === false;
|
||||
};
|
||||
|
||||
const titleWasEdited = isEditing(titleInput);
|
||||
const urlWasEdited = isEditing(urlInput);
|
||||
const magnetWasEdited = isEditing(magnetInput);
|
||||
|
||||
const finalTitle = titleWasEdited ? newTitle : original.title || "";
|
||||
const finalUrl = urlWasEdited ? newUrl : original.url || "";
|
||||
const shouldUseOriginalWs = wsInput ? wsInput.readOnly !== false : true;
|
||||
const shouldUseOriginalXs = xsInput ? xsInput.readOnly !== false : true;
|
||||
let finalWs = shouldUseOriginalWs ? original.ws || "" : newWs;
|
||||
let finalXs = shouldUseOriginalXs ? original.xs || "" : newXs;
|
||||
let finalMagnet = magnetWasEdited ? newMagnet : original.magnet || "";
|
||||
const finalThumbnail = isEditing(thumbnailInput)
|
||||
? newThumbnail
|
||||
: original.thumbnail || "";
|
||||
const finalDescription = isEditing(descriptionInput)
|
||||
? newDescription
|
||||
: original.description || "";
|
||||
const originalEnableComments =
|
||||
typeof original.enableComments === "boolean" ? original.enableComments : true;
|
||||
const originalIsPrivate = original.isPrivate === true;
|
||||
|
||||
const currentNip71 = this.cloneNip71Metadata(
|
||||
this.nip71FormManager.collectSection(this.nip71SectionKey)
|
||||
);
|
||||
const hasImetaSource = Array.isArray(currentNip71?.imeta)
|
||||
? currentNip71.imeta.some((variant) => {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return false;
|
||||
}
|
||||
const hasUrl = typeof variant.url === "string" && variant.url.trim();
|
||||
const hasMagnet = typeof variant.x === "string" && variant.x.trim();
|
||||
return Boolean(hasUrl || hasMagnet);
|
||||
})
|
||||
: false;
|
||||
|
||||
let finalEnableComments = originalEnableComments;
|
||||
if (commentsInput) {
|
||||
if (commentsInput.disabled) {
|
||||
finalEnableComments = commentsInput.dataset.originalValue === "true";
|
||||
} else {
|
||||
finalEnableComments = this.sanitizers.checkbox(commentsInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
let finalIsPrivate = originalIsPrivate;
|
||||
if (privateInput) {
|
||||
if (privateInput.disabled) {
|
||||
finalIsPrivate = privateInput.dataset.originalValue === "true";
|
||||
} else {
|
||||
finalIsPrivate = this.sanitizers.checkbox(privateInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalTitle || (!finalUrl && !finalMagnet && !hasImetaSource)) {
|
||||
this.showError(
|
||||
"Title and at least one of URL, Magnet, or a NIP-71 media variant is required."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
|
||||
this.showError("Hosted video URLs must use HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalMagnet) {
|
||||
const normalizedMagnet = normalizeAndAugmentMagnet(finalMagnet, {
|
||||
ws: finalWs,
|
||||
xs: finalXs,
|
||||
});
|
||||
finalMagnet = normalizedMagnet;
|
||||
const hints = extractMagnetHints(normalizedMagnet);
|
||||
finalWs = hints.ws;
|
||||
finalXs = hints.xs;
|
||||
} else {
|
||||
finalWs = "";
|
||||
finalXs = "";
|
||||
}
|
||||
|
||||
const updatedData = {
|
||||
version: original.version || 2,
|
||||
title: finalTitle,
|
||||
magnet: finalMagnet,
|
||||
url: finalUrl,
|
||||
thumbnail: finalThumbnail,
|
||||
description: finalDescription,
|
||||
mode: this.getMode(),
|
||||
ws: finalWs,
|
||||
xs: finalXs,
|
||||
wsEdited: !shouldUseOriginalWs,
|
||||
xsEdited: !shouldUseOriginalXs,
|
||||
urlEdited: urlWasEdited,
|
||||
magnetEdited: magnetWasEdited,
|
||||
enableComments: finalEnableComments,
|
||||
isPrivate: finalIsPrivate,
|
||||
};
|
||||
|
||||
const currentNip71Json = JSON.stringify(currentNip71);
|
||||
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
|
||||
updatedData.nip71 = currentNip71;
|
||||
updatedData.nip71Edited = nip71Edited;
|
||||
|
||||
const originalEvent = {
|
||||
id: this.sanitizers.text(original.id || ""),
|
||||
pubkey: this.sanitizers.text(original.pubkey || ""),
|
||||
videoRootId: this.sanitizers.text(original.videoRootId || ""),
|
||||
};
|
||||
|
||||
this.emit("video:edit-submit", {
|
||||
originalEvent,
|
||||
updatedData,
|
||||
video: { ...original },
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitState({ pending = false, video } = {}) {
|
||||
const nextPending = Boolean(pending);
|
||||
if (nextPending) {
|
||||
this.pendingSubmitVideo = video || this.activeVideo || this.pendingSubmitVideo;
|
||||
} else {
|
||||
this.pendingSubmitVideo = null;
|
||||
}
|
||||
|
||||
this.pendingSubmit = nextPending;
|
||||
this.updateSubmitButtonState();
|
||||
}
|
||||
|
||||
updateSubmitButtonState() {
|
||||
if (!this.submitButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingSubmit) {
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
this.submitButton.disabled = false;
|
||||
this.submitButton.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user