mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
763 Commits
sync-readi
...
v0.10.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e821aaf058 | ||
|
|
a84d439489 | ||
|
|
67bf7e017d | ||
|
|
e47419a0b8 | ||
|
|
2dda52c30f | ||
|
|
2e0a493243 | ||
|
|
2e955e9bed | ||
|
|
538cbd2296 | ||
|
|
c17eab5a47 | ||
|
|
b3c61ba635 | ||
|
|
3bfa750a0c | ||
|
|
d1f7e549c2 | ||
|
|
0fec120410 | ||
|
|
9b21075a9b | ||
|
|
4f78ee4794 | ||
|
|
8bb871913b | ||
|
|
49eb6855ca | ||
|
|
748b2e1631 | ||
|
|
9fa83a2a1c | ||
|
|
d45705e8e4 | ||
|
|
83c170b4e2 | ||
|
|
8459853c43 | ||
|
|
f7eeb080e1 | ||
|
|
2769b2dba7 | ||
|
|
46636b8e6a | ||
|
|
92a85761ef | ||
|
|
f6a325f7e9 | ||
|
|
a501fa816f | ||
|
|
5ece80b8e9 | ||
|
|
87c017b2c2 | ||
|
|
550ee415f0 | ||
|
|
aaaf226623 | ||
|
|
23ce0c9d4c | ||
|
|
dddf8575c4 | ||
|
|
3ab0610e1e | ||
|
|
e40f820fdc | ||
|
|
3f82bc7873 | ||
|
|
b913cc4d7f | ||
|
|
bc1aed30b4 | ||
|
|
9a801975aa | ||
|
|
f3e44edd51 | ||
|
|
0be6aa81ce | ||
|
|
c7b885cfcd | ||
|
|
11041df1fb | ||
|
|
89273e2a03 | ||
|
|
0610454e74 | ||
|
|
a02413a7cb | ||
|
|
0bc84e7c6c | ||
|
|
a1e28c6bc9 | ||
|
|
a1a7f0e4a4 | ||
|
|
cde8e30ab2 | ||
|
|
aa7e532950 | ||
|
|
c9208cfff2 | ||
|
|
2fb4132342 | ||
|
|
81180c8ba8 | ||
|
|
1c48adf44e | ||
|
|
366e10b23a | ||
|
|
bb66823915 | ||
|
|
f09973c858 | ||
|
|
d03726801d | ||
|
|
164e941a1f | ||
|
|
6def58f128 | ||
|
|
347e23ff6f | ||
|
|
934768ebf2 | ||
|
|
60e9ede9cf | ||
|
|
c70e6bc2aa | ||
|
|
ab8665815b | ||
|
|
1929b50892 | ||
|
|
160dca628d | ||
|
|
c04ba0c787 | ||
|
|
479d9314bd | ||
|
|
b9d5e501f4 | ||
|
|
43e0dd76c4 | ||
|
|
dc9a49e895 | ||
|
|
3200bdf378 | ||
|
|
2254586960 | ||
|
|
18c78c19be | ||
|
|
167d5f2041 | ||
|
|
cce7507e50 | ||
|
|
e83d4dbcdb | ||
|
|
a5bdde68fc | ||
|
|
5551cc3a55 | ||
|
|
145ff138b0 | ||
|
|
5bd5686805 | ||
|
|
d2c1a16ca6 | ||
|
|
b8242312b5 | ||
|
|
96ef227f79 | ||
|
|
30ed5fb436 | ||
|
|
42d7143845 | ||
|
|
f02bc21faf | ||
|
|
0f5d42465d | ||
|
|
004367bab6 | ||
|
|
312adea9f9 | ||
|
|
a081b26333 | ||
|
|
51e48804fe | ||
|
|
e08ce0e477 | ||
|
|
2791c69ebe | ||
|
|
96451e6173 | ||
|
|
d20cc684c3 | ||
|
|
4316c46a4d | ||
|
|
e382310c88 | ||
|
|
e6b99490dd | ||
|
|
09ee05861d | ||
|
|
205988a6b0 | ||
|
|
8012752a39 | ||
|
|
c3302da11d | ||
|
|
60e1e3c821 | ||
|
|
6c2247249a | ||
|
|
33a31df2b4 | ||
|
|
f9dda1c5d4 | ||
|
|
6522a2871c | ||
|
|
f39b926e7b | ||
|
|
144cf5cbd1 | ||
|
|
4b9de7cd07 | ||
|
|
2be58332bb | ||
|
|
6fc93cbd0f | ||
|
|
5df426a863 | ||
|
|
8ca4671bea | ||
|
|
ad1a808c6d | ||
|
|
ae118a0581 | ||
|
|
3cddcd850e | ||
|
|
cadf4dcb48 | ||
|
|
47d257faaf | ||
|
|
f542cee4cc | ||
|
|
8274eb26c2 | ||
|
|
35018fef91 | ||
|
|
1fd08bb64a | ||
|
|
d953542c93 | ||
|
|
8c0b73ad0c | ||
|
|
a5d2ed8b07 | ||
|
|
67fec91ab3 | ||
|
|
868fe68ce2 | ||
|
|
66c4bfc449 | ||
|
|
29918f78f9 | ||
|
|
18fcf6064e | ||
|
|
35766d5691 | ||
|
|
7450ba4251 | ||
|
|
95c770c083 | ||
|
|
14a7e1138e | ||
|
|
9c45c71c8a | ||
|
|
23b9224272 | ||
|
|
bcd4a12542 | ||
|
|
d82e22ce1c | ||
|
|
ea5c173745 | ||
|
|
a214c487cc | ||
|
|
43f56fc29a | ||
|
|
cfbc3efeeb | ||
|
|
bb9e98ff16 | ||
|
|
073bb3867f | ||
|
|
1ac7fb26b2 | ||
|
|
a551234a29 | ||
|
|
227f062456 | ||
|
|
6c42ee88ea | ||
|
|
fc138f3ceb | ||
|
|
831f701c04 | ||
|
|
94b9d89225 | ||
|
|
2793a6dd44 | ||
|
|
9086692e29 | ||
|
|
f8c4bbb99c | ||
|
|
b14842c6fe | ||
|
|
7cdf0673bd | ||
|
|
bbed20d679 | ||
|
|
7594d30fd2 | ||
|
|
67506d9040 | ||
|
|
e2d0bc2acf | ||
|
|
2283f4ec08 | ||
|
|
463ac8f44c | ||
|
|
e2de6f2d91 | ||
|
|
fdb52fe3b2 | ||
|
|
ae14064822 | ||
|
|
5526bfc425 | ||
|
|
b3f4b03229 | ||
|
|
b92f5716dc | ||
|
|
177f8c1e70 | ||
|
|
0407769206 | ||
|
|
eb75e7722d | ||
|
|
81aa414d2e | ||
|
|
c82fb65745 | ||
|
|
cc1b9f042f | ||
|
|
c2bf4b4a9a | ||
|
|
13a47e4fdc | ||
|
|
24b652847c | ||
|
|
c623dc8d84 | ||
|
|
31987010b8 | ||
|
|
b3206d5e79 | ||
|
|
34f44c59b5 | ||
|
|
a51fbd25d7 | ||
|
|
95f6949ab7 | ||
|
|
1e613bd2a2 | ||
|
|
95b882b0d1 | ||
|
|
be00f1434d | ||
|
|
568890e131 | ||
|
|
f000ac3be1 | ||
|
|
2fed1cc6e7 | ||
|
|
4bdcfcaeb4 | ||
|
|
a5494ba15c | ||
|
|
64aad42be3 | ||
|
|
3673849a9a | ||
|
|
c6795f7c18 | ||
|
|
b27f26b639 | ||
|
|
975399e293 | ||
|
|
53b8356373 | ||
|
|
8c5225b271 | ||
|
|
dfac7a5089 | ||
|
|
9fe09b813b | ||
|
|
ea30c136f2 | ||
|
|
623856ffe9 | ||
|
|
d08071def2 | ||
|
|
556e8f2f7d | ||
|
|
9ab6847501 | ||
|
|
31afe3792e | ||
|
|
ebe8ecf63b | ||
|
|
c418000a0c | ||
|
|
15fd19f6a4 | ||
|
|
2a44b4e3c0 | ||
|
|
aa7807e3d2 | ||
|
|
359d3d0dd6 | ||
|
|
d40b3c0048 | ||
|
|
7b4ca50b16 | ||
|
|
76e001aba4 | ||
|
|
0b42aeb383 | ||
|
|
a4554e5176 | ||
|
|
2e844fc26b | ||
|
|
8c0a4cac16 | ||
|
|
c6eccc9589 | ||
|
|
2e5536c331 | ||
|
|
fc025b9579 | ||
|
|
88db14c352 | ||
|
|
49c5f0c3ad | ||
|
|
dbed4ad253 | ||
|
|
b117b1e6cf | ||
|
|
627ffd6c5d | ||
|
|
0d53027818 | ||
|
|
811d96dee0 | ||
|
|
21335d56dc | ||
|
|
f7e50023a3 | ||
|
|
6b09212fe9 | ||
|
|
cecff6b8d5 | ||
|
|
2b061afa47 | ||
|
|
7516013e67 | ||
|
|
567641de77 | ||
|
|
4e86907663 | ||
|
|
ec34e00573 | ||
|
|
5e6c8b7516 | ||
|
|
e50af42c96 | ||
|
|
73470987be | ||
|
|
31e203825d | ||
|
|
6f9c0a35e2 | ||
|
|
96f59a54f3 | ||
|
|
87c0a0454b | ||
|
|
77c2ef1794 | ||
|
|
8d08911bd3 | ||
|
|
31b005a989 | ||
|
|
337bfe5432 | ||
|
|
2f275375f7 | ||
|
|
27cbcb56ec | ||
|
|
7f150003b5 | ||
|
|
1f50d8e1b6 | ||
|
|
f53decef16 | ||
|
|
f272943b64 | ||
|
|
49745e1b8a | ||
|
|
470f4fb34e | ||
|
|
8cde36c08c | ||
|
|
c21f96f5bb | ||
|
|
c9fef5804b | ||
|
|
8337622a22 | ||
|
|
572f0fed6f | ||
|
|
27a55ec329 | ||
|
|
7ba362a3bb | ||
|
|
dc1844907e | ||
|
|
28123b5e13 | ||
|
|
d9eb87aa5c | ||
|
|
a0ff0daf9d | ||
|
|
8c3baf1416 | ||
|
|
e0c169edbc | ||
|
|
d2181ad772 | ||
|
|
8ff3f08d8c | ||
|
|
e17e1bc824 | ||
|
|
948674ae8c | ||
|
|
431f14f56d | ||
|
|
4cc9d557a0 | ||
|
|
cc60f9584a | ||
|
|
94f1f9035b | ||
|
|
e5b1594933 | ||
|
|
2bf9b9789b | ||
|
|
d3405a4029 | ||
|
|
763f7bef4d | ||
|
|
e8e629f4e1 | ||
|
|
a0829e834f | ||
|
|
ff938aa384 | ||
|
|
3991bfeeb2 | ||
|
|
e8c35c8914 | ||
|
|
46345c154b | ||
|
|
f43dae92aa | ||
|
|
99c164a5e9 | ||
|
|
569b4357f2 | ||
|
|
de287c625b | ||
|
|
1424f6ebc5 | ||
|
|
b0a368fc64 | ||
|
|
6f8cf641b7 | ||
|
|
23b4c3475f | ||
|
|
5633dc640c | ||
|
|
0f1dfa445a | ||
|
|
ab5225de50 | ||
|
|
b89705cf43 | ||
|
|
740dd53299 | ||
|
|
eb61553c20 | ||
|
|
8b708535ca | ||
|
|
f77761c002 | ||
|
|
b900666eb8 | ||
|
|
2639c78957 | ||
|
|
8320911bc9 | ||
|
|
00d6bd4c46 | ||
|
|
cd377b6f26 | ||
|
|
84b0339505 | ||
|
|
12fa1db0db | ||
|
|
0919091f19 | ||
|
|
e1c04b4e7f | ||
|
|
b9642067a1 | ||
|
|
ceca37df08 | ||
|
|
dfdc5d0946 | ||
|
|
3619cd2585 | ||
|
|
f93e52611e | ||
|
|
ecb81cb151 | ||
|
|
adf73cb9d1 | ||
|
|
4202807777 | ||
|
|
1c21615103 | ||
|
|
732070e89b | ||
|
|
d9a00dd157 | ||
|
|
103be75f6e | ||
|
|
8dd4e358b4 | ||
|
|
2e8dfaee09 | ||
|
|
db3084b373 | ||
|
|
83e4a2ad4c | ||
|
|
c1d23fac7b | ||
|
|
de32310801 | ||
|
|
5c82dff8df | ||
|
|
abe2d6528a | ||
|
|
8b56fe3d6e | ||
|
|
bdce7c9358 | ||
|
|
81a4ae392f | ||
|
|
6e438b8ee2 | ||
|
|
31974e7271 | ||
|
|
676be1a932 | ||
|
|
9883f2eb1a | ||
|
|
87e46be86f | ||
|
|
b745a92a7e | ||
|
|
5a79da4024 | ||
|
|
a7d05a29f5 | ||
|
|
0740d53d37 | ||
|
|
914738abb4 | ||
|
|
4fac5f42c9 | ||
|
|
16b3668e73 | ||
|
|
f3a83256a8 | ||
|
|
0e98ddeef4 | ||
|
|
1ba375e93e | ||
|
|
5d14d25d0e | ||
|
|
616038a23a | ||
|
|
14fce2c3dc | ||
|
|
7c511de474 | ||
|
|
3a10ac8691 | ||
|
|
205879f948 | ||
|
|
bff43f4a28 | ||
|
|
2a7fffd594 | ||
|
|
50a4161e16 | ||
|
|
5fd8976097 | ||
|
|
80b26abff2 | ||
|
|
c0638851c6 | ||
|
|
9b6b14cfe8 | ||
|
|
b6ad62a3ab | ||
|
|
85d87bac29 | ||
|
|
3b31eceeab | ||
|
|
442c138d6a | ||
|
|
61e6027252 | ||
|
|
7d373015b4 | ||
|
|
32b1286079 | ||
|
|
17fdd92827 | ||
|
|
aa6aeb2723 | ||
|
|
4b0f275f57 | ||
|
|
73e2e060e3 | ||
|
|
3007ae83c2 | ||
|
|
a862eb880e | ||
|
|
016e369fb1 | ||
|
|
4f21982c48 | ||
|
|
f6d3fe9aba | ||
|
|
fc60e6b80a | ||
|
|
d9cdbb7279 | ||
|
|
401d333e0f | ||
|
|
d32a47e3c3 | ||
|
|
35efdb6d3f | ||
|
|
c7f7792d73 | ||
|
|
8aa26caae0 | ||
|
|
6c00904bd5 | ||
|
|
23526954ea | ||
|
|
9a437dd97b | ||
|
|
0baf75462c | ||
|
|
30b8f1af92 | ||
|
|
07aea9d35f | ||
|
|
41a4abff37 | ||
|
|
c9998984c3 | ||
|
|
a799709e62 | ||
|
|
18c6c3e68a | ||
|
|
5e7395652f | ||
|
|
83076e7b01 | ||
|
|
c79f4122da | ||
|
|
179fe0bbc2 | ||
|
|
20b4f2b1b2 | ||
|
|
936f9093cf | ||
|
|
3149e5b824 | ||
|
|
8619cecaf3 | ||
|
|
d40c49edb0 | ||
|
|
ce5d97fb1f | ||
|
|
ffb8031a05 | ||
|
|
d54e1072b8 | ||
|
|
55defb645c | ||
|
|
1ba9595542 | ||
|
|
340913f15f | ||
|
|
1d6595f754 | ||
|
|
6099e3c6a4 | ||
|
|
ed75bc6059 | ||
|
|
dcfc08287e | ||
|
|
35b2168f9a | ||
|
|
f8a9079e5f | ||
|
|
780996c7c5 | ||
|
|
809437faa6 | ||
|
|
36f14811ae | ||
|
|
8b95af9c49 | ||
|
|
236ade3d2f | ||
|
|
c2e882ec31 | ||
|
|
0a382e77b9 | ||
|
|
a1fd4bfc94 | ||
|
|
530cc20cba | ||
|
|
a275c0a8e3 | ||
|
|
cb43b748e4 | ||
|
|
ff9ce46448 | ||
|
|
1e6718fe1e | ||
|
|
d6a913f2a6 | ||
|
|
8030e2fa00 | ||
|
|
1ff2f28566 | ||
|
|
78457335c6 | ||
|
|
553feb10df | ||
|
|
ba5d7df3bd | ||
|
|
cf3ca2d527 | ||
|
|
06763d5307 | ||
|
|
a08e4fdc24 | ||
|
|
bc7b4ae42d | ||
|
|
4dc1894ef3 | ||
|
|
f00f26dfe0 | ||
|
|
2e59bc9375 | ||
|
|
0d50d05245 | ||
|
|
90c74a8e9d | ||
|
|
a4bad34a90 | ||
|
|
84ff24e06a | ||
|
|
aaf8a9d4fc | ||
|
|
efa6d13726 | ||
|
|
6116dd12bc | ||
|
|
210cdd41ec | ||
|
|
9378b3c9a9 | ||
|
|
973409e82a | ||
|
|
5d6f48b9a8 | ||
|
|
4921427ad4 | ||
|
|
ad8cad29d3 | ||
|
|
8d4a4a04a3 | ||
|
|
1dc44930b4 | ||
|
|
c77907f87a | ||
|
|
9345228e66 | ||
|
|
811362175c | ||
|
|
3d22e7a3cb | ||
|
|
0b0d3c2859 | ||
|
|
1f8d18071c | ||
|
|
a4afe59437 | ||
|
|
1fe3786a3d | ||
|
|
42d265731f | ||
|
|
e4b4b97874 | ||
|
|
1870c307da | ||
|
|
bcb6cfbe97 | ||
|
|
6ba1ce27b7 | ||
|
|
2f620265f4 | ||
|
|
61ae31c6a2 | ||
|
|
b0fcb0e897 | ||
|
|
3b08cd5d23 | ||
|
|
a3a00b8456 | ||
|
|
7fecc0c0c3 | ||
|
|
93d0284fd6 | ||
|
|
94d5089e33 | ||
|
|
5965bc1747 | ||
|
|
0fbf80b04f | ||
|
|
2004ce76c9 | ||
|
|
90c79e34eb | ||
|
|
6ea0fd292c | ||
|
|
193c1f45d4 | ||
|
|
4da3a0347f | ||
|
|
795ef5016e | ||
|
|
83693f7fb0 | ||
|
|
c55e20f341 | ||
|
|
1430d2fc47 | ||
|
|
3f24ccff74 | ||
|
|
51b7e53385 | ||
|
|
8dbb18b1c8 | ||
|
|
88bc7f690e | ||
|
|
29ef21a1fa | ||
|
|
7a75982715 | ||
|
|
f95f8f4bf1 | ||
|
|
9eef5855a9 | ||
|
|
2e70745bab | ||
|
|
8a971dfe52 | ||
|
|
a004e96eca | ||
|
|
ce2432632c | ||
|
|
56b3100c8e | ||
|
|
327d65a128 | ||
|
|
e5a7a07deb | ||
|
|
5bd57573be | ||
|
|
c2223e6b08 | ||
|
|
d1ffc8c3f9 | ||
|
|
5a5cd14df5 | ||
|
|
2fb25da9d6 | ||
|
|
21228cd212 | ||
|
|
e0b86a84ba | ||
|
|
c3a4e41968 | ||
|
|
f3205843ac | ||
|
|
9a03dd312f | ||
|
|
b711b21048 | ||
|
|
8eaba04d91 | ||
|
|
0785b034e4 | ||
|
|
47e698f197 | ||
|
|
3a752a761a | ||
|
|
f6cc49c07a | ||
|
|
5c4fca9cc9 | ||
|
|
536a7ce1fa | ||
|
|
61072aef40 | ||
|
|
b7ec1fcf06 | ||
|
|
d2fd8fb8fe | ||
|
|
68ee1b3122 | ||
|
|
a37735fc1c | ||
|
|
de0f587174 | ||
|
|
f977561779 | ||
|
|
043ea168fb | ||
|
|
5336bafed4 | ||
|
|
c51291bf81 | ||
|
|
489e48fe4d | ||
|
|
744a145e9f | ||
|
|
7ad925dbd3 | ||
|
|
a69298a3a9 | ||
|
|
2c3aff0407 | ||
|
|
aad35d41db | ||
|
|
cc6189a5d9 | ||
|
|
18bf8f9a2c | ||
|
|
37f3a32a1c | ||
|
|
c9678564a5 | ||
|
|
721c18c509 | ||
|
|
9e30fe683b | ||
|
|
7fff50c146 | ||
|
|
fc1c845b67 | ||
|
|
c2ec1f3677 | ||
|
|
0cbd357856 | ||
|
|
26ea9ed547 | ||
|
|
9cbbecb32c | ||
|
|
db12c89731 | ||
|
|
6f413deb90 | ||
|
|
0127e2dc86 | ||
|
|
7743928702 | ||
|
|
bf76150fc1 | ||
|
|
c62107172b | ||
|
|
a253587dfa | ||
|
|
1938533d53 | ||
|
|
28943c55bd | ||
|
|
791bbb68b6 | ||
|
|
ec8adcc794 | ||
|
|
68058e7661 | ||
|
|
416c62369c | ||
|
|
a19dd53423 | ||
|
|
79ec33b79a | ||
|
|
be881b957c | ||
|
|
244872e9f2 | ||
|
|
1397f7f0f4 | ||
|
|
96424dd65c | ||
|
|
9efc5459fb | ||
|
|
7e02168e54 | ||
|
|
f8e6b3e828 | ||
|
|
c06176bfc9 | ||
|
|
e2a1701000 | ||
|
|
d7703ceef4 | ||
|
|
93daabc673 | ||
|
|
9264245944 | ||
|
|
f56423040b | ||
|
|
4b91504a50 | ||
|
|
1f0f7fef5e | ||
|
|
6aced653fb | ||
|
|
0899482869 | ||
|
|
1bdfa1e6e1 | ||
|
|
f22a8f15c0 | ||
|
|
bf6394fc7d | ||
|
|
6f08586e8f | ||
|
|
d60a4a24ad | ||
|
|
51069f3623 | ||
|
|
1407af22e3 | ||
|
|
ea6220277d | ||
|
|
fbffa03dad | ||
|
|
a74760d804 | ||
|
|
c4b0a712d2 | ||
|
|
1fecf9c7f4 | ||
|
|
7be21203d9 | ||
|
|
f65f2c6597 | ||
|
|
227def4328 | ||
|
|
b506624f57 | ||
|
|
fbb6a0a153 | ||
|
|
528de32689 | ||
|
|
230e5380ca | ||
|
|
349237d097 | ||
|
|
d4df9f0424 | ||
|
|
2f68e84002 | ||
|
|
b18dcc29cd | ||
|
|
680169e312 | ||
|
|
11753c4515 | ||
|
|
bd29dfd65f | ||
|
|
4b1ae838e5 | ||
|
|
85599d3103 | ||
|
|
4603c5a258 | ||
|
|
ec45fbc5e8 | ||
|
|
53400334b2 | ||
|
|
af4ff7081a | ||
|
|
7f21b8ed76 | ||
|
|
55e44dcc9c | ||
|
|
59dac947ab | ||
|
|
7d33c3c024 | ||
|
|
38a014ef84 | ||
|
|
f451348430 | ||
|
|
685aaf43b0 | ||
|
|
d6a20b5272 | ||
|
|
d8d7a19fa1 | ||
|
|
63626fae3a | ||
|
|
de09ef2935 | ||
|
|
bcb28a63a7 | ||
|
|
a479903ce3 | ||
|
|
567d105261 | ||
|
|
83743c5a9f | ||
|
|
0b8f88ea1d | ||
|
|
fadc755930 | ||
|
|
f67f171e64 | ||
|
|
449c59015e | ||
|
|
4d697e6a79 | ||
|
|
04ae70873a | ||
|
|
2f8a64826a | ||
|
|
11cb3542ee | ||
|
|
905296621c | ||
|
|
769484bc0d | ||
|
|
27ff4cef22 | ||
|
|
a352e2616e | ||
|
|
77cbb9394f | ||
|
|
39c8b3dfe4 | ||
|
|
7bd11e695e | ||
|
|
a76b703d36 | ||
|
|
df51173405 | ||
|
|
a79d7f9eaf | ||
|
|
1032a46456 | ||
|
|
ae997758ab | ||
|
|
91a827324d | ||
|
|
bf849c9faa | ||
|
|
118ab46ac0 | ||
|
|
d2f2b689f9 | ||
|
|
5229e45566 | ||
|
|
b17043e85d | ||
|
|
19ca909ef5 | ||
|
|
f7ff309b6e | ||
|
|
ea5a8486b9 | ||
|
|
58897b3436 | ||
|
|
6a59ecfa47 | ||
|
|
272066c6e0 | ||
|
|
0426c9d3b0 | ||
|
|
c22419ba0e | ||
|
|
8278fed2fb | ||
|
|
b24a65b490 | ||
|
|
fb509fabd8 | ||
|
|
d21285123f | ||
|
|
1029b6be0c | ||
|
|
3fff9455a1 | ||
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
85649ae283 |
@@ -2,4 +2,4 @@
|
|||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
Keep files below 210 lines.
|
Keep files below 420 lines.
|
||||||
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: fetching data from relays
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
We fetch data from relays using controllers:
|
||||||
|
- Start controllers immediatly; don’t await.
|
||||||
|
- Stream via onEvent; dedupe replaceables; emit immediately.
|
||||||
|
- Parallel local/remote queries; complete on EOSE.
|
||||||
|
- Finalize and persist since after completion.
|
||||||
|
- Guard with generations to cancel stale runs.
|
||||||
|
- UI flips off loading on first streamed result.
|
||||||
|
|
||||||
|
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate local‑only mode for writes (queueing for later).
|
||||||
|
|
||||||
|
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
|
||||||
|
|
||||||
|
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
|||||||
# Reference Projects
|
# Reference Projects
|
||||||
applesauce
|
applesauce
|
||||||
primal-web-app
|
primal-web-app
|
||||||
|
Amber
|
||||||
|
|
||||||
|
|||||||
155
Amber.md
Normal file
155
Amber.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
## Boris ↔ Amber bunker: current findings
|
||||||
|
|
||||||
|
- **Environment**
|
||||||
|
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
|
||||||
|
- Bunker: Amber (mobile).
|
||||||
|
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
|
||||||
|
|
||||||
|
## What we changed client-side
|
||||||
|
|
||||||
|
- **Signer wiring**
|
||||||
|
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
|
||||||
|
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
||||||
|
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
||||||
|
|
||||||
|
- **Account queue disabling (CRITICAL)**
|
||||||
|
- `applesauce-accounts` `BaseAccount` queues requests by default - each request waits for the previous one to complete before being sent.
|
||||||
|
- This caused batch decrypt operations to hang: first request would timeout waiting for user interaction, blocking all subsequent requests in the queue.
|
||||||
|
- **Solution**: Set `accounts.disableQueue = true` globally on the `AccountManager` in `App.tsx` during initialization. This applies to all accounts.
|
||||||
|
- Without this, Amber never sees decrypt requests because they're stuck in the account's internal queue.
|
||||||
|
- Reference: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
|
||||||
|
|
||||||
|
- **Probes and timeouts**
|
||||||
|
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
||||||
|
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
||||||
|
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
|
||||||
|
|
||||||
|
- **Logging**
|
||||||
|
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
|
||||||
|
- Confirmed NIP‑46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
|
||||||
|
|
||||||
|
## Evidence from logs (client)
|
||||||
|
|
||||||
|
```
|
||||||
|
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
|
||||||
|
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
|
||||||
|
[bunker] subscribe via signer: { relays: [...], filters: [...] }
|
||||||
|
[bunker] ✅ Signer subscription opened
|
||||||
|
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
|
||||||
|
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Amber’s).
|
||||||
|
|
||||||
|
## Evidence from Amber (device)
|
||||||
|
|
||||||
|
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
|
||||||
|
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
- Transport and publish paths are working: Boris is publishing NIP‑46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
|
||||||
|
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 10–30s windows.
|
||||||
|
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 decrypt handling or permission gating.
|
||||||
|
|
||||||
|
## Repro steps (quick)
|
||||||
|
|
||||||
|
1) Revoke Boris in Amber.
|
||||||
|
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip‑04 and nip‑44.
|
||||||
|
3) Keep Amber unlocked and foregrounded.
|
||||||
|
4) Reload Boris; observe:
|
||||||
|
- Logs showing `publish via signer` for kind 24133.
|
||||||
|
- In Amber, activity should include “Decrypt data using nip 4/44”.
|
||||||
|
|
||||||
|
If DECRYPT entries still don’t appear:
|
||||||
|
|
||||||
|
- This points to Amber’s NIP‑46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
|
||||||
|
|
||||||
|
## Suggestions for Amber-side debugging
|
||||||
|
|
||||||
|
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
|
||||||
|
- Confirm the provider recognizes NIP‑46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
|
||||||
|
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
||||||
|
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
||||||
|
|
||||||
|
## Performance improvements (post-debugging)
|
||||||
|
|
||||||
|
### Non-blocking publish wiring
|
||||||
|
- **Problem**: Awaiting `pool.publish()` completion blocks until all relay sends finish (can take 30s+ with timeouts).
|
||||||
|
- **Solution**: Wrapped `NostrConnectSigner.publishMethod` at app startup to fire-and-forget publish Observable/Promise; responses still arrive via signer subscription.
|
||||||
|
- **Result**: Encrypt/decrypt operations complete in <2s as seen in `/debug` page (NIP-44: ~900ms enc, ~700ms dec; NIP-04: ~1s enc, ~2s dec).
|
||||||
|
|
||||||
|
### Bookmark decryption optimization
|
||||||
|
- **Problem #1**: Sequential decrypt of encrypted bookmark events blocks UI and takes long with multiple events.
|
||||||
|
- **Problem #2**: 30-second timeouts on `nip44.decrypt` meant waiting 30s per event if bunker didn't support nip44.
|
||||||
|
- **Problem #3**: Account request queue blocked all decrypt requests until first one completed (waiting for user interaction).
|
||||||
|
- **Solution**:
|
||||||
|
- Removed all artificial timeouts - let decrypt fail naturally like debug page does.
|
||||||
|
- Added smart encryption detection (NIP-04 has `?iv=`, NIP-44 doesn't) to try the right method first.
|
||||||
|
- **Disabled account queue globally** (`accounts.disableQueue = true`) in `App.tsx` so all requests are sent immediately.
|
||||||
|
- Process sequentially (removed concurrent `mapWithConcurrency` hack).
|
||||||
|
- **Result**: Bookmark decryption is near-instant, limited only by bunker response time and user approval speed.
|
||||||
|
|
||||||
|
## Amethyst-style bookmarks (kind:30001)
|
||||||
|
|
||||||
|
**Important**: Amethyst bookmarks are stored in a **SINGLE** `kind:30001` event with d-tag `"bookmark"` that contains BOTH public AND private bookmarks in different parts of the event.
|
||||||
|
|
||||||
|
### Event structure:
|
||||||
|
- **Event kind**: `30001` (NIP-51 bookmark set)
|
||||||
|
- **d-tag**: `"bookmark"` (identifies this as the Amethyst bookmark list)
|
||||||
|
- **Public bookmarks**: Stored in event `tags` (e.g., `["e", "..."]`, `["a", "..."]`)
|
||||||
|
- **Private bookmarks**: Stored in encrypted `content` field (NIP-04 or NIP-44)
|
||||||
|
|
||||||
|
### Example event:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30001,
|
||||||
|
"tags": [
|
||||||
|
["d", "bookmark"], // Identifies this as Amethyst bookmarks
|
||||||
|
["e", "102a2fe..."], // Public bookmark (76 total)
|
||||||
|
["a", "30023:..."] // Public bookmark
|
||||||
|
],
|
||||||
|
"content": "lvOfl7Qb...?iv=5KzDXv09..." // NIP-04 encrypted (416 private bookmarks)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing:
|
||||||
|
When this single event is processed:
|
||||||
|
1. **Public tags** → 76 bookmark items with `sourceKind: 30001, isPrivate: false, setName: "bookmark"`
|
||||||
|
2. **Encrypted content** → 416 bookmark items with `sourceKind: 30001, isPrivate: true, setName: "bookmark"`
|
||||||
|
3. Total: 492 bookmarks from one event
|
||||||
|
|
||||||
|
### Encryption detection:
|
||||||
|
- The encrypted `content` field contains a JSON array of private bookmark tags
|
||||||
|
- `Helpers.hasHiddenContent()` from `applesauce-core` only detects **NIP-44** encrypted content
|
||||||
|
- **NIP-04** encrypted content must be detected explicitly by checking for `?iv=` in the content string
|
||||||
|
- Both detection methods are needed in:
|
||||||
|
1. **Display logic** (`Debug.tsx` - `hasEncryptedContent()`) - to show padlock emoji and decrypt button
|
||||||
|
2. **Decryption logic** (`bookmarkProcessing.ts`) - to schedule decrypt jobs
|
||||||
|
|
||||||
|
### Grouping:
|
||||||
|
In the UI, these are separated into two groups:
|
||||||
|
- **Amethyst Lists**: `sourceKind === 30001 && !isPrivate && setName === 'bookmark'` (public items)
|
||||||
|
- **Amethyst Private**: `sourceKind === 30001 && isPrivate && setName === 'bookmark'` (private items)
|
||||||
|
|
||||||
|
Both groups come from the same event, separated by whether they were in public tags or encrypted content.
|
||||||
|
|
||||||
|
### Why this matters:
|
||||||
|
This dual-storage format (public + private in one event) is why we need explicit NIP-04 detection. Without it, `Helpers.hasHiddenContent()` returns `false` and the encrypted content is never decrypted, resulting in 0 private bookmarks despite having encrypted data.
|
||||||
|
|
||||||
|
## Current conclusion
|
||||||
|
|
||||||
|
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||||
|
- Non-blocking publish keeps operations fast (~1-2s for encrypt/decrypt).
|
||||||
|
- **Account queue is GLOBALLY DISABLED** - this was the primary cause of hangs/timeouts.
|
||||||
|
- Smart encryption detection (both NIP-04 and NIP-44) and no artificial timeouts make operations instant.
|
||||||
|
- Sequential processing is cleaner and more predictable than concurrent hacks.
|
||||||
|
- Relay queries now trust EOSE signals instead of arbitrary timeouts, completing in 1-2s instead of 6s.
|
||||||
|
- The missing DECRYPT activity in Amber was partially due to requests never being sent (stuck in queue). With queue disabled globally, Amber receives all decrypt requests immediately.
|
||||||
|
- **Amethyst-style bookmarks** require explicit NIP-04 detection (`?iv=` check) since `Helpers.hasHiddenContent()` only detects NIP-44.
|
||||||
|
|
||||||
|
|
||||||
863
CHANGELOG.md
863
CHANGELOG.md
@@ -7,6 +7,844 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.10.11] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Clock icon for chronological bookmark view
|
||||||
|
- Clickable highlight count to open highlights sidebar
|
||||||
|
- Dynamic bookmark filter titles based on selected filter
|
||||||
|
- Profile picture moved to first position (left-aligned) with consistent sizing
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Default bookmark view changed to flat chronological list (newest first)
|
||||||
|
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
|
||||||
|
- Router updated to handle `/me/reading-list` → `/me/bookmarks` redirect
|
||||||
|
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
|
||||||
|
- Me.tsx updated to use faClock icon instead of faBars
|
||||||
|
- Removed bookmark count from section headings for cleaner display
|
||||||
|
- Hide close/collapse sidebar buttons on mobile for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bookmark sorting now uses proper display time (created_at || listUpdatedAt) with nulls last
|
||||||
|
- Robust sorting of merged bookmarks with fallback timestamps
|
||||||
|
- Corrected bookmark timestamp to use bookmark list creation time, not content creation time
|
||||||
|
- Preserved content created_at while adding listUpdatedAt for proper sorting
|
||||||
|
- Removed synthetic added_at field, now uses created_at from bookmark list event
|
||||||
|
- Consistent chronological sorting with useMemo optimization
|
||||||
|
- Removed unused faTimes import
|
||||||
|
- Bookmark timestamps now show sane dates using created_at fallback to listUpdatedAt
|
||||||
|
- Guarded formatters to prevent timestamp display errors
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Removed excessive debug logging for cleaner console output
|
||||||
|
- Bookmark timestamp handling never defaults to "now", allows nulls and sorts nulls last
|
||||||
|
- Renders empty when timestamp is missing instead of showing invalid dates
|
||||||
|
|
||||||
|
## [0.10.10] - 2025-10-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Version bump for consistency (no user-facing changes)
|
||||||
|
|
||||||
|
## [0.10.9] - 2025-10-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Event fetching reliability with exponential backoff in eventManager
|
||||||
|
- Improved retry logic with incremental backoff delays
|
||||||
|
- Better handling of concurrent event requests
|
||||||
|
- More robust event retrieval from relay pool
|
||||||
|
- Bookmark timestamp handling
|
||||||
|
- Use per-item `added_at`/`created_at` timestamps when available
|
||||||
|
- Improves accuracy of bookmark date tracking
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed all debug console logs
|
||||||
|
- Cleaner console output in development and production
|
||||||
|
- Improved performance by eliminating debugging statements
|
||||||
|
|
||||||
|
## [0.10.8] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Individual event rendering via `/e/:eventId` path
|
||||||
|
- Display `kind:1` notes and other events with article-like presentation
|
||||||
|
- Publication date displayed in top-right corner like articles
|
||||||
|
- Author attribution with "Note by @author" titles
|
||||||
|
- Direct event loading with intelligent caching from eventStore
|
||||||
|
- Centralized event fetching via new `eventManager` singleton
|
||||||
|
- Request deduplication for concurrent fetches
|
||||||
|
- Automatic retry logic when relay pool becomes available
|
||||||
|
- Non-blocking background fetching with 12-second timeout
|
||||||
|
- Seamless integration with eventStore for instant cached event display
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bookmark hydration efficiency
|
||||||
|
- Only request content for bookmarks missing data (not all bookmarks)
|
||||||
|
- Use eventStore fallback for instant display of cached profiles
|
||||||
|
- Prevents over-fetching and improves initial load performance
|
||||||
|
- Search button behavior for notes
|
||||||
|
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
|
||||||
|
- Articles continue to use search portal with proper naddr encoding
|
||||||
|
- Removes unwanted `nostr-event:` prefix from URLs
|
||||||
|
- Author profile resolution
|
||||||
|
- Fetch author profiles from eventStore cache first before relay requests
|
||||||
|
- Instant title updates if profile already loaded
|
||||||
|
- Graceful fallback to short pubkey display if profile unavailable
|
||||||
|
|
||||||
|
## [0.10.7] - 2025-10-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Profile pages now display all writings correctly
|
||||||
|
- Events are now stored in eventStore as they stream in from relays
|
||||||
|
- `fetchBlogPostsFromAuthors` now accepts `eventStore` parameter like other fetch functions
|
||||||
|
- Ensures all writings appear on `/p/` routes, not just the first few
|
||||||
|
- Background fetching of highlights and writings uses consistent patterns
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified profile background fetching logic for better maintainability
|
||||||
|
- Extracted relay URLs to variable for clarity
|
||||||
|
- Consistent error handling patterns across fetch functions
|
||||||
|
- Clearer comments about no-limit fetching behavior
|
||||||
|
|
||||||
|
## [0.10.6] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Text-to-speech reliability improvements
|
||||||
|
- Chunking support for long-form content to prevent WebSpeech API cutoffs
|
||||||
|
- Automatic chunk-based resumption for interrupted playback
|
||||||
|
- Better handling of content exceeding browser TTS limits
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Tab switching regression on `/me` page
|
||||||
|
- Resolved infinite update loop caused by circular dependency in `useCallback` hooks
|
||||||
|
- Tab navigation now properly updates UI when URL changes
|
||||||
|
- Removed `loadedTabs` from dependency arrays to prevent re-render cycles
|
||||||
|
- Explore page data loading patterns
|
||||||
|
- Implemented subscribe-first, non-blocking loading model
|
||||||
|
- Removed all timeouts in favor of immediate subscription and progressive hydration
|
||||||
|
- Contacts, writings, and highlights now stream results as they arrive
|
||||||
|
- Nostrverse content loads in background without blocking UI
|
||||||
|
- Text-to-speech handler cleanup
|
||||||
|
- Removed no-op self-assignment in rate change handler
|
||||||
|
|
||||||
|
## [0.10.4] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Web Share Target support for PWA (system-level share integration)
|
||||||
|
- Boris can now receive shared URLs from other apps on mobile and desktop
|
||||||
|
- Implements POST-based Web Share Target API per Chrome standards
|
||||||
|
- Service worker intercepts share requests and redirects to handler route
|
||||||
|
- ShareTargetHandler component auto-saves shared URLs as web bookmarks
|
||||||
|
- Android compatibility with URL extraction from text field when url param is missing
|
||||||
|
- Automatic navigation to bookmarks list after successful save
|
||||||
|
- Login prompt when sharing while logged out
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Manifest now includes `share_target` configuration for system share menu integration
|
||||||
|
- Service worker handles POST requests to `/share-target` endpoint
|
||||||
|
- Added `/share-target` route for processing incoming shared content
|
||||||
|
|
||||||
|
## [0.10.3] - 2025-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Content filtering setting to hide articles posted by bots
|
||||||
|
- New "Hide content posted by bots" checkbox in Explore settings (enabled by default)
|
||||||
|
- Filters articles where author's profile name or display_name contains "bot" (case-insensitive)
|
||||||
|
- Applies to both Explore page and Me section writings
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Resolved all linting and type checking issues
|
||||||
|
- Added missing React Hook dependencies to `useMemo` and `useEffect`
|
||||||
|
- Wrapped loader functions in `useCallback` to prevent unnecessary re-renders
|
||||||
|
- Removed unused variables (`queryTime`, `startTime`, `allEvents`)
|
||||||
|
- All ESLint warnings and TypeScript errors now resolved
|
||||||
|
|
||||||
|
## [0.10.2] - 2025-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Text-to-speech (TTS) speaker language selection mode
|
||||||
|
- New "Speaker language" dropdown in TTS settings (system or content)
|
||||||
|
- Detects content language using tinyld for accurate voice matching
|
||||||
|
- Falls back to system language when content detection unavailable
|
||||||
|
- Top 10 languages featured in dropdown for quick access
|
||||||
|
- TTS example text section in settings
|
||||||
|
- Test TTS voices directly in the settings panel
|
||||||
|
- Uses Boris mission statement as example text
|
||||||
|
- Real-time speaker selection testing
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- TTS language selection now uses "Speaker language" terminology
|
||||||
|
- Distinguishes between American English (en-US) and British English (en-GB)
|
||||||
|
- Improved language detection with content-aware voice selection
|
||||||
|
- Streamlined dropdown for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- TTS voice detection and selection logic
|
||||||
|
- Proper empty catch block handling instead of silently failing
|
||||||
|
- Consistent use of `setting-select` class for dropdown styling
|
||||||
|
- Improved dropdown spacing with adequate padding-right
|
||||||
|
|
||||||
|
## [0.10.0] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized bookmark loading with streaming and auto-decrypt
|
||||||
|
- Bookmarks now load progressively with streaming updates
|
||||||
|
- Auto-decrypt bookmarks as they arrive from relays
|
||||||
|
- Individual decrypt buttons for encrypted bookmark events
|
||||||
|
- Centralized bookmark controller for consistent loading across the app
|
||||||
|
- Enhanced debug page with comprehensive diagnostics
|
||||||
|
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||||
|
- Live performance timing with stopwatch display
|
||||||
|
- Bookmark loading and decryption diagnostics
|
||||||
|
- Real-time bunker logs with filtering and clearing
|
||||||
|
- Version and git commit footer
|
||||||
|
- Bunker (NIP-46) authentication support
|
||||||
|
- Support for remote signing via Nostr Connect protocol
|
||||||
|
- Bunker URI input with validation and error handling
|
||||||
|
- Automatic reconnection on app restore with proper permissions
|
||||||
|
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved bookmark loading performance
|
||||||
|
- Non-blocking, progressive bookmark updates via callback pattern
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Enhanced bunker error messages
|
||||||
|
- Formatted error messages with signer suggestions
|
||||||
|
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||||
|
- Better error handling for missing signer extensions
|
||||||
|
- Centralized bookmark loading architecture
|
||||||
|
- Single shared bookmark controller for consistent loading
|
||||||
|
- Unified bookmark loading with streaming and auto-decrypt
|
||||||
|
- Consolidated bookmark loading into single centralized function
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- NIP-46 bunker signing and decryption
|
||||||
|
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||||
|
- Bunker relays added to relay pool for signing requests
|
||||||
|
- Proper setup of pool and relays before bunker reconnection
|
||||||
|
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||||
|
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||||
|
- Wait for bunker relay connections before marking signer ready
|
||||||
|
- Validate bunker URI (remote must differ from user pubkey)
|
||||||
|
- Accept remote===pubkey for Amber compatibility
|
||||||
|
- Bookmark loading and decryption
|
||||||
|
- Bookmarks load and complete properly with streaming
|
||||||
|
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||||
|
- Include decrypted private bookmarks in sidebar
|
||||||
|
- Skip background event fetching when there are too many IDs
|
||||||
|
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||||
|
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Proper timeout handling for bookmark decryption (no hanging)
|
||||||
|
- Smart encryption detection with consistent padlock display
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Add extraRelays to EventLoader and AddressLoader
|
||||||
|
- TypeScript and linting errors throughout
|
||||||
|
- Replace empty catch blocks with warnings
|
||||||
|
- Fix explicit any types
|
||||||
|
- Add missing useEffect dependencies
|
||||||
|
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking NIP-46 operations
|
||||||
|
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||||
|
- Non-blocking bookmark decryption with sequential processing
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Optimized bookmark loading
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Progressive, non-blocking bookmark loading with streaming
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Remove artificial delays from bookmark decryption
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Centralized bookmark controller architecture
|
||||||
|
- Extract bookmark streaming helpers and centralize loading
|
||||||
|
- Consolidated bookmark loading into single function
|
||||||
|
- Remove deprecated bookmark service files
|
||||||
|
- Share bookmark controller between components
|
||||||
|
- Debug page organization
|
||||||
|
- Extract VersionFooter component to eliminate duplication
|
||||||
|
- Structured sections with proper layout and styling
|
||||||
|
- Apply settings page styling structure
|
||||||
|
- Simplified bunker implementation following applesauce patterns
|
||||||
|
- Clean up bunker implementation for better maintainability
|
||||||
|
- Import RELAYS from central config (DRY principle)
|
||||||
|
- Update RELAYS list with relay.nsec.app
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Comprehensive Amber.md documentation
|
||||||
|
- Amethyst-style bookmarks section
|
||||||
|
- Bunker decrypt investigation summary
|
||||||
|
- Critical queue disabling requirement
|
||||||
|
- NIP-46 setup and troubleshooting
|
||||||
|
|
||||||
|
## [0.9.1] - 2025-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Video embedding for nostr-native content
|
||||||
|
- Detect and embed `<video>...</video>` blocks (including nested `<source>`)
|
||||||
|
- Detect and embed `<img src="…(mp4|webm|ogg|mov|avi|mkv|m4v)">` tags
|
||||||
|
- Detect and embed bare video file URLs and platform-classified video links
|
||||||
|
- Media display settings
|
||||||
|
- New "Render video links as embeds" setting (defaults to enabled)
|
||||||
|
- New "Full-width images" display option
|
||||||
|
- Dedicated "Media Display" settings section
|
||||||
|
- Article view improvements
|
||||||
|
- Center images by default in reader
|
||||||
|
- Writings list sorted by publication date (newest first)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enable media display options by default for a better out‑of‑the‑box experience
|
||||||
|
- Constrain video player to reader width to prevent horizontal overflow
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevent double video player rendering when both processor and panel attempted to embed
|
||||||
|
- Remove text artifacts and broken tags when converting markdown image/video URLs
|
||||||
|
- Improved URL regex and robust tag replacement
|
||||||
|
- Avoid injecting unknown img props from markdown renderer
|
||||||
|
- Resolved remaining ESLint and TypeScript issues
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Optimized Support page loading with instant display and skeletons
|
||||||
|
|
||||||
|
## [0.9.0] - 2025-01-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- User relay list integration (NIP-65) and blocked relays (NIP-51)
|
||||||
|
- Automatically loads user's relay list from kind 10002 events
|
||||||
|
- Supports blocked relay filtering from kind 10006 mute lists
|
||||||
|
- Integrates with existing relay pool for seamless user experience
|
||||||
|
- Relay list debug section in Debug component
|
||||||
|
- Enhanced debugging capabilities for relay list loading
|
||||||
|
- Detailed logging for relay query diagnostics
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved relay list loading performance
|
||||||
|
- Added streaming callback to relay list service for faster results
|
||||||
|
- User relay list now streams into pool immediately and finalizes after blocked relays
|
||||||
|
- Made relay list loading non-blocking in App.tsx
|
||||||
|
- Enhanced relay URL handling
|
||||||
|
- Normalized relay URLs to match applesauce-relay internal format
|
||||||
|
- Removed relay.dergigi.com from default relays
|
||||||
|
- Use user's relay list exclusively when logged in
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Resolved all linting issues across the codebase
|
||||||
|
- Fixed TypeScript type issues in relayListService
|
||||||
|
- Replaced any types with proper NostrEvent types
|
||||||
|
- Improved type safety and code quality
|
||||||
|
- Cleaned up temporary test relays from hardcoded list
|
||||||
|
- Removed non-relay console.log statements and debug output
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Enhanced relay initialization logging for better diagnostics
|
||||||
|
- Improved error handling and timeout management for relay queries
|
||||||
|
- Better separation of concerns between relay loading and application startup
|
||||||
|
|
||||||
|
## [0.8.6] - 2025-10-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- React Hooks violations in NostrMentionLink component
|
||||||
|
- Fixed useEffect dependency warnings by removing isMounted from dependencies
|
||||||
|
- Reverted to inline mount tracking with useRef for safer lifecycle handling
|
||||||
|
|
||||||
|
## [0.8.4] - 2024-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Progressive article hydration for reads tab
|
||||||
|
- Articles now load titles, summaries, images, and author information progressively
|
||||||
|
- Implemented readsController following the same pattern as bookmarkController
|
||||||
|
- Uses AddressLoader for efficient batched article event retrieval
|
||||||
|
- Articles rehydrate as data arrives from relays without blocking initial display
|
||||||
|
- Event store integration for caching article events
|
||||||
|
- Centralized reads data fetching following DRY principles
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed React type imports in useArticleLoader
|
||||||
|
- Import `Dispatch` and `SetStateAction` directly from 'react' instead of using `React.` prefix
|
||||||
|
- Resolves ESLint no-undef errors
|
||||||
|
|
||||||
|
## [0.8.3] - 2025-01-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Highlight creation now shows immediate UI feedback without page refresh
|
||||||
|
- Fixed streaming highlight merge logic to preserve newly created highlights
|
||||||
|
- Decoupled cached highlight sync from content loading to prevent unintended reloads
|
||||||
|
- Newly created highlights appear instantly in both reader and highlights panel
|
||||||
|
- Highlights remain visible while remote results stream in and merge properly
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved highlight creation user experience
|
||||||
|
- Selection clearing and synchronous rendering for immediate highlight display
|
||||||
|
- Better error handling for bunker permission issues with user-friendly messages
|
||||||
|
|
||||||
|
## [0.8.2] - 2025-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reading progress indicator in compact bookmark cards
|
||||||
|
- Shows progress bar for articles and web bookmarks with reading data
|
||||||
|
- Progress bar aligned with bookmark text for better visual association
|
||||||
|
- Color-coded progress (blue for reading, green for completed, gray for started)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Compact cards layout optimizations for more space-efficient display
|
||||||
|
- Reduced vertical padding from 0.5rem to 0.25rem
|
||||||
|
- Reduced compact row height from 28px to 24px
|
||||||
|
- Reduced gap between compact cards from 0.5rem to 0.25rem
|
||||||
|
- Reading progress bar styling for compact view
|
||||||
|
- Bar height reduced from 2px to 1px for more subtle appearance
|
||||||
|
- Left margin of 1.5rem aligns bar with bookmark text instead of appearing as separator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed borders from compact bookmarks in bookmarks sidebar
|
||||||
|
- Border styling from `.bookmarks-list` no longer applies to compact cards
|
||||||
|
- Compact cards now display as truly borderless and transparent
|
||||||
|
|
||||||
|
## [0.8.0] - 2025-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized reading progress controller for non-blocking reading position sync
|
||||||
|
- Progressive loading with caching from event store
|
||||||
|
- Streaming updates from relays with proper merging
|
||||||
|
- 2-second completion hold at 100% reading position to prevent UI jitter
|
||||||
|
- Configurable auto-mark-as-read at 100% reading progress
|
||||||
|
- Reading progress indicators on blog post cards
|
||||||
|
- Visual progress bars on article cards in Explore and bookmarks sidebar
|
||||||
|
- Persistent reading position synced across devices via NIP-85
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reading position sync now enabled by default in runtime paths
|
||||||
|
- Improved auto-mark-as-read behavior with reliable completion detection
|
||||||
|
- Reading progress events use proper NIP-85 specification (kind 39802)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reading position saves with proper validation and event store integration
|
||||||
|
- Profile page writings loading now fetches all writings without limits
|
||||||
|
- Consistent reading progress calculation and event publishing
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking reading progress controller with streaming updates
|
||||||
|
- Cache-first loading strategy with local event store before relay queries
|
||||||
|
- Efficient progress merging and deduplication
|
||||||
|
|
||||||
|
## [0.7.4] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Profile page data preloading for instant tab switching
|
||||||
|
- Automatically preloads all highlights and writings when viewing a profile (`/p/` pages)
|
||||||
|
- Non-blocking background fetch stores all events in event store
|
||||||
|
- Tab switching becomes instant after initial preload
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `/me/bookmarks` tab now displays in cards view only
|
||||||
|
- Removed view mode toggle buttons (compact, large) from bookmarks tab
|
||||||
|
- Cards view provides optimal bookmark browsing experience
|
||||||
|
- Grouping toggle (grouped/flat) still available
|
||||||
|
- Highlights sidebar filters simplified when logged out
|
||||||
|
- Only nostrverse filter button shown when not logged in
|
||||||
|
- Friends and personal highlight filters hidden when logged out
|
||||||
|
- Cleaner UX showing only available options
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Profile page tabs now display cached content instantly
|
||||||
|
- Highlights and writings show immediately from event store cache
|
||||||
|
- Network fetches happen in background without blocking UI
|
||||||
|
- Matches Explore and Debug page non-blocking loading pattern
|
||||||
|
- Eliminated loading delays when switching between tabs
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Cache-first profile loading strategy
|
||||||
|
- Instant display of cached highlights and writings from event store
|
||||||
|
- Background refresh updates data without blocking
|
||||||
|
- Tab switches show content immediately without loading states
|
||||||
|
|
||||||
|
## [0.7.3] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized nostrverse writings controller for kind 30023 content
|
||||||
|
- Automatically starts at app initialization
|
||||||
|
- Streams nostrverse blog posts progressively to Explore page
|
||||||
|
- Provides non-blocking, cache-first loading strategy
|
||||||
|
- Centralized nostrverse highlights controller
|
||||||
|
- Pre-loads nostrverse highlights at app start for instant toggling
|
||||||
|
- Streams highlights progressively to Explore page
|
||||||
|
- Integrated with EventStore for caching
|
||||||
|
- Writings loading debug section on `/debug` page
|
||||||
|
- Diagnostics for writings controller and loading states
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Explore page now uses centralized `writingsController` for user's own writings
|
||||||
|
- Auto-loads user writings at login for instant availability
|
||||||
|
- Non-blocking fetch with progressive streaming
|
||||||
|
- Explore page loading strategy optimized
|
||||||
|
- Shows skeleton placeholders instead of blocking spinners
|
||||||
|
- Seeds from cache, then streams and merges results progressively
|
||||||
|
- Keeps nostrverse fetches non-blocking
|
||||||
|
- User's own writings now included in Explore when enabled
|
||||||
|
- Lazy-loads on 'mine' toggle when logged in
|
||||||
|
- Streams in parallel with friends/nostrverse content
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Explore page works correctly in logged-out mode
|
||||||
|
- Relies solely on centralized nostrverse controllers
|
||||||
|
- Controllers start even when logged out
|
||||||
|
- Fetches nostrverse content properly without authentication
|
||||||
|
- Explore page no longer allows disabling all scope filters
|
||||||
|
- Ensures at least one filter (mine/friends/nostrverse) remains active
|
||||||
|
- Prevents blank content state
|
||||||
|
- Explore page reflects default scope setting immediately
|
||||||
|
- No more blank lists on initial load
|
||||||
|
- Pre-loads and merges nostrverse from event store
|
||||||
|
- Explore page highlights properly scoped
|
||||||
|
- Nostrverse highlights never block the page
|
||||||
|
- Shows empty state instead of spinner
|
||||||
|
- Streams results into store immediately
|
||||||
|
- Highlights are merged and loaded correctly
|
||||||
|
- Article-specific highlights properly filtered
|
||||||
|
- Highlights scoped to current article on `/a/` and `/r/` routes
|
||||||
|
- Derives coordinate from naddr for early filtering
|
||||||
|
- Sidebar and content only show relevant highlights
|
||||||
|
- ContentPanel shows only article-specific highlights for nostr articles
|
||||||
|
- Explore writings properly deduplicated
|
||||||
|
- Deduplication by replaceable event (author:d-tag) happens before visibility filtering
|
||||||
|
- Consistent dedupe/sort behavior across all loading scenarios
|
||||||
|
- Debug page writings loading section added
|
||||||
|
- No infinite loop when loading nostrverse content
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking explore page loading
|
||||||
|
- Fully non-blocking loading strategy
|
||||||
|
- Seeds caches then streams and merges results progressively
|
||||||
|
- Lazy-loading for content filters
|
||||||
|
- Nostrverse writings lazy-load when toggled on while logged in
|
||||||
|
- Avoids redundant loading with guard flags
|
||||||
|
- Streaming callbacks for progressive updates
|
||||||
|
- Writings stream to UI via onPost callback
|
||||||
|
- Posts appear instantly as they arrive from cache or network
|
||||||
|
|
||||||
|
## [0.7.2] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Cached-first loading with EventStore across the app
|
||||||
|
- Instant display of cached highlights and writings from local event store
|
||||||
|
- Progressive loading with streaming updates from relays
|
||||||
|
- Centralized event storage for improved performance and offline support
|
||||||
|
- Default explore scope setting for controlling content visibility
|
||||||
|
- Configurable default scope for explore page content
|
||||||
|
- Dedicated Explore section in settings for better organization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Highlights and writings now load from cache first, then stream from relays
|
||||||
|
- Explore page shows cached content instantly before network updates
|
||||||
|
- Article-specific highlights stored in centralized event store for faster access
|
||||||
|
- Nostrverse content cached locally for improved performance
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevent "No highlights yet" flash on `/me/highlights` page
|
||||||
|
- Force React to remount tab content when switching tabs for proper state management
|
||||||
|
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
|
||||||
|
- Show skeleton placeholders while highlights are loading for better UX
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Local-first loading strategy reduces perceived loading times
|
||||||
|
- Cached content displays immediately while background sync occurs
|
||||||
|
- Centralized event storage eliminates redundant network requests
|
||||||
|
|
||||||
|
## [0.7.0] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Login with Bunker (NIP-46) authentication support
|
||||||
|
- Support for remote signing via Nostr Connect protocol
|
||||||
|
- Bunker URI input with validation and error handling
|
||||||
|
- Automatic reconnection on app restore with proper permissions
|
||||||
|
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||||
|
- Debug page (`/debug`) for diagnostics and testing
|
||||||
|
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||||
|
- Live performance timing with stopwatch display
|
||||||
|
- Bookmark loading and decryption diagnostics
|
||||||
|
- Real-time bunker logs with filtering and clearing
|
||||||
|
- Version and git commit footer
|
||||||
|
- Progressive bookmark loading with streaming updates
|
||||||
|
- Non-blocking, progressive bookmark updates via callback pattern
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Auto-decrypt bookmarks as they arrive from relays
|
||||||
|
- Individual decrypt buttons for encrypted bookmark events
|
||||||
|
- Bookmark grouping toggle (grouped by source vs flat chronological)
|
||||||
|
- Toggle between grouped view and flat chronological list
|
||||||
|
- Amethyst-style bookmark detection and grouping
|
||||||
|
- Display bookmarks even when they only have IDs (content loads in background)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved login UI with better copy and modern design
|
||||||
|
- Personable title and nostr-native language
|
||||||
|
- Highlighted 'your own highlights' in login copy
|
||||||
|
- Simplified button text to single words (Extension, Signer)
|
||||||
|
- Hide login button and user icon when logged out
|
||||||
|
- Hide Extension button when Bunker input is shown
|
||||||
|
- Auto-load bookmarks on login and page mount
|
||||||
|
- Enhanced bunker error messages
|
||||||
|
- Formatted error messages with signer suggestions
|
||||||
|
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||||
|
- Better error handling for missing signer extensions
|
||||||
|
- Centered and constrained bunker input field
|
||||||
|
- Centralized bookmark loading architecture
|
||||||
|
- Single shared bookmark controller for consistent loading
|
||||||
|
- Unified bookmark loading with streaming and auto-decrypt
|
||||||
|
- Consolidated bookmark loading into single centralized function
|
||||||
|
- Bookmarks passed as props throughout component tree
|
||||||
|
- Renamed UI elements for clarity
|
||||||
|
- "Bunker" button renamed to "Signer"
|
||||||
|
- Hide bookmark controls when logged out
|
||||||
|
- Settings version footer improvements
|
||||||
|
- Separate links for version (to GitHub release) and commit (to commit page)
|
||||||
|
- Proper spacing around middot separator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- NIP-46 bunker signing and decryption
|
||||||
|
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||||
|
- Bunker relays added to relay pool for signing requests
|
||||||
|
- Proper setup of pool and relays before bunker reconnection
|
||||||
|
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||||
|
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||||
|
- Wait for bunker relay connections before marking signer ready
|
||||||
|
- Validate bunker URI (remote must differ from user pubkey)
|
||||||
|
- Accept remote===pubkey for Amber compatibility
|
||||||
|
- Bookmark loading and decryption
|
||||||
|
- Bookmarks load and complete properly with streaming
|
||||||
|
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||||
|
- Include decrypted private bookmarks in sidebar
|
||||||
|
- Skip background event fetching when there are too many IDs
|
||||||
|
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||||
|
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Proper timeout handling for bookmark decryption (no hanging)
|
||||||
|
- Smart encryption detection with consistent padlock display
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Add extraRelays to EventLoader and AddressLoader
|
||||||
|
- PWA cache limit increased to 3 MiB for larger bundles
|
||||||
|
- Extension login error messages with nos2x link
|
||||||
|
- TypeScript and linting errors throughout
|
||||||
|
- Replace empty catch blocks with warnings
|
||||||
|
- Fix explicit any types
|
||||||
|
- Add missing useEffect dependencies
|
||||||
|
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking NIP-46 operations
|
||||||
|
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||||
|
- Non-blocking bookmark decryption with sequential processing
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Optimized bookmark loading
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Progressive, non-blocking bookmark loading with streaming
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Remove artificial delays from bookmark decryption
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Centralized bookmark controller architecture
|
||||||
|
- Extract bookmark streaming helpers and centralize loading
|
||||||
|
- Consolidated bookmark loading into single function
|
||||||
|
- Remove deprecated bookmark service files
|
||||||
|
- Share bookmark controller between components
|
||||||
|
- Debug page organization
|
||||||
|
- Extract VersionFooter component to eliminate duplication
|
||||||
|
- Structured sections with proper layout and styling
|
||||||
|
- Apply settings page styling structure
|
||||||
|
- Simplified bunker implementation following applesauce patterns
|
||||||
|
- Clean up bunker implementation for better maintainability
|
||||||
|
- Import RELAYS from central config (DRY principle)
|
||||||
|
- Update RELAYS list with relay.nsec.app
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Comprehensive Amber.md documentation
|
||||||
|
- Amethyst-style bookmarks section
|
||||||
|
- Bunker decrypt investigation summary
|
||||||
|
- Critical queue disabling requirement
|
||||||
|
- NIP-46 setup and troubleshooting
|
||||||
|
|
||||||
|
## [0.6.24] - 2025-01-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- TypeScript global declarations for build-time defines
|
||||||
|
- Added proper type declarations for `__APP_VERSION__`, `__GIT_COMMIT__`, `__GIT_BRANCH__`, `__BUILD_TIME__`, and `__GIT_COMMIT_URL__`
|
||||||
|
- Resolved ESLint no-undef errors for build-time injected variables
|
||||||
|
- Added Node.js environment hint to Vite configuration
|
||||||
|
|
||||||
|
## [0.6.23] - 2025-01-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deep-link refresh redirect issue for nostr-native articles
|
||||||
|
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
|
||||||
|
- Real browsers now hit the SPA directly, preventing redirect to root path
|
||||||
|
- Bot crawlers still receive proper OpenGraph metadata for social sharing
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Version and git commit information in Settings footer
|
||||||
|
- Displays app version and short commit hash with link to GitHub
|
||||||
|
- Build-time metadata injection via Vite configuration
|
||||||
|
- Subtle footer styling with selectable text
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article OG handler now uses proper RelayPool.request() API
|
||||||
|
- Aligned with applesauce RelayPool interface
|
||||||
|
- Removed deprecated open/close methods
|
||||||
|
- Fixed TypeScript linting errors
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Added debug logging for route state and article OG handler
|
||||||
|
- Gated by `?debug=1` query parameter for production testing
|
||||||
|
- Structured logging for troubleshooting deep-link issues
|
||||||
|
- Temporary debug components for validation
|
||||||
|
|
||||||
|
## [0.6.22] - 2025-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
|
||||||
|
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
|
||||||
|
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
|
||||||
|
- User-agent detection serves appropriate content to crawlers vs browsers
|
||||||
|
- Falls back to default social preview image when articles have no cover image
|
||||||
|
- Social preview image for homepage and article links
|
||||||
|
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
|
||||||
|
- Homepage now includes social preview image in meta tags
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article deep-links now properly preserve URL when loading in browser
|
||||||
|
- Uses `history.replaceState()` to maintain correct article path
|
||||||
|
- Browser navigation works correctly on refresh and new tab opens
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Vercel rewrite configuration for article routes
|
||||||
|
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
|
||||||
|
- Regular SPA routing preserved for browser navigation
|
||||||
|
|
||||||
|
## [0.6.21] - 2025-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
|
||||||
|
- Automatically saves and syncs reading position as you scroll
|
||||||
|
- Visual reading progress indicator on article cards
|
||||||
|
- Reading progress shown in Explore and Bookmarks sidebar
|
||||||
|
- Auto-scroll to last reading position setting (configurable in Settings)
|
||||||
|
- Reading position displayed as colored progress bar on cards
|
||||||
|
- Reading progress filters for organizing articles
|
||||||
|
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
|
||||||
|
- Filter icons colored when active (blue for most, green for completed)
|
||||||
|
- URL routing support for reading progress filters
|
||||||
|
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||||
|
- Reads and Links tabs on `/me` page
|
||||||
|
- Reads tab shows nostr-native articles with reading progress
|
||||||
|
- Links tab shows external URLs with reading progress
|
||||||
|
- Both tabs populate instantly from bookmarks for fast loading
|
||||||
|
- Lazy loading for improved performance
|
||||||
|
- Auto-mark as read at 100% reading progress
|
||||||
|
- Articles automatically marked as read when scrolled to end
|
||||||
|
- Marked-as-read articles treated as 100% progress
|
||||||
|
- Fancy checkmark animation on Mark as Read button
|
||||||
|
- Click-to-open article navigation on highlights
|
||||||
|
- Clicking highlights in Explore and Me pages opens the source article
|
||||||
|
- Automatically scrolls to highlighted text position
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed Archive to Reads with expanded functionality
|
||||||
|
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
|
||||||
|
- Simplified filter icon colors to blue (except green for completed)
|
||||||
|
- Started reading progress state (0-10%) uses neutral text color
|
||||||
|
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
|
||||||
|
- Removed unused IEventStore import in ContentPanel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reading position calculation now accurately reaches 100%
|
||||||
|
- Reading position filters work correctly in bookmarks sidebar
|
||||||
|
- Filter out reads without timestamps or 'Untitled' items
|
||||||
|
- Show skeleton placeholders correctly during initial tab load
|
||||||
|
- External URLs in Reads tab only shown if they have reading progress
|
||||||
|
- Reading progress merges even when timestamp is older than bookmark
|
||||||
|
- Resolved all linter errors and TypeScript type issues
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Renamed ArchiveFilters component to ReadingProgressFilters
|
||||||
|
- Extracted shared utilities from readsFromBookmarks for DRY code
|
||||||
|
- Use setState callback pattern for background enrichment
|
||||||
|
- Use naddr format for article IDs to match reading positions
|
||||||
|
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
|
||||||
|
|
||||||
## [0.6.20] - 2025-10-15
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1641,7 +2479,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.11...HEAD
|
||||||
|
[0.10.11]: https://github.com/dergigi/boris/compare/v0.10.10...v0.10.11
|
||||||
|
[0.10.10]: https://github.com/dergigi/boris/compare/v0.10.9...v0.10.10
|
||||||
|
[0.10.9]: https://github.com/dergigi/boris/compare/v0.10.8...v0.10.9
|
||||||
|
[0.10.8]: https://github.com/dergigi/boris/compare/v0.10.7...v0.10.8
|
||||||
|
[0.10.7]: https://github.com/dergigi/boris/compare/v0.10.6...v0.10.7
|
||||||
|
[0.10.6]: https://github.com/dergigi/boris/compare/v0.10.5...v0.10.6
|
||||||
|
[0.10.5]: https://github.com/dergigi/boris/compare/v0.10.4...v0.10.5
|
||||||
|
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
|
||||||
|
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
|
||||||
|
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2
|
||||||
|
[0.10.1]: https://github.com/dergigi/boris/compare/v0.10.0...v0.10.1
|
||||||
|
[0.10.0]: https://github.com/dergigi/boris/compare/v0.9.1...v0.10.0
|
||||||
|
[0.9.1]: https://github.com/dergigi/boris/compare/v0.9.0...v0.9.1
|
||||||
|
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
|
||||||
|
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
|
||||||
|
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
|
||||||
|
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
|
||||||
|
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
||||||
|
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
|
||||||
|
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||||
|
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||||
|
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||||
|
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||||
- **Progress**: Reading progress indicator with completion state.
|
- **Progress**: Reading progress indicator with completion state.
|
||||||
|
- **Text‑to‑Speech**: Listen to articles with browser‑native TTS; play/pause/stop controls with adjustable speed (0.8–1.6x).
|
||||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||||
|
|
||||||
|
|||||||
297
api/article-og.ts
Normal file
297
api/article-og.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
// Relay configuration (from src/config/relays.ts)
|
||||||
|
const RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://relay.dergigi.com',
|
||||||
|
'wss://wot.dergigi.com',
|
||||||
|
'wss://relay.snort.social',
|
||||||
|
'wss://nostr-pub.wellorder.net',
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.primal.net'
|
||||||
|
]
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
html: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleMetadata {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEventsFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||||
|
|
||||||
|
// `request` emits NostrEvent objects directly
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by created_at and return most recent first
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode naddr
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
|
// Determine relay URLs
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Fetch article and profile in parallel
|
||||||
|
const [articleEvents, profileEvents] = await Promise.all([
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 5000),
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pointer.pubkey]
|
||||||
|
}, 3000)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (articleEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = articleEvents[0]
|
||||||
|
|
||||||
|
// Extract article metadata
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract author name from profile
|
||||||
|
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
if (profileEvents.length > 0) {
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvents[0].content)
|
||||||
|
authorName = profileData.display_name || profileData.name || authorName
|
||||||
|
} catch {
|
||||||
|
// Use fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata:', err)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
// No explicit close needed; pool manages connections internally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||||
|
const baseUrl = 'https://read.withboris.com'
|
||||||
|
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||||
|
|
||||||
|
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||||
|
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||||
|
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||||
|
const author = meta?.author || 'Boris'
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrawler(userAgent: string | undefined): boolean {
|
||||||
|
if (!userAgent) return false
|
||||||
|
const crawlers = [
|
||||||
|
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
||||||
|
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
||||||
|
]
|
||||||
|
const ua = userAgent.toLowerCase()
|
||||||
|
return crawlers.some(crawler => ua.includes(crawler))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = req.headers['user-agent'] as string | undefined
|
||||||
|
const isCrawlerRequest = isCrawler(userAgent)
|
||||||
|
|
||||||
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
|
if (debugEnabled) {
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||||
|
// Use history.replaceState to set the URL before the SPA boots
|
||||||
|
if (!isCrawlerRequest) {
|
||||||
|
const articlePath = `/a/${naddr}`
|
||||||
|
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Boris - Loading Article...</title>
|
||||||
|
<script>
|
||||||
|
// Set the URL to the article path before SPA loads
|
||||||
|
if (window.location.pathname !== '${articlePath}') {
|
||||||
|
history.replaceState(null, '', '${articlePath}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
|
||||||
|
<script>
|
||||||
|
// Redirect to index.html which will load the SPA
|
||||||
|
// The history state is already set, so SPA will see the correct URL
|
||||||
|
window.location.replace('/');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
if (debugEnabled) {
|
||||||
|
// Debug mode enabled
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache for bots/crawlers
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(naddr)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
// Debug mode enabled
|
||||||
|
}
|
||||||
|
return res.status(200).send(cached.html)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata
|
||||||
|
const meta = await fetchArticleMetadata(naddr)
|
||||||
|
|
||||||
|
// Generate HTML
|
||||||
|
const html = generateHtml(naddr, meta)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
// Debug mode enabled
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating article OG HTML:', err)
|
||||||
|
|
||||||
|
// Fallback to basic HTML with SPA boot
|
||||||
|
const html = generateHtml(naddr, null)
|
||||||
|
setCacheHeaders(res, 3600)
|
||||||
|
if (debugEnabled) {
|
||||||
|
// Debug mode enabled
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<meta property="og:url" content="https://read.withboris.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
<meta property="og:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.13",
|
"version": "0.10.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -11215,6 +11216,22 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyld": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tinyld": "bin/tinyld.js",
|
||||||
|
"tinyld-heavy": "bin/tinyld-heavy.js",
|
||||||
|
"tinyld-light": "bin/tinyld-light.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.10.0",
|
||||||
|
"npm": ">= 6.12.0",
|
||||||
|
"yarn": ">= 1.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.20",
|
"version": "0.10.12",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"rehype-prism-plus": "^2.0.1",
|
"rehype-prism-plus": "^2.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
@@ -9,6 +9,16 @@
|
|||||||
"background_color": "#0b1220",
|
"background_color": "#0b1220",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["productivity", "social", "utilities"],
|
"categories": ["productivity", "social", "utilities"],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "link"
|
||||||
|
}
|
||||||
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
|
|||||||
75
public/md/NIP-85.md
Normal file
75
public/md/NIP-85.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# NIP-85
|
||||||
|
|
||||||
|
## Reading Progress
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Format](#format)
|
||||||
|
* [Tags](#tags)
|
||||||
|
* [Content](#content)
|
||||||
|
* [Examples](#examples)
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
|
||||||
|
|
||||||
|
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
|
||||||
|
|
||||||
|
- `d` (required): Unique identifier for the target content
|
||||||
|
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
|
||||||
|
- For external URLs: `url:<base64url-encoded-url>`
|
||||||
|
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
|
||||||
|
- `r` (optional but recommended for URLs): Raw URL of the external content
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
The content is a JSON object with the following fields:
|
||||||
|
|
||||||
|
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
|
||||||
|
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
|
||||||
|
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
|
||||||
|
- `ver` (optional): Schema version string
|
||||||
|
|
||||||
|
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
|
||||||
|
|
||||||
|
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Nostr Article
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635012,
|
||||||
|
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "30023:<author-pubkey>:<article-identifier>"],
|
||||||
|
["a", "30023:<author-pubkey>:<article-identifier>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### External URL
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 39802,
|
||||||
|
"pubkey": "<user-pubkey>",
|
||||||
|
"created_at": 1734635999,
|
||||||
|
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
|
||||||
|
"tags": [
|
||||||
|
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
|
||||||
|
["r", "https://example.com/post"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
524
src/App.tsx
524
src/App.tsx
@@ -1,19 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||||
import { EventStore } from 'applesauce-core'
|
import { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
|
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
||||||
|
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
|
||||||
|
import { Bookmark } from './types/bookmarks'
|
||||||
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
|
import { contactsController } from './services/contactsController'
|
||||||
|
import { highlightsController } from './services/highlightsController'
|
||||||
|
import { writingsController } from './services/writingsController'
|
||||||
|
import { readingProgressController } from './services/readingProgressController'
|
||||||
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
|
import { archiveController } from './services/archiveController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -21,26 +39,140 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
|||||||
// AppRoutes component that has access to hooks
|
// AppRoutes component that has access to hooks
|
||||||
function AppRoutes({
|
function AppRoutes({
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
showToast
|
showToast
|
||||||
}: {
|
}: {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
|
eventStore: EventStore | null
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
|
// Centralized bookmark state (fed by controller)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [bookmarksLoading, setBookmarksLoading] = useState(false)
|
||||||
|
|
||||||
|
// Centralized contacts state (fed by controller)
|
||||||
|
const [contacts, setContacts] = useState<Set<string>>(new Set())
|
||||||
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Subscribe to bookmark controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||||
|
setBookmarks(bookmarks)
|
||||||
|
})
|
||||||
|
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||||
|
setBookmarksLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubBookmarks()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
setContacts(contacts)
|
||||||
|
})
|
||||||
|
const unsubLoading = contactsController.onLoading((loading) => {
|
||||||
|
setContactsLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubContacts()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAccount && relayPool) {
|
||||||
|
const pubkey = (activeAccount as { pubkey?: string }).pubkey
|
||||||
|
|
||||||
|
// Load bookmarks
|
||||||
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load contacts
|
||||||
|
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||||
|
contactsController.start({ relayPool, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load highlights (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||||
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load writings (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||||
|
writingsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reading progress (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
|
||||||
|
readingProgressController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load archive (marked-as-read) controller
|
||||||
|
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
|
||||||
|
archiveController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
|
if (eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||||
|
|
||||||
|
// Ensure nostrverse controllers run even when logged out
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
|
}, [relayPool, eventStore])
|
||||||
|
|
||||||
|
// Manual refresh (for sidebar button)
|
||||||
|
const handleRefreshBookmarks = useCallback(async () => {
|
||||||
|
if (!relayPool || !activeAccount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookmarkController.reset()
|
||||||
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
}, [relayPool, activeAccount, accountManager])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
accountManager.clearActive()
|
accountManager.clearActive()
|
||||||
|
bookmarkController.reset() // Clear bookmarks via controller
|
||||||
|
contactsController.reset() // Clear contacts via controller
|
||||||
|
highlightsController.reset() // Clear highlights via controller
|
||||||
|
readingProgressController.reset() // Clear reading progress via controller
|
||||||
|
archiveController.reset() // Clear archive state
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/share-target"
|
||||||
|
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/a/:naddr"
|
path="/a/:naddr"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -50,6 +182,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -59,6 +194,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -68,6 +206,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -77,6 +218,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -86,6 +230,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -99,24 +246,69 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/reading-list"
|
path="/me/bookmarks"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/me/reads"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reads/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -126,6 +318,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -135,6 +330,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -144,6 +342,34 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:eventId"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debug"
|
||||||
|
element={
|
||||||
|
<Debug
|
||||||
|
relayPool={relayPool}
|
||||||
|
eventStore={eventStore}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -165,20 +391,45 @@ function App() {
|
|||||||
const store = new EventStore()
|
const store = new EventStore()
|
||||||
const accounts = new AccountManager()
|
const accounts = new AccountManager()
|
||||||
|
|
||||||
|
// Disable request queueing globally - makes all operations instant
|
||||||
|
// Queue causes requests to wait for user interaction which blocks batch operations
|
||||||
|
accounts.disableQueue = true
|
||||||
|
|
||||||
// Register common account types (needed for deserialization)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
|
// Create relay pool and set it up BEFORE loading accounts
|
||||||
|
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||||
|
const pool = new RelayPool()
|
||||||
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
|
NostrConnectSigner.publishMethod = (relays: string[], event: NostrEvent) => {
|
||||||
|
// Fire-and-forget publish; do not block callers
|
||||||
|
pool.publish(relays, event).catch(() => { /* ignore errors */ })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a relay group for better event deduplication and management
|
||||||
|
pool.group(RELAYS)
|
||||||
|
|
||||||
// Load persisted accounts from localStorage
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
|
|
||||||
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
if (activeId && accounts.getAccount(activeId)) {
|
|
||||||
accounts.setActive(activeId)
|
if (activeId) {
|
||||||
console.log('Restored active account:', activeId)
|
const account = accounts.getAccount(activeId)
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
accounts.setActive(activeId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', err)
|
console.error('Failed to load accounts from storage:', err)
|
||||||
@@ -198,21 +449,253 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const pool = new RelayPool()
|
// Reconnect bunker signers when active account changes
|
||||||
|
// Keep track of which accounts we've already reconnected to avoid double-connecting
|
||||||
|
const reconnectedAccounts = new Set<string>()
|
||||||
|
|
||||||
// Create a relay group for better event deduplication and management
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
pool.group(RELAYS)
|
|
||||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
if (account && account.type === 'nostr-connect') {
|
||||||
console.log('Relay URLs:', RELAYS)
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||||
|
try {
|
||||||
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore queue disable errors
|
||||||
|
}
|
||||||
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
|
// Skip if we've already reconnected this account
|
||||||
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
|
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
|
||||||
|
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||||
|
// This ensures the pool has all relays when the signer sets up its methods
|
||||||
|
const bunkerRelays = signerData.relays || []
|
||||||
|
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||||
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
|
if (newBunkerRelays.length > 0) {
|
||||||
|
pool.group(newBunkerRelays)
|
||||||
|
} else {
|
||||||
|
// Bunker relays already in pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
|
relays: signerData.relays,
|
||||||
|
pubkey: nostrConnectAccount.pubkey,
|
||||||
|
remote: signerData.remote,
|
||||||
|
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||||
|
pool: pool
|
||||||
|
})
|
||||||
|
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||||
|
try {
|
||||||
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
|
recreatedSigner.relays = mergedRelays
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
|
||||||
|
// Replace the signer on the account
|
||||||
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
|
||||||
|
// Fire-and-forget publish for bunker: trigger but don't wait for completion
|
||||||
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
const result = originalPublish(relays, event)
|
||||||
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return {} as unknown as never
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
|
// The fromBunkerURI already connected with permissions during login
|
||||||
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
|
await nostrConnectAccount.signer.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
|
try {
|
||||||
|
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore reconnect errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
|
// This ensures the signer is ready to handle and receive responses
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||||
|
try {
|
||||||
|
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||||
|
return await Promise.race([
|
||||||
|
p,
|
||||||
|
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
setTimeout(async () => {
|
||||||
|
const self = nostrConnectAccount.pubkey
|
||||||
|
// Try a roundtrip so the bunker can respond successfully
|
||||||
|
try {
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore probe errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||||
|
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore probe errors
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore signer setup errors
|
||||||
|
}
|
||||||
|
// The bunker remembers the permissions from the initial connection
|
||||||
|
nostrConnectAccount.signer.isConnected = true
|
||||||
|
|
||||||
|
|
||||||
|
// Mark this account as reconnected
|
||||||
|
reconnectedAccounts.add(account.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open signer:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle user relay list and blocked relays when account changes
|
||||||
|
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||||
|
if (account) {
|
||||||
|
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||||
|
const pubkey = account.pubkey
|
||||||
|
|
||||||
|
// Bunker relays (if any)
|
||||||
|
let bunkerRelays: string[] = []
|
||||||
|
if (account.type === 'nostr-connect') {
|
||||||
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
bunkerRelays = signerData.relays || []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Start with hardcoded + bunker relays immediately (non-blocking)
|
||||||
|
const initialRelays = computeRelaySet({
|
||||||
|
hardcoded: RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: [],
|
||||||
|
blocked: [],
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Apply initial set immediately
|
||||||
|
applyRelaySetToPool(pool, initialRelays)
|
||||||
|
|
||||||
|
// Prepare keep-alive helper
|
||||||
|
const updateKeepAlive = () => {
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const activeRelays = getActiveRelayUrls(pool)
|
||||||
|
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin loading blocked relays in background
|
||||||
|
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||||
|
|
||||||
|
// Stream user relay list; apply immediately on first/updated event
|
||||||
|
loadUserRelayList(pool, pubkey, {
|
||||||
|
onUpdate: (userRelays) => {
|
||||||
|
const interimRelays = computeRelaySet({
|
||||||
|
hardcoded: HARDCODED_RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: userRelays,
|
||||||
|
blocked: [],
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, interimRelays)
|
||||||
|
updateKeepAlive()
|
||||||
|
}
|
||||||
|
}).then(async (userRelayList) => {
|
||||||
|
const blockedRelays = await blockedPromise.catch(() => [])
|
||||||
|
|
||||||
|
const finalRelays = computeRelaySet({
|
||||||
|
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||||
|
bunker: bunkerRelays,
|
||||||
|
userList: userRelayList,
|
||||||
|
blocked: blockedRelays,
|
||||||
|
alwaysIncludeLocal: ALWAYS_LOCAL_RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, finalRelays)
|
||||||
|
|
||||||
|
updateKeepAlive()
|
||||||
|
|
||||||
|
// Update address loader with new relays
|
||||||
|
const activeRelays = getActiveRelayUrls(pool)
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: activeRelays
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
||||||
|
// Continue with initial relay set on error - no need to change anything
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// User logged out - reset to hardcoded relays
|
||||||
|
|
||||||
|
applyRelaySetToPool(pool, RELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
// Update keep-alive subscription
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
|
||||||
|
// Reset address loader
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: RELAYS
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
// Create a minimal subscription that never completes to keep connections alive
|
// Create a minimal subscription that never completes to keep connections alive
|
||||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||||
next: () => {}, // No-op, we don't care about events
|
next: () => {},
|
||||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
error: () => {}
|
||||||
})
|
})
|
||||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||||
@@ -233,6 +716,8 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
|
bunkerReconnectSub.unsubscribe()
|
||||||
|
userRelaysSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
@@ -249,7 +734,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
// Monitor online/offline status
|
// Monitor online/offline status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -284,7 +769,8 @@ function App() {
|
|||||||
<AccountsProvider manager={accountManager}>
|
<AccountsProvider manager={accountManager}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
|
||||||
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
@@ -17,22 +16,29 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bookmark-filters">
|
<div className="bookmark-filters">
|
||||||
{filters.map(filter => (
|
{filters.map(filter => {
|
||||||
<button
|
const isActive = selectedFilter === filter.type
|
||||||
key={filter.type}
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
onClick={() => onFilterChange(filter.type)}
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
|
||||||
title={filter.label}
|
return (
|
||||||
aria-label={`Filter by ${filter.label}`}
|
<button
|
||||||
>
|
key={filter.type}
|
||||||
<FontAwesomeIcon icon={filter.icon} />
|
onClick={() => onFilterChange(filter.type)}
|
||||||
</button>
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
))}
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,46 @@ import { formatDistance } from 'date-fns'
|
|||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
|
import { isKnownBot } from '../config/bots'
|
||||||
|
|
||||||
interface BlogPostCardProps {
|
interface BlogPostCardProps {
|
||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
readingProgress?: number // 0-1 reading progress (optional)
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
|
hideBotByName?: boolean // default true
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
|
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
|
||||||
|
|
||||||
|
// Hide bot authors by name/display_name
|
||||||
|
if (hideBotByName && (rawName.includes('bot') || isKnownBot(post.author))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const publishedDate = post.published || post.event.created_at
|
const publishedDate = post.published || post.event.created_at
|
||||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate progress percentage and determine color
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug log - reading progress shown as visual indicator
|
||||||
|
if (readingProgress !== undefined) {
|
||||||
|
// Reading progress display
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ interface BookmarkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -139,12 +140,25 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon: getContentTypeIcon()
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
const compactProps = {
|
||||||
const { articleImage, ...compactProps } = sharedProps
|
bookmark,
|
||||||
|
index,
|
||||||
|
hasUrls,
|
||||||
|
extractedUrls,
|
||||||
|
onSelectUrl,
|
||||||
|
authorNpub,
|
||||||
|
eventNevent,
|
||||||
|
getAuthorDisplayName,
|
||||||
|
handleReadNow,
|
||||||
|
articleSummary,
|
||||||
|
contentTypeIcon: getContentTypeIcon(),
|
||||||
|
readingProgress
|
||||||
|
}
|
||||||
return <CompactView {...compactProps} />
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useRef, useState, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -13,14 +14,19 @@ import { ViewMode } from './Bookmarks'
|
|||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { BookmarkSkeleton } from './Skeletons'
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import LoginOptions from './LoginOptions'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -64,14 +70,75 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
|
return saved === 'grouped' ? 'grouped' : 'flat'
|
||||||
|
})
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Subscribe to reading progress updates
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial progress map
|
||||||
|
setReadingProgressMap(readingProgressController.getProgressMap())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a bookmark
|
||||||
|
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
|
||||||
|
if (bookmark.kind === 30023) {
|
||||||
|
// For articles, use naddr as key
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return readingProgressMap.get(naddr)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For web bookmarks and other types, try to use URL if available
|
||||||
|
const urls = extractUrlsFromContent(bookmark.content)
|
||||||
|
if (urls.length > 0) {
|
||||||
|
return readingProgressMap.get(urls[0])
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGroupingMode = () => {
|
||||||
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||||
|
setGroupingMode(newMode)
|
||||||
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilterTitle = (filter: BookmarkFilterType): string => {
|
||||||
|
const titles: Record<BookmarkFilterType, string> = {
|
||||||
|
'all': 'All Bookmarks',
|
||||||
|
'article': 'Bookmarked Reads',
|
||||||
|
'external': 'Bookmarked Links',
|
||||||
|
'video': 'Bookmarked Videos',
|
||||||
|
'note': 'Bookmarked Notes',
|
||||||
|
'web': 'Web Bookmarks'
|
||||||
|
}
|
||||||
|
return titles[filter]
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
if (!activeAccount || !relayPool) {
|
if (!activeAccount || !relayPool) {
|
||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
}
|
}
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull-to-refresh for bookmarks
|
// Pull-to-refresh for bookmarks
|
||||||
@@ -86,34 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
isDisabled: !onRefresh
|
isDisabled: !onRefresh
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const sections = useMemo(() => {
|
||||||
.filter(hasContent)
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
|
|
||||||
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
|
// Group non-set bookmarks by source or flatten based on mode
|
||||||
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
|
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
|
groupingMode === 'flat'
|
||||||
|
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||||
|
: [
|
||||||
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
|
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
|
||||||
|
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add bookmark sets as additional sections (only in grouped mode)
|
||||||
|
if (groupingMode === 'grouped') {
|
||||||
|
bookmarkSets.forEach(set => {
|
||||||
|
sectionsArray.push({
|
||||||
|
key: `set-${set.name}`,
|
||||||
|
title: set.title || set.name,
|
||||||
|
items: set.bookmarks
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectionsArray
|
||||||
|
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||||
|
|
||||||
// Apply filter
|
// Get all filtered bookmarks for empty state checks
|
||||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
const allIndividualBookmarks = useMemo(() =>
|
||||||
|
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||||
|
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||||
|
)
|
||||||
|
|
||||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
const filteredBookmarks = useMemo(() =>
|
||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
[allIndividualBookmarks, selectedFilter]
|
||||||
|
)
|
||||||
// Group non-set bookmarks as before
|
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
|
||||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add bookmark sets as additional sections
|
|
||||||
bookmarkSets.forEach(set => {
|
|
||||||
sections.push({
|
|
||||||
key: `set-${set.name}`,
|
|
||||||
title: set.title || set.name,
|
|
||||||
items: set.bookmarks
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -153,7 +244,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
{!activeAccount ? (
|
||||||
|
<LoginOptions />
|
||||||
|
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks match this filter.</p>
|
<p>No bookmarks match this filter.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +263,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -204,6 +296,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelectUrl={onSelectUrl}
|
onSelectUrl={onSelectUrl}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,40 +315,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
style={{ color: friendsColor }}
|
style={{ color: friendsColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="view-mode-right">
|
{activeAccount && (
|
||||||
{onRefresh && (
|
<div className="view-mode-right">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRotate}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||||
onClick={onRefresh}
|
onClick={toggleGroupingMode}
|
||||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel="Refresh bookmarks"
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{onRefresh && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faList}
|
icon={faRotate}
|
||||||
onClick={() => onViewModeChange('compact')}
|
onClick={onRefresh}
|
||||||
title="Compact list view"
|
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||||
ariaLabel="Compact list view"
|
ariaLabel="Refresh bookmarks"
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
variant="ghost"
|
||||||
/>
|
disabled={isRefreshing}
|
||||||
<IconButton
|
spin={isRefreshing}
|
||||||
icon={faThLarge}
|
/>
|
||||||
onClick={() => onViewModeChange('cards')}
|
)}
|
||||||
title="Cards view"
|
<IconButton
|
||||||
ariaLabel="Cards view"
|
icon={faList}
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
onClick={() => onViewModeChange('compact')}
|
||||||
/>
|
title="Compact list view"
|
||||||
<IconButton
|
ariaLabel="Compact list view"
|
||||||
icon={faImage}
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
onClick={() => onViewModeChange('large')}
|
/>
|
||||||
title="Large preview view"
|
<IconButton
|
||||||
ariaLabel="Large preview view"
|
icon={faThLarge}
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
onClick={() => onViewModeChange('cards')}
|
||||||
/>
|
title="Cards view"
|
||||||
</div>
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onViewModeChange('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<AddBookmarkModal
|
<AddBookmarkModal
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
@@ -24,6 +24,7 @@ interface CardViewProps {
|
|||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -38,7 +39,8 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||||
@@ -52,6 +54,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
const shouldTruncate = !expanded && contentLength > 210
|
const shouldTruncate = !expanded && contentLength > 210
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress color (matching BlogPostCard logic)
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine which image to use (article image, instant preview, or OG image)
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
@@ -102,10 +112,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
title="Open event in search"
|
title="Open event in search"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,19 +147,15 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isArticle && articleSummary ? (
|
{isArticle && articleSummary ? (
|
||||||
<div className="bookmark-content article-summary">
|
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||||
<ContentWithResolvedProfiles content={articleSummary} />
|
|
||||||
</div>
|
|
||||||
) : bookmark.parsedContent ? (
|
) : bookmark.parsedContent ? (
|
||||||
<div className="bookmark-content">
|
<div className="bookmark-content">
|
||||||
{shouldTruncate && bookmark.content
|
{shouldTruncate && bookmark.content
|
||||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}…`} className="" />
|
||||||
: renderParsedContent(bookmark.parsedContent)}
|
: renderParsedContent(bookmark.parsedContent)}
|
||||||
</div>
|
</div>
|
||||||
) : bookmark.content && (
|
) : bookmark.content && (
|
||||||
<div className="bookmark-content">
|
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||||
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentLength > 210 && (
|
{contentLength > 210 && (
|
||||||
@@ -163,6 +169,28 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round(readingProgress * 100)}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bookmark-footer">
|
<div className="bookmark-footer">
|
||||||
<div className="bookmark-meta-minimal">
|
<div className="bookmark-meta-minimal">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -13,6 +14,7 @@ interface CompactViewProps {
|
|||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactView: React.FC<CompactViewProps> = ({
|
export const CompactView: React.FC<CompactViewProps> = ({
|
||||||
@@ -22,26 +24,34 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
const isNote = bookmark.kind === 1
|
||||||
|
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||||
if (!onSelectUrl) return
|
|
||||||
|
// Calculate progress color
|
||||||
if (isArticle) {
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
} else if (hasUrls) {
|
progressColor = '#10b981' // Green (completed)
|
||||||
onSelectUrl(extractedUrls[0])
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
}
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For articles, prefer summary; for others, use content
|
const handleCompactClick = () => {
|
||||||
const displayText = isArticle && articleSummary
|
if (isArticle) {
|
||||||
? articleSummary
|
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||||
: bookmark.content
|
} else if (hasUrls) {
|
||||||
|
onSelectUrl?.(extractedUrls[0])
|
||||||
|
} else if (isNote) {
|
||||||
|
navigate(`/e/${bookmark.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
@@ -54,14 +64,41 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText ? (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
|
||||||
|
<code>{bookmark.id.slice(0, 12)}...</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reading progress indicator for all bookmark types with reading data */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '1px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
margin: '0',
|
||||||
|
marginLeft: '1.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round(readingProgress * 100)}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import RichContent from '../RichContent'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { getEventUrl } from '../../config/nostrGateways'
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
@@ -23,6 +23,7 @@ interface LargeViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -83,12 +95,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
|
|
||||||
<div className="large-content">
|
<div className="large-content">
|
||||||
{isArticle && articleSummary ? (
|
{isArticle && articleSummary ? (
|
||||||
<div className="large-text article-summary">
|
<RichContent content={articleSummary} className="large-text article-summary" />
|
||||||
<ContentWithResolvedProfiles content={articleSummary} />
|
|
||||||
</div>
|
|
||||||
) : bookmark.content && (
|
) : bookmark.content && (
|
||||||
<div className="large-text">
|
<RichContent content={bookmark.content} className="large-text" />
|
||||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -114,7 +144,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
import Me from './Me'
|
import Me from './Me'
|
||||||
|
import Profile from './Profile'
|
||||||
import Support from './Support'
|
import Support from './Support'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
@@ -24,10 +27,19 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
|||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
bookmarksLoading: boolean
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
relayPool,
|
||||||
|
onLogout,
|
||||||
|
bookmarks,
|
||||||
|
bookmarksLoading,
|
||||||
|
onRefreshBookmarks
|
||||||
|
}) => {
|
||||||
|
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -44,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const showMe = location.pathname.startsWith('/me')
|
const showMe = location.pathname.startsWith('/me')
|
||||||
const showProfile = location.pathname.startsWith('/p/')
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
|
const eventId = eventIdParam
|
||||||
|
|
||||||
// Extract tab from explore routes
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
@@ -51,8 +64,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
// Extract tab from me routes
|
// Extract tab from me routes
|
||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/bookmarks' ? 'bookmarks' :
|
||||||
location.pathname === '/me/archive' ? 'archive' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
|
location.pathname.startsWith('/me/links') ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
@@ -151,8 +165,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
@@ -165,12 +177,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
|
eventStore,
|
||||||
|
onRefreshBookmarks
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -233,6 +246,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
useExternalUrlLoader({
|
useExternalUrlLoader({
|
||||||
url: externalUrl,
|
url: externalUrl,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -243,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Load event if /e/:eventId route is used
|
||||||
|
useEventLoader({
|
||||||
|
eventId,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed
|
||||||
|
})
|
||||||
|
|
||||||
// Classify highlights with levels based on user context
|
// Classify highlights with levels based on user context
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
@@ -316,10 +341,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
support={showSupport ? (
|
support={showSupport ? (
|
||||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import rehypePrism from 'rehype-prism-plus'
|
import rehypePrism from 'rehype-prism-plus'
|
||||||
|
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import 'prismjs/themes/prism-tomorrow.css'
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ContentSkeleton } from './Skeletons'
|
import { ContentSkeleton } from './Skeletons'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
@@ -29,10 +30,12 @@ import {
|
|||||||
hasMarkedEventAsRead,
|
hasMarkedEventAsRead,
|
||||||
hasMarkedWebsiteAsRead
|
hasMarkedWebsiteAsRead
|
||||||
} from '../services/reactionService'
|
} from '../services/reactionService'
|
||||||
|
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||||
|
import { archiveController } from '../services/archiveController'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
@@ -40,9 +43,10 @@ import { EventFactory } from 'applesauce-factory'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import {
|
import {
|
||||||
generateArticleIdentifier,
|
generateArticleIdentifier,
|
||||||
loadReadingPosition,
|
saveReadingPosition,
|
||||||
saveReadingPosition
|
startReadingPositionStream
|
||||||
} from '../services/readingPositionService'
|
} from '../services/readingPositionService'
|
||||||
|
import TTSControls from './TTSControls'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -72,6 +76,7 @@ interface ContentPanelProps {
|
|||||||
// For reading progress indicator positioning
|
// For reading progress indicator positioning
|
||||||
isSidebarCollapsed?: boolean
|
isSidebarCollapsed?: boolean
|
||||||
isHighlightsCollapsed?: boolean
|
isHighlightsCollapsed?: boolean
|
||||||
|
onOpenHighlights?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -99,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
isSidebarCollapsed = false,
|
isSidebarCollapsed = false,
|
||||||
isHighlightsCollapsed = false
|
isHighlightsCollapsed = false,
|
||||||
|
onOpenHighlights
|
||||||
}) => {
|
}) => {
|
||||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
@@ -151,20 +157,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Callback to save reading position
|
// Callback to save reading position
|
||||||
const handleSavePosition = useCallback(async (position: number) => {
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
|
||||||
hasAccount: !!activeAccount,
|
|
||||||
hasRelayPool: !!relayPool,
|
|
||||||
hasEventStore: !!eventStore,
|
|
||||||
hasIdentifier: !!articleIdentifier
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!settings?.syncReadingPosition) {
|
if (!settings?.syncReadingPosition) {
|
||||||
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content is long enough to track reading progress
|
||||||
|
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = new EventFactory({ signer: activeAccount })
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
@@ -176,56 +180,51 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{
|
{
|
||||||
position,
|
position,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
scrollTop
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
|
||||||
|
|
||||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
const { progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
syncEnabled: settings?.syncReadingPosition,
|
syncEnabled: settings?.syncReadingPosition !== false,
|
||||||
onSave: handleSavePosition,
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Optional: Auto-mark as read when reading is complete
|
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||||
if (activeAccount && !isMarkedAsRead) {
|
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
|
||||||
// Could trigger auto-mark as read here if desired
|
if (!isMarkedAsRead) {
|
||||||
|
handleMarkAsRead()
|
||||||
|
} else {
|
||||||
|
// Already archived: still show the success animation for feedback
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
setTimeout(() => setShowCheckAnimation(false), 600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Log sync status when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||||
|
|
||||||
// Load saved reading position when article loads
|
// Load saved reading position when article loads (non-blocking, EOSE-driven)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
|
||||||
isTextContent,
|
|
||||||
hasAccount: !!activeAccount,
|
|
||||||
hasRelayPool: !!relayPool,
|
|
||||||
hasEventStore: !!eventStore,
|
|
||||||
hasIdentifier: !!articleIdentifier
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!settings?.syncReadingPosition) {
|
if (settings?.syncReadingPosition === false) {
|
||||||
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
const stop = startReadingPositionStream(
|
||||||
|
relayPool,
|
||||||
const loadPosition = async () => {
|
eventStore,
|
||||||
try {
|
activeAccount.pubkey,
|
||||||
const savedPosition = await loadReadingPosition(
|
articleIdentifier,
|
||||||
relayPool,
|
(savedPosition) => {
|
||||||
eventStore,
|
|
||||||
activeAccount.pubkey,
|
|
||||||
articleIdentifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
|
||||||
// Wait for content to be fully rendered before scrolling
|
// Wait for content to be fully rendered before scrolling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
@@ -236,22 +235,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
top: scrollTop,
|
top: scrollTop,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
|
||||||
}, 500) // Give content time to render
|
}, 500) // Give content time to render
|
||||||
} else if (savedPosition) {
|
|
||||||
if (savedPosition.position === 1) {
|
|
||||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
loadPosition()
|
return () => stop()
|
||||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
// Save position before unmounting or changing article
|
// Save position before unmounting or changing article
|
||||||
@@ -324,6 +313,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
const hasHighlights = relevantHighlights.length > 0
|
||||||
|
|
||||||
|
// Extract plain text for TTS
|
||||||
|
const baseHtml = useMemo(() => {
|
||||||
|
if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : ''
|
||||||
|
return finalHtml || html || ''
|
||||||
|
}, [markdown, renderedMarkdownHtml, finalHtml, html])
|
||||||
|
|
||||||
|
const articleText = useMemo(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (title) parts.push(title)
|
||||||
|
if (summary) parts.push(summary)
|
||||||
|
if (baseHtml) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = baseHtml
|
||||||
|
const txt = (div.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (txt) parts.push(txt)
|
||||||
|
}
|
||||||
|
return parts.join('. ')
|
||||||
|
}, [title, summary, baseHtml])
|
||||||
|
|
||||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||||
@@ -361,7 +369,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
if (!currentArticle) return null
|
if (!currentArticle) return null
|
||||||
|
|
||||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const relayHints = RELAYS.filter(r =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayHints = activeRelays.filter(r =>
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
).slice(0, 3)
|
).slice(0, 3)
|
||||||
|
|
||||||
@@ -467,7 +476,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenSearch = () => {
|
const handleOpenSearch = () => {
|
||||||
if (articleLinks) {
|
// For regular notes (kind:1), open via /e/ path
|
||||||
|
if (currentArticle?.kind === 1) {
|
||||||
|
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
|
||||||
|
window.open(borisUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
} else if (articleLinks) {
|
||||||
|
// For articles, use search portal
|
||||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
@@ -554,7 +568,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const handleSearchExternalUrl = () => {
|
const handleSearchExternalUrl = () => {
|
||||||
if (selectedUrl) {
|
if (selectedUrl) {
|
||||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||||
|
if (selectedUrl.startsWith('nostr-event:')) {
|
||||||
|
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||||
|
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||||
|
} else {
|
||||||
|
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowExternalMenu(false)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
@@ -577,12 +597,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
hasRead = hasRead || archiveController.isMarked(naddr)
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore encoding errors
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hasRead = await hasMarkedWebsiteAsRead(
|
hasRead = await hasMarkedWebsiteAsRead(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount.pubkey,
|
activeAccount.pubkey,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
// Also check archiveController
|
||||||
|
const ctrl = archiveController.isMarked(selectedUrl)
|
||||||
|
hasRead = hasRead || ctrl
|
||||||
}
|
}
|
||||||
setIsMarkedAsRead(hasRead)
|
setIsMarkedAsRead(hasRead)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -596,7 +629,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
if (!activeAccount || !relayPool) return
|
||||||
|
|
||||||
|
// Toggle archive state: if already archived, request deletion; else archive
|
||||||
|
if (isMarkedAsRead) {
|
||||||
|
// Optimistically unarchive in UI; background deletion request (NIP-09)
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
// Send deletion for all matching reactions
|
||||||
|
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
|
||||||
|
// Also clear controller mark so lists update
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.unmark(naddr)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[archive][content] encode naddr failed', e)
|
||||||
|
}
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
|
||||||
|
archiveController.unmark(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] unarchive failed', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,16 +679,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
currentArticle.pubkey,
|
currentArticle.pubkey,
|
||||||
currentArticle.kind,
|
currentArticle.kind,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool,
|
||||||
|
{
|
||||||
|
aCoord: (() => {
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
return `${30023}:${currentArticle.pubkey}:${dTag}`
|
||||||
|
} catch { return undefined }
|
||||||
|
})()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
console.log('✅ Marked nostr article as read')
|
// Update archiveController immediately
|
||||||
|
try {
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
|
||||||
|
archiveController.mark(naddr)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[archive][content] optimistic article mark failed', err)
|
||||||
|
}
|
||||||
} else if (selectedUrl) {
|
} else if (selectedUrl) {
|
||||||
await createWebsiteReaction(
|
await createWebsiteReaction(
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
console.log('✅ Marked website as read')
|
archiveController.mark(selectedUrl)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as read:', error)
|
console.error('Failed to mark as read:', error)
|
||||||
@@ -661,7 +740,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
{isTextContent && (
|
{isTextContent && (
|
||||||
<ReadingProgressIndicator
|
<ReadingProgressIndicator
|
||||||
progress={progressPercentage}
|
progress={progressPercentage}
|
||||||
isComplete={isReadingComplete}
|
// Consider complete only at 95%+
|
||||||
|
isComplete={progressPercentage >= 95}
|
||||||
showPercentage={true}
|
showPercentage={true}
|
||||||
isSidebarCollapsed={isSidebarCollapsed}
|
isSidebarCollapsed={isSidebarCollapsed}
|
||||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
@@ -676,11 +756,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
components={{
|
components={{
|
||||||
img: ({ src, alt, ...props }) => (
|
img: ({ src, alt }) => (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -701,7 +780,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
highlights={relevantHighlights}
|
highlights={relevantHighlights}
|
||||||
highlightVisibility={highlightVisibility}
|
highlightVisibility={highlightVisibility}
|
||||||
|
onHighlightCountClick={onOpenHighlights}
|
||||||
/>
|
/>
|
||||||
|
{isTextContent && articleText && (
|
||||||
|
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||||
|
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isExternalVideo ? (
|
{isExternalVideo ? (
|
||||||
<>
|
<>
|
||||||
<div className="reader-video">
|
<div className="reader-video">
|
||||||
@@ -767,8 +852,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
disabled={isCheckingReadStatus}
|
||||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||||
|
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
@@ -785,10 +871,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{markdown ? (
|
{markdown ? (
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-markdown"
|
html={finalHtml}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
|
className="reader-markdown"
|
||||||
onMouseUp={handleSelectionEnd}
|
onMouseUp={handleSelectionEnd}
|
||||||
onTouchEnd={handleSelectionEnd}
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
@@ -800,10 +887,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<VideoEmbedProcessor
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-html"
|
html={finalHtml || html || ''}
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||||
|
className="reader-html"
|
||||||
onMouseUp={handleSelectionEnd}
|
onMouseUp={handleSelectionEnd}
|
||||||
onTouchEnd={handleSelectionEnd}
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
@@ -837,13 +925,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<FontAwesomeIcon icon={faCopy} />
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
<span>Copy URL</span>
|
<span>Copy URL</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||||
className="article-menu-item"
|
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||||
onClick={handleOpenExternalUrl}
|
<button
|
||||||
>
|
className="article-menu-item"
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
onClick={handleOpenExternalUrl}
|
||||||
<span>Open Original</span>
|
>
|
||||||
</button>
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="article-menu-item"
|
className="article-menu-item"
|
||||||
onClick={handleSearchExternalUrl}
|
onClick={handleSearchExternalUrl}
|
||||||
@@ -930,21 +1021,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mark as Read button */}
|
{/* Archive button */}
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<div className="mark-as-read-container">
|
<div className="mark-as-read-container">
|
||||||
<button
|
<button
|
||||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
disabled={isCheckingReadStatus}
|
||||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
|
||||||
|
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
spin={isCheckingReadStatus}
|
spin={isCheckingReadStatus}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1960
src/components/Debug.tsx
Normal file
1960
src/components/Debug.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
@@ -8,20 +8,33 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
// Contacts are managed via controller subscription
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
import { getCachedPosts, setCachedPosts, getCachedHighlights, setCachedHighlights } from '../services/exploreCache'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
// import { KINDS } from '../config/kinds'
|
||||||
|
// import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
// import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
|
// Accessors from Helpers (currently unused here)
|
||||||
|
// const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -41,14 +54,193 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
const hasHydratedRef = useRef(false)
|
||||||
|
|
||||||
// Visibility filters (defaults from settings, or friends only)
|
// Get myHighlights directly from controller
|
||||||
|
const [/* myHighlights */, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
// Remove unused loading state to avoid warnings
|
||||||
|
|
||||||
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Load cached content from event store (instant display)
|
||||||
|
// const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
|
// const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||||
|
// event,
|
||||||
|
// title: getArticleTitle(event) || 'Untitled',
|
||||||
|
// summary: getArticleSummary(event),
|
||||||
|
// image: getArticleImage(event),
|
||||||
|
// published: getArticlePublished(event),
|
||||||
|
// author: event.pubkey
|
||||||
|
// }), [])
|
||||||
|
|
||||||
|
// const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Ensure at least one scope remains active
|
||||||
|
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
|
||||||
|
setVisibility(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||||
|
return prev // ignore toggle that would disable all scopes
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to highlights controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts stream and mirror into local state
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ensure contacts controller is started for the active account (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && activeAccount?.pubkey) {
|
||||||
|
contactsController.start({ relayPool, pubkey: activeAccount.pubkey }).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [relayPool, activeAccount?.pubkey])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: Highlight[]) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
|
for (const h of incoming) byId.set(h.id, h)
|
||||||
|
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// seed immediately
|
||||||
|
apply(nostrverseHighlightsController.getHighlights())
|
||||||
|
const unsub = nostrverseHighlightsController.onHighlights(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse writings controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: BlogPostPreview[]) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of prev) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
byKey.set(key, p)
|
||||||
|
}
|
||||||
|
for (const p of incoming) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apply(nostrverseWritingsController.getWritings())
|
||||||
|
const unsub = nostrverseWritingsController.onWritings(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to writings controller for "mine" posts and seed immediately
|
||||||
|
useEffect(() => {
|
||||||
|
// Seed from controller's current state
|
||||||
|
const seed = writingsController.getWritings()
|
||||||
|
if (seed.length > 0) {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream updates
|
||||||
|
const unsub = writingsController.onWritings((posts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Update visibility when settings/login state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount) {
|
||||||
|
// When logged out, show nostrverse by default
|
||||||
|
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||||
|
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
} else {
|
||||||
|
// When logged in, use settings defaults immediately
|
||||||
|
setVisibility({
|
||||||
|
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||||
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
|
})
|
||||||
|
setHasLoadedNostrverse(false)
|
||||||
|
setHasLoadedNostrverseHighlights(false)
|
||||||
|
}
|
||||||
|
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -56,162 +248,164 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
useEffect(() => {
|
// Load initial data and refresh on triggers
|
||||||
const loadData = async () => {
|
const loadData = useCallback(() => {
|
||||||
if (!activeAccount) {
|
if (!relayPool) return
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Seed from cache for instant UI
|
||||||
// show spinner but keep existing data
|
if (activeAccount) {
|
||||||
setLoading(true)
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
|
if (cachedPosts && cachedPosts.length > 0) setBlogPosts(cachedPosts)
|
||||||
|
const cached = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cached && cached.length > 0) setHighlights(cached)
|
||||||
|
}
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
setLoading(true)
|
||||||
// Use functional update to check current state without creating dependency
|
|
||||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
|
||||||
if (cachedPosts && cachedPosts.length > 0) {
|
|
||||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
|
||||||
}
|
|
||||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
|
||||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
|
||||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
try {
|
||||||
const contacts = await fetchContacts(
|
// Prepare parallel fetches
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
|
// Nostrverse writings: subscribe-style via onPost; hydrate on first post
|
||||||
|
if (!activeAccount || (activeAccount && visibility.nostrverse)) {
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount.pubkey,
|
relayUrls,
|
||||||
(partial) => {
|
50,
|
||||||
// Store followed pubkeys for highlight classification
|
eventStore || undefined,
|
||||||
setFollowedPubkeys(partial)
|
(post) => {
|
||||||
// When local contacts are available, kick off early fetch
|
setBlogPosts(prev => {
|
||||||
if (partial.size > 0) {
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
const partialArray = Array.from(partial)
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
// Fetch blog posts
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
fetchBlogPostsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
relayUrls,
|
|
||||||
(post) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
const exists = prev.some(p => p.event.id === post.event.id)
|
|
||||||
if (exists) return prev
|
|
||||||
const next = [...prev, post]
|
|
||||||
return next.sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setBlogPosts((prev) => {
|
|
||||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
|
||||||
for (const post of all) byId.set(post.event.id, post)
|
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
setCachedPosts(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch highlights
|
|
||||||
fetchHighlightsFromAuthors(
|
|
||||||
relayPool,
|
|
||||||
partialArray,
|
|
||||||
(highlight) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const exists = prev.some(h => h.id === highlight.id)
|
|
||||||
if (exists) return prev
|
|
||||||
const next = [...prev, highlight]
|
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
})
|
|
||||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
|
||||||
}
|
|
||||||
).then((all) => {
|
|
||||||
setHighlights((prev) => {
|
|
||||||
const byId = new Map(prev.map(h => [h.id, h]))
|
|
||||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
setCachedHighlights(activeAccount.pubkey, merged)
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
).then((nostrversePosts) => {
|
||||||
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
// Always proceed to load nostrverse content even if no contacts
|
}).catch(() => {})
|
||||||
// (removed blocking error for empty contacts)
|
}
|
||||||
|
|
||||||
// Store final followed pubkeys
|
|
||||||
setFollowedPubkeys(contacts)
|
|
||||||
|
|
||||||
// Fetch both friends content and nostrverse content in parallel
|
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const contactsArray = Array.from(contacts)
|
|
||||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
|
||||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
|
||||||
fetchNostrverseHighlights(relayPool, 100)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Merge and deduplicate all posts
|
|
||||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
|
||||||
const postsByKey = new Map<string, BlogPostPreview>()
|
|
||||||
for (const post of allPosts) {
|
|
||||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
|
||||||
const existing = postsByKey.get(key)
|
|
||||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
|
||||||
postsByKey.set(key, post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
|
||||||
const timeB = b.published || b.event.created_at
|
|
||||||
return timeB - timeA
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merge and deduplicate all highlights
|
|
||||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
|
||||||
const highlightsByKey = new Map<string, Highlight>()
|
|
||||||
for (const highlight of allHighlights) {
|
|
||||||
highlightsByKey.set(highlight.id, highlight)
|
|
||||||
}
|
|
||||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
|
|
||||||
// Fetch profiles for all blog post authors to cache them
|
|
||||||
if (uniquePosts.length > 0) {
|
|
||||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
|
||||||
console.error('Failed to fetch author profiles:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// No blocking errors - let empty states handle messaging
|
|
||||||
setBlogPosts(uniquePosts)
|
|
||||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
|
||||||
|
|
||||||
setHighlights(uniqueHighlights)
|
|
||||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}
|
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
}, [loadData, refreshTrigger])
|
||||||
|
|
||||||
|
// Kick off friends fetches reactively when contacts arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool) return
|
||||||
|
if (followedPubkeys.size === 0) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const contactsArray = Array.from(followedPubkeys)
|
||||||
|
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls, (post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
// Pre-cache profiles in background
|
||||||
|
const authorPubkeys = Array.from(new Set(merged.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, 100, eventStore).then((friendsPosts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||||
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
fetchHighlightsFromAuthors(relayPool, contactsArray, (highlight) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, highlight])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
if (!hasHydratedRef.current) { hasHydratedRef.current = true; setLoading(false) }
|
||||||
|
}, eventStore || undefined).then((friendsHighlights) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [relayPool, followedPubkeys, eventStore, settings, activeAccount])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
setHasLoadedNostrverse(true)
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
|
relayPool,
|
||||||
|
relayUrls,
|
||||||
|
50,
|
||||||
|
eventStore || undefined,
|
||||||
|
(post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existingIndex = prev.findIndex(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = prev[existingIndex]
|
||||||
|
if (post.event.created_at <= existing.event.created_at) return prev
|
||||||
|
const next = [...prev]
|
||||||
|
next[existingIndex] = post
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
}
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
).then((finalPosts) => {
|
||||||
|
// Ensure final deduped list
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of [...prev, ...finalPosts]) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((nostriverseHighlights) => {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [activeAccount, relayPool, visibility.nostrverse, hasLoadedNostrverse, eventStore])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((hl) => {
|
||||||
|
if (hl && hl.length > 0) {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
|
||||||
|
|
||||||
|
// Lazy-load my writings when user toggles "mine" on (logged in)
|
||||||
|
// No direct fetch here; writingsController streams my posts centrally
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !visibility.mine || hasLoadedMine) return
|
||||||
|
setHasLoadedMine(true)
|
||||||
|
}, [visibility.mine, activeAccount, hasLoadedMine])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -249,15 +443,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
})
|
})
|
||||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Dedupe and sort posts once for rendering
|
||||||
|
const uniqueSortedPosts = useMemo(() => {
|
||||||
|
const unique = dedupeWritingsByReplaceable(blogPosts)
|
||||||
|
return unique.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [blogPosts])
|
||||||
|
|
||||||
// Filter blog posts by future dates and visibility, and add level classification
|
// Filter blog posts by future dates and visibility, and add level classification
|
||||||
const filteredBlogPosts = useMemo(() => {
|
const filteredBlogPosts = useMemo(() => {
|
||||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||||
return blogPosts
|
return uniqueSortedPosts
|
||||||
.filter(post => {
|
.filter(post => {
|
||||||
// Filter out future dates
|
// Filter out future dates
|
||||||
const publishedTime = post.published || post.event.created_at
|
const publishedTime = post.published || post.event.created_at
|
||||||
if (publishedTime > maxFutureTime) return false
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Hide bot authors by profile display name if setting enabled
|
||||||
|
if (settings?.hideBotArticlesByName !== false) {
|
||||||
|
// Profile resolution and filtering is handled in BlogPostCard via ProfileModel
|
||||||
|
// Keep list intact here; individual cards will render null if author is a bot
|
||||||
|
}
|
||||||
|
|
||||||
// Apply visibility filters
|
// Apply visibility filters
|
||||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
const isFriend = followedPubkeys.has(post.author)
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
@@ -276,7 +486,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility, settings?.hideBotArticlesByName])
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[progress] ❌ Error encoding naddr:', err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -302,6 +534,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
level={post.level}
|
level={post.level}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
|
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +553,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
return classifiedHighlights.length === 0 ? (
|
return classifiedHighlights.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
<span>No highlights to show for the selected scope.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -338,7 +572,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show skeletons while first load in this session
|
||||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
@@ -350,7 +584,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<h1>
|
<h1>
|
||||||
<FontAwesomeIcon icon={faNewspaper} />
|
<FontAwesomeIcon icon={faPersonHiking} />
|
||||||
Explore
|
Explore
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -367,7 +601,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faNetworkWired}
|
icon={faNetworkWired}
|
||||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
onClick={() => toggleScope('nostrverse')}
|
||||||
title="Toggle nostrverse content"
|
title="Toggle nostrverse content"
|
||||||
ariaLabel="Toggle nostrverse content"
|
ariaLabel="Toggle nostrverse content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -378,7 +612,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUserGroup}
|
icon={faUserGroup}
|
||||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
onClick={() => toggleScope('friends')}
|
||||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||||
ariaLabel="Toggle friends content"
|
ariaLabel="Toggle friends content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -390,7 +624,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUser}
|
icon={faUser}
|
||||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
onClick={() => toggleScope('mine')}
|
||||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||||
ariaLabel="Toggle my content"
|
ariaLabel="Toggle my content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -422,7 +656,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTabContent()}
|
<div>
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
// Fallback: extract directly from p tag
|
// Fallback: extract directly from p tag
|
||||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||||
if (pTag && pTag[1]) {
|
if (pTag && pTag[1]) {
|
||||||
console.log('📝 Found author from p tag:', pTag[1])
|
|
||||||
return pTag[1]
|
return pTag[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (!highlight.eventReference) return
|
if (!highlight.eventReference) return
|
||||||
|
|
||||||
|
// Skip if it's a raw event ID (hex string without colons)
|
||||||
|
// Raw event IDs cannot be decoded to nadrs without additional context
|
||||||
|
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert eventReference to naddr if needed
|
// Convert eventReference to naddr if needed
|
||||||
let naddr: string
|
let naddr: string
|
||||||
if (highlight.eventReference.includes(':')) {
|
if (highlight.eventReference.includes(':')) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Models, IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal } from '../utils/helpers'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
import { createDeletionRequest } from '../services/deletionService'
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
@@ -17,6 +17,7 @@ import { getNostrUrl } from '../config/nostrGateways'
|
|||||||
import CompactButton from './CompactButton'
|
import CompactButton from './CompactButton'
|
||||||
import { HighlightCitation } from './HighlightCitation'
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
|
||||||
// Helper to detect if a URL is an image
|
// Helper to detect if a URL is an image
|
||||||
const isImageUrl = (url: string): boolean => {
|
const isImageUrl = (url: string): boolean => {
|
||||||
@@ -29,99 +30,6 @@ const isImageUrl = (url: string): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render a nostr identifier
|
|
||||||
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
|
||||||
try {
|
|
||||||
// Remove nostr: prefix
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
const decoded = nip19.decode(identifier)
|
|
||||||
|
|
||||||
switch (decoded.type) {
|
|
||||||
case 'npub': {
|
|
||||||
const pubkey = decoded.data
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${nip19.npubEncode(pubkey)}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nprofile': {
|
|
||||||
const { pubkey } = decoded.data
|
|
||||||
const npub = nip19.npubEncode(pubkey)
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/p/${npub}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{pubkey.slice(0, 8)}...
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'naddr': {
|
|
||||||
const { kind, pubkey, identifier } = decoded.data
|
|
||||||
// Check if it's a blog post (kind:30023)
|
|
||||||
if (kind === 30023) {
|
|
||||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={`/a/${naddr}`}
|
|
||||||
className="highlight-comment-link"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{identifier || 'Article'}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// For other kinds, show shortened identifier
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
nostr:{identifier.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'note': {
|
|
||||||
const eventId = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
note:{eventId.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'nevent': {
|
|
||||||
const { id } = decoded.data
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
event:{id.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Fallback for unrecognized types
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If decoding fails, show shortened identifier
|
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
||||||
return (
|
|
||||||
<span key={index} className="highlight-comment-nostr-id">
|
|
||||||
{identifier.slice(0, 20)}...
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component to render comment with links, inline images, and nostr identifiers
|
// Component to render comment with links, inline images, and nostr identifiers
|
||||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||||
// Pattern to match both http(s) URLs and nostr: URIs
|
// Pattern to match both http(s) URLs and nostr: URIs
|
||||||
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
// Handle nostr: URIs
|
// Handle nostr: URIs - now with profile resolution
|
||||||
if (part.startsWith('nostr:')) {
|
if (part.startsWith('nostr:')) {
|
||||||
return renderNostrId(part, index)
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle http(s) URLs
|
// Handle http(s) URLs
|
||||||
@@ -236,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
setShowOfflineIndicator(false)
|
setShowOfflineIndicator(false)
|
||||||
|
|
||||||
// Update the highlight with all relays after successful sync
|
// Update the highlight with all relays after successful sync
|
||||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||||
const updatedHighlight = {
|
const updatedHighlight = {
|
||||||
...highlight,
|
...highlight,
|
||||||
publishedRelays: RELAYS,
|
publishedRelays: getActiveRelayUrls(relayPool),
|
||||||
isLocalOnly: false,
|
isLocalOnly: false,
|
||||||
isOfflineCreated: false
|
isOfflineCreated: false
|
||||||
}
|
}
|
||||||
@@ -250,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [highlight, onHighlightUpdate])
|
}, [highlight, onHighlightUpdate, relayPool])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && itemRef.current) {
|
if (isSelected && itemRef.current) {
|
||||||
@@ -310,7 +224,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
const getHighlightLinks = () => {
|
const getHighlightLinks = () => {
|
||||||
// Encode the highlight event itself (kind 9802) as a nevent
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
// Get non-local relays for the hint
|
// Get non-local relays for the hint
|
||||||
const relayHints = RELAYS.filter(r =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayHints = activeRelays.filter(r =>
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
).slice(0, 3) // Include up to 3 relay hints
|
||||||
|
|
||||||
@@ -346,13 +261,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish to all configured relays - let the relay pool handle connection state
|
// Publish to all configured relays - let the relay pool handle connection state
|
||||||
const targetRelays = RELAYS
|
const targetRelays = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
|
||||||
|
|
||||||
await relayPool.publish(targetRelays, event)
|
await relayPool.publish(targetRelays, event)
|
||||||
|
|
||||||
console.log('✅ Rebroadcast successful!')
|
|
||||||
|
|
||||||
// Update the highlight with new relay info
|
// Update the highlight with new relay info
|
||||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||||
@@ -416,7 +329,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: show all relays we queried (where this was likely fetched from)
|
// Fallback: show all relays we queried (where this was likely fetched from)
|
||||||
const relayNames = RELAYS.map(url =>
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
|
const relayNames = activeRelays.map(url =>
|
||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -449,7 +363,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
relayPool
|
relayPool
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight deletion request published')
|
|
||||||
|
|
||||||
// Notify parent to remove this highlight from the list
|
// Notify parent to remove this highlight from the list
|
||||||
if (onHighlightDelete) {
|
if (onHighlightDelete) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
settings
|
settings,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
const [showHighlights, setShowHighlights] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||||
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && filteredHighlights.length === 0 ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
|
|||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||||
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
onToggleHighlights,
|
onToggleHighlights,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onHighlightVisibilityChange
|
onHighlightVisibilityChange,
|
||||||
|
isMobile = false
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="highlights-header">
|
<div className="highlights-header">
|
||||||
@@ -46,36 +48,38 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
{currentUserPubkey && (
|
||||||
icon={faUserGroup}
|
<>
|
||||||
onClick={() => onHighlightVisibilityChange({
|
<IconButton
|
||||||
...highlightVisibility,
|
icon={faUserGroup}
|
||||||
friends: !highlightVisibility.friends
|
onClick={() => onHighlightVisibilityChange({
|
||||||
})}
|
...highlightVisibility,
|
||||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
friends: !highlightVisibility.friends
|
||||||
ariaLabel="Toggle friends highlights"
|
})}
|
||||||
variant="ghost"
|
title="Toggle friends highlights"
|
||||||
disabled={!currentUserPubkey}
|
ariaLabel="Toggle friends highlights"
|
||||||
style={{
|
variant="ghost"
|
||||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
style={{
|
||||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
}}
|
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||||
/>
|
}}
|
||||||
<IconButton
|
/>
|
||||||
icon={faUser}
|
<IconButton
|
||||||
onClick={() => onHighlightVisibilityChange({
|
icon={faUser}
|
||||||
...highlightVisibility,
|
onClick={() => onHighlightVisibilityChange({
|
||||||
mine: !highlightVisibility.mine
|
...highlightVisibility,
|
||||||
})}
|
mine: !highlightVisibility.mine
|
||||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
})}
|
||||||
ariaLabel="Toggle my highlights"
|
title="Toggle my highlights"
|
||||||
variant="ghost"
|
ariaLabel="Toggle my highlights"
|
||||||
disabled={!currentUserPubkey}
|
variant="ghost"
|
||||||
style={{
|
style={{
|
||||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
@@ -99,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
{!isMobile && (
|
||||||
icon={faChevronRight}
|
<IconButton
|
||||||
onClick={onToggleCollapse}
|
icon={faChevronRight}
|
||||||
title="Collapse highlights panel"
|
onClick={onToggleCollapse}
|
||||||
ariaLabel="Collapse highlights panel"
|
title="Collapse highlights panel"
|
||||||
variant="ghost"
|
ariaLabel="Collapse highlights panel"
|
||||||
style={{ transform: 'rotate(180deg)' }}
|
variant="ghost"
|
||||||
/>
|
style={{ transform: 'rotate(180deg)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
209
src/components/LoginOptions.tsx
Normal file
209
src/components/LoginOptions.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPuzzlePiece, faShieldHalved, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
|
||||||
|
const LoginOptions: React.FC = () => {
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||||
|
const [bunkerUri, setBunkerUri] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<React.ReactNode | null>(null)
|
||||||
|
|
||||||
|
const handleExtensionLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Extension login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// Check if extension is not installed
|
||||||
|
if (errorMessage.includes('Signer extension missing') || errorMessage.includes('window.nostr') || errorMessage.includes('not found') || errorMessage.includes('undefined') || errorMessage.toLowerCase().includes('extension missing')) {
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
No browser extension found. Please install{' '}
|
||||||
|
<a href="https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp" target="_blank" rel="noopener noreferrer">
|
||||||
|
nos2x
|
||||||
|
</a>
|
||||||
|
{' '}or another nostr extension.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (errorMessage.includes('denied') || errorMessage.includes('rejected') || errorMessage.includes('cancel')) {
|
||||||
|
setError('Authentication was cancelled or denied.')
|
||||||
|
} else {
|
||||||
|
setError(`Authentication failed: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
Invalid bunker URI. Must start with bunker://. Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
// Show helpful message for bunker connection failures
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
Failed: {errorMessage}
|
||||||
|
<br /><br />
|
||||||
|
Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="empty-state login-container">
|
||||||
|
<div className="login-content">
|
||||||
|
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||||
|
<p className="login-description">
|
||||||
|
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="login-buttons">
|
||||||
|
{!showBunkerInput && (
|
||||||
|
<button
|
||||||
|
onClick={handleExtensionLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="login-button login-button-primary"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPuzzlePiece} />
|
||||||
|
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showBunkerInput ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBunkerInput(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="login-button login-button-secondary"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShieldHalved} />
|
||||||
|
<span>Signer</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bunker-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bunker-input"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBunkerLogin()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bunker-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isLoading || !bunkerUri.trim()}
|
||||||
|
className="bunker-button bunker-connect"
|
||||||
|
>
|
||||||
|
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
setBunkerUri('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bunker-button bunker-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="login-footer">
|
||||||
|
New to nostr? Start here:{' '}
|
||||||
|
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||||
|
nstart.me
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginOptions
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
134
src/components/NostrMentionLink.tsx
Normal file
134
src/components/NostrMentionLink.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
|
||||||
|
interface NostrMentionLinkProps {
|
||||||
|
nostrUri: string
|
||||||
|
onClick?: (e: React.MouseEvent) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render nostr mentions with resolved profile names
|
||||||
|
* Handles npub, nprofile, note, nevent, and naddr URIs
|
||||||
|
*/
|
||||||
|
const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
|
||||||
|
nostrUri,
|
||||||
|
onClick,
|
||||||
|
className = 'highlight-comment-link'
|
||||||
|
}) => {
|
||||||
|
// Decode the nostr URI first
|
||||||
|
let decoded: ReturnType<typeof nip19.decode> | null = null
|
||||||
|
let pubkey: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
|
// Extract pubkey for profile fetching (works for npub and nprofile)
|
||||||
|
if (decoded.type === 'npub') {
|
||||||
|
pubkey = decoded.data
|
||||||
|
} else if (decoded.type === 'nprofile') {
|
||||||
|
pubkey = decoded.data.pubkey
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Decoding failed, will fallback to shortened identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile at top level (Rules of Hooks)
|
||||||
|
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
|
||||||
|
// If decoding failed, show shortened identifier
|
||||||
|
if (!decoded) {
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render based on decoded type
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub': {
|
||||||
|
const pk = decoded.data
|
||||||
|
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${nip19.npubEncode(pk)}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
const { pubkey: pk } = decoded.data
|
||||||
|
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
|
||||||
|
const npub = nip19.npubEncode(pk)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
|
||||||
|
// Check if it's a blog post (kind:30023)
|
||||||
|
if (kind === 30023) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey: pk, identifier: addrIdentifier })
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/a/${naddr}`}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{addrIdentifier || 'Article'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// For other kinds, show shortened identifier
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
nostr:{addrIdentifier.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
const eventId = decoded.data
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
note:{eventId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
const { id } = decoded.data
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
event:{id.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Fallback for unrecognized types
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NostrMentionLink
|
||||||
|
|
||||||
270
src/components/Profile.tsx
Normal file
270
src/components/Profile.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
activeTab?: 'highlights' | 'writings'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile: React.FC<ProfileProps> = ({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey,
|
||||||
|
activeTab: propActiveTab
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Load cached data from event store instantly
|
||||||
|
const cachedHighlights = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
|
eventToHighlight,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cachedWritings = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
{ kinds: [30023], authors: [pubkey] },
|
||||||
|
toBlogPostPreview,
|
||||||
|
[pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort writings by publication date, newest first
|
||||||
|
const sortedWritings = useMemo(() => {
|
||||||
|
return cachedWritings.slice().sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [cachedWritings])
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Subscribe to reading progress controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
const initialMap = readingProgressController.getProgressMap()
|
||||||
|
setReadingProgressMap(initialMap)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubProgress = readingProgressController.onProgress((newMap) => {
|
||||||
|
setReadingProgressMap(newMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubProgress()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load reading progress data when logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount?.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgressController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Background fetch to populate event store (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pubkey || !relayPool || !eventStore) return
|
||||||
|
|
||||||
|
// Fetch all highlights and writings in background (no limits)
|
||||||
|
const relayUrls = getActiveRelayUrls(relayPool)
|
||||||
|
|
||||||
|
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
|
||||||
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||||
|
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, [pubkey], relayUrls, undefined, null, eventStore)
|
||||||
|
.catch(err => console.warn('⚠️ [Profile] Failed to fetch writings:', err))
|
||||||
|
}, [pubkey, relayPool, eventStore, refreshTrigger])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !pubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get reading progress for a post
|
||||||
|
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
const progress = readingProgressMap.get(naddr)
|
||||||
|
|
||||||
|
// Only log when found or map is empty
|
||||||
|
if (progress || readingProgressMap.size === 0) {
|
||||||
|
// Progress found or map is empty
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [readingProgressMap])
|
||||||
|
|
||||||
|
const handleHighlightDelete = () => {
|
||||||
|
// Not allowed to delete other users' highlights
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cachedHighlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No highlights yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="highlights-list me-highlights-list">
|
||||||
|
{cachedHighlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={{ ...highlight, level: 'mine' }}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return sortedWritings.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles written yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{sortedWritings.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={getReadingProgress(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate(`/p/${npub}`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate(`/p/${npub}/writings`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tab-content">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
|||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
highlights?: Highlight[]
|
highlights?: Highlight[]
|
||||||
highlightVisibility?: HighlightVisibility
|
highlightVisibility?: HighlightVisibility
|
||||||
|
onHighlightCountClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
highlightCount,
|
highlightCount,
|
||||||
settings,
|
settings,
|
||||||
highlights = [],
|
highlights = [],
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
|
onHighlightCountClick
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image)
|
const cachedImage = useImageCache(image)
|
||||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||||
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(true)}
|
style={getHighlightIndicatorStyles(true)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div
|
<div
|
||||||
className="highlight-indicator"
|
className="highlight-indicator clickable"
|
||||||
style={getHighlightIndicatorStyles(false)}
|
style={getHighlightIndicatorStyles(false)}
|
||||||
|
onClick={onHighlightCountClick}
|
||||||
|
title="Open highlights sidebar"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
|||||||
60
src/components/ReadingProgressFilters.tsx
Normal file
60
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
// Archive-marked items (previously emoji-marked)
|
||||||
|
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
|
||||||
|
let activeStyle: Record<string, string> | undefined = undefined
|
||||||
|
if (isActive) {
|
||||||
|
if (filter.type === 'completed') {
|
||||||
|
activeStyle = { color: '#10b981' } // green
|
||||||
|
} else if (filter.type === 'highlighted') {
|
||||||
|
activeStyle = { color: '#fde047' } // yellow
|
||||||
|
} else if (filter.type === 'archive') {
|
||||||
|
activeStyle = { color: '#60a5fa' } // blue accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
const leftOffset = isSidebarCollapsed
|
const leftOffset = isSidebarCollapsed
|
||||||
? 'var(--sidebar-collapsed-width)'
|
? 'var(--sidebar-collapsed-width)'
|
||||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
style={{ backgroundColor: 'var(--color-border)' }}
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
isComplete
|
|
||||||
? 'bg-green-500'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${clampedProgress}%`,
|
width: `${clampedProgress}%`,
|
||||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
backgroundColor: barColorStyle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
isComplete ? 'text-green-500' : ''
|
isComplete ? 'text-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🔌 Relay Status Indicator:', {
|
// Mode and relay status determined
|
||||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
|
||||||
totalStatuses: relayStatuses.length,
|
|
||||||
connectedCount: connectedUrls.length,
|
|
||||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
|
||||||
hasLocalRelay,
|
|
||||||
hasRemoteRelay,
|
|
||||||
isConnecting
|
|
||||||
})
|
|
||||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
|
||||||
|
|
||||||
// Don't show indicator when fully connected (but show when connecting)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
|||||||
fontWeight: 400
|
fontWeight: 400
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
Local relays only
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
77
src/components/RichContent.tsx
Normal file
77
src/components/RichContent.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
|
||||||
|
interface RichContentProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render text content with:
|
||||||
|
* - Clickable links
|
||||||
|
* - Resolved nostr mentions (npub, nprofile, note, nevent, naddr)
|
||||||
|
* - Plain text
|
||||||
|
*
|
||||||
|
* Handles both nostr:npub1... and plain npub1... formats
|
||||||
|
*/
|
||||||
|
const RichContent: React.FC<RichContentProps> = ({
|
||||||
|
content,
|
||||||
|
className = 'bookmark-content'
|
||||||
|
}) => {
|
||||||
|
// Pattern to match:
|
||||||
|
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
|
||||||
|
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
|
||||||
|
// 3. http(s) URLs
|
||||||
|
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
|
||||||
|
|
||||||
|
const parts = content.split(pattern)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Handle nostr: URIs
|
||||||
|
if (part.startsWith('nostr:')) {
|
||||||
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={part}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain nostr identifiers (add nostr: prefix)
|
||||||
|
if (
|
||||||
|
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NostrMentionLink
|
||||||
|
key={index}
|
||||||
|
nostrUri={`nostr:${part}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle http(s) URLs
|
||||||
|
if (part.match(/^https?:\/\//)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={part}
|
||||||
|
className="nostr-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text
|
||||||
|
return <React.Fragment key={index}>{part}</React.Fragment>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichContent
|
||||||
|
|
||||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,11 +6,15 @@ import IconButton from './IconButton'
|
|||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
import ThemeSettings from './Settings/ThemeSettings'
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
|
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||||
|
import ExploreSettings from './Settings/ExploreSettings'
|
||||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
import PWASettings from './Settings/PWASettings'
|
import PWASettings from './Settings/PWASettings'
|
||||||
|
import TTSSettings from './Settings/TTSSettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -28,13 +32,24 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
|
defaultExploreScopeNostrverse: false,
|
||||||
|
defaultExploreScopeFriends: true,
|
||||||
|
defaultExploreScopeMine: false,
|
||||||
zapSplitHighlighterWeight: 50,
|
zapSplitHighlighterWeight: 50,
|
||||||
zapSplitBorisWeight: 2.1,
|
zapSplitBorisWeight: 2.1,
|
||||||
zapSplitAuthorWeight: 50,
|
zapSplitAuthorWeight: 50,
|
||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
syncReadingPosition: false,
|
fullWidthImages: true,
|
||||||
|
renderVideoLinksAsEmbeds: true,
|
||||||
|
syncReadingPosition: true,
|
||||||
|
autoMarkAsReadOnCompletion: false,
|
||||||
|
hideBookmarksWithoutCreationDate: true,
|
||||||
|
ttsUseSystemLanguage: false,
|
||||||
|
ttsDetectContentLanguage: true,
|
||||||
|
ttsLanguageMode: 'content',
|
||||||
|
ttsDefaultSpeed: 2.1,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -162,11 +177,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/components/Settings/ExploreSettings.tsx
Normal file
72
src/components/Settings/ExploreSettings.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
interface ExploreSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Explore</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Explore Scope</label>
|
||||||
|
<div className="highlight-level-toggles">
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
|
||||||
|
title="Nostrverse content"
|
||||||
|
ariaLabel="Toggle nostrverse content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
|
||||||
|
title="Friends content"
|
||||||
|
ariaLabel="Toggle friends content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
|
||||||
|
title="My content"
|
||||||
|
ariaLabel="Toggle my content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBotArticlesByName" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBotArticlesByName"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBotArticlesByName !== false}
|
||||||
|
onChange={(e) => onUpdate({ hideBotArticlesByName: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide content posted by bots</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExploreSettings
|
||||||
|
|
||||||
@@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Sync reading position across devices</span>
|
<span>Sync reading position across devices</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoMarkAsReadOnCompletion"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoMarkAsReadOnCompletion ?? false}
|
||||||
|
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Automatically move to archive at 100%</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="hideBookmarksWithoutCreationDate"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.hideBookmarksWithoutCreationDate ?? false}
|
||||||
|
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Hide bookmarks missing a creation date</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
|
interface MediaDisplaySettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Media Display</h3>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="fullWidthImages"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.fullWidthImages === true}
|
||||||
|
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Full-width images in articles</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="renderVideoLinksAsEmbeds"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||||
|
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Render video links as embeds</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaDisplaySettings
|
||||||
@@ -27,7 +27,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
|||||||
if (isInstalled) return
|
if (isInstalled) return
|
||||||
const success = await installApp()
|
const success = await installApp()
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('App installed successfully')
|
// Installation successful
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Highlight Visibility</label>
|
<label>Default Highlight Visibility</label>
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
|
|||||||
86
src/components/Settings/TTSSettings.tsx
Normal file
86
src/components/Settings/TTSSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import TTSControls from '../TTSControls'
|
||||||
|
|
||||||
|
interface TTSSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
|
||||||
|
|
||||||
|
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const currentSpeed = settings.ttsDefaultSpeed || 2.1
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Text-to-Speech</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Playback Speed</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{currentSpeed}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Speaker language</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<select
|
||||||
|
value={settings.ttsLanguageMode || 'content'}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value
|
||||||
|
onUpdate({
|
||||||
|
ttsLanguageMode: value,
|
||||||
|
ttsUseSystemLanguage: value === 'system',
|
||||||
|
ttsDetectContentLanguage: value === 'content'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="setting-select"
|
||||||
|
>
|
||||||
|
<option value="system">System Language</option>
|
||||||
|
<option value="content">Content (auto-detect)</option>
|
||||||
|
<option disabled>────────────</option>
|
||||||
|
<option value="en-US">English (American)</option>
|
||||||
|
<option value="en-GB">English (British)</option>
|
||||||
|
<option value="zh">Mandarin Chinese</option>
|
||||||
|
<option value="es">Spanish</option>
|
||||||
|
<option value="hi">Hindi</option>
|
||||||
|
<option value="ar">Arabic</option>
|
||||||
|
<option value="fr">French</option>
|
||||||
|
<option value="pt">Portuguese</option>
|
||||||
|
<option value="de">German</option>
|
||||||
|
<option value="ja">Japanese</option>
|
||||||
|
<option value="ru">Russian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||||
|
{EXAMPLE_TEXT}
|
||||||
|
</div>
|
||||||
|
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSSettings
|
||||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
|
|
||||||
|
interface ShareTargetHandlerProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming shared URLs from the Web Share Target API.
|
||||||
|
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||||
|
*/
|
||||||
|
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSharedContent = async () => {
|
||||||
|
// Parse query parameters
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const link = params.get('link')
|
||||||
|
const title = params.get('title')
|
||||||
|
const text = params.get('text')
|
||||||
|
|
||||||
|
// Validate we have a URL
|
||||||
|
if (!link) {
|
||||||
|
showToast('No URL to save')
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no active account, wait for login
|
||||||
|
if (!activeAccount) {
|
||||||
|
setWaitingForLogin(true)
|
||||||
|
showToast('Please log in to save this bookmark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have account and URL, proceed with saving
|
||||||
|
if (!processing) {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await createWebBookmark(
|
||||||
|
link,
|
||||||
|
title || undefined,
|
||||||
|
text || undefined,
|
||||||
|
undefined,
|
||||||
|
activeAccount,
|
||||||
|
relayPool,
|
||||||
|
getActiveRelayUrls(relayPool)
|
||||||
|
)
|
||||||
|
showToast('Bookmark saved!')
|
||||||
|
navigate('/me/links')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save shared bookmark:', err)
|
||||||
|
showToast('Failed to save bookmark')
|
||||||
|
navigate('/')
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSharedContent()
|
||||||
|
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
|
||||||
|
|
||||||
|
// Show waiting for login state
|
||||||
|
if (waitingForLogin && !activeAccount) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||||
|
<p className="text-lg">Waiting for login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show processing state
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
|
||||||
|
<p className="text-lg">Saving bookmark...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
|
||||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
try {
|
|
||||||
setIsConnecting(true)
|
|
||||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
|
||||||
accountManager.addAccount(account)
|
|
||||||
accountManager.setActive(account)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
|
||||||
} finally {
|
|
||||||
setIsConnecting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProfileImage = () => {
|
const getProfileImage = () => {
|
||||||
return profile?.picture || null
|
return profile?.picture || null
|
||||||
}
|
}
|
||||||
@@ -53,80 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-header-bar">
|
<div className="sidebar-header-bar">
|
||||||
{isMobile ? (
|
{activeAccount && (
|
||||||
<IconButton
|
<button
|
||||||
icon={faTimes}
|
className="profile-avatar-button"
|
||||||
onClick={onToggleCollapse}
|
title={getUserDisplayName()}
|
||||||
title="Close sidebar"
|
onClick={() => navigate('/me')}
|
||||||
ariaLabel="Close sidebar"
|
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||||
variant="ghost"
|
|
||||||
className="mobile-close-btn"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
className="toggle-sidebar-btn"
|
|
||||||
title="Collapse bookmarks sidebar"
|
|
||||||
aria-label="Collapse bookmarks sidebar"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
{profileImage ? (
|
||||||
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar-header-right">
|
<div className="sidebar-header-right">
|
||||||
<div
|
<IconButton
|
||||||
className="profile-avatar"
|
icon={faHome}
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
onClick={() => navigate('/')}
|
||||||
onClick={
|
title="Home"
|
||||||
activeAccount
|
ariaLabel="Home"
|
||||||
? () => navigate('/me')
|
variant="ghost"
|
||||||
: (isConnecting ? () => {} : handleLogin)
|
/>
|
||||||
}
|
<IconButton
|
||||||
style={{ cursor: 'pointer' }}
|
icon={faGear}
|
||||||
>
|
onClick={onOpenSettings}
|
||||||
{profileImage ? (
|
title="Settings"
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
ariaLabel="Settings"
|
||||||
) : (
|
variant="ghost"
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faPersonHiking}
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
title="Explore"
|
||||||
|
ariaLabel="Explore"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
{activeAccount && (
|
||||||
|
<IconButton
|
||||||
|
icon={faRightFromBracket}
|
||||||
|
onClick={onLogout}
|
||||||
|
title="Logout"
|
||||||
|
ariaLabel="Logout"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="toggle-sidebar-btn"
|
||||||
|
title="Collapse bookmarks sidebar"
|
||||||
|
aria-label="Collapse bookmarks sidebar"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
icon={faHome}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
title="Home"
|
|
||||||
ariaLabel="Home"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faNewspaper}
|
|
||||||
onClick={() => navigate('/explore')}
|
|
||||||
title="Explore"
|
|
||||||
ariaLabel="Explore"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faGear}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
ariaLabel="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
{activeAccount ? (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightFromBracket}
|
|
||||||
onClick={onLogout}
|
|
||||||
title="Logout"
|
|
||||||
ariaLabel="Logout"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightToBracket}
|
|
||||||
onClick={isConnecting ? () => {} : handleLogin}
|
|
||||||
title={isConnecting ? "Connecting..." : "Login"}
|
|
||||||
ariaLabel="Login"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
|
|||||||
|
|
||||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSupporters = async () => {
|
const loadSupporters = async () => {
|
||||||
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
|
|
||||||
if (zappers.length > 0) {
|
if (zappers.length > 0) {
|
||||||
const pubkeys = zappers.map(z => z.pubkey)
|
const pubkeys = zappers.map(z => z.pubkey)
|
||||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
// Fetch profiles in background without blocking
|
||||||
|
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
setSupporters(zappers)
|
setSupporters(zappers)
|
||||||
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
loadSupporters()
|
loadSupporters()
|
||||||
}, [relayPool, eventStore, settings])
|
}, [relayPool, eventStore, settings])
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||||
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supporters.length === 0 ? (
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
<div className="mb-16 md:mb-20">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Legends
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Supporters
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : supporters.length === 0 ? (
|
||||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,5 +249,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SupporterSkeletonProps {
|
||||||
|
isWhale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Avatar Skeleton */}
|
||||||
|
<div
|
||||||
|
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
|
||||||
|
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whale Badge Skeleton */}
|
||||||
|
{isWhale && (
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-border)',
|
||||||
|
borderColor: 'var(--color-bg)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and Total Skeleton */}
|
||||||
|
<div className="mt-2 text-center space-y-1">
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Support
|
export default Support
|
||||||
|
|
||||||
|
|||||||
113
src/components/TTSControls.tsx
Normal file
113
src/components/TTSControls.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useTextToSpeech } from '../hooks/useTextToSpeech'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { detect } from 'tinyld'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
defaultLang?: string
|
||||||
|
className?: string
|
||||||
|
settings?: UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||||
|
|
||||||
|
const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }) => {
|
||||||
|
const {
|
||||||
|
supported, speaking, paused,
|
||||||
|
speak, pause, resume,
|
||||||
|
rate, setRate
|
||||||
|
} = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed })
|
||||||
|
|
||||||
|
const canPlay = supported && text?.trim().length > 0
|
||||||
|
|
||||||
|
const resolvedSystemLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) {
|
||||||
|
return navigator?.language?.split('-')[0]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage])
|
||||||
|
|
||||||
|
const detectContentLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
if (mode) return mode === 'content'
|
||||||
|
return settings?.ttsDetectContentLanguage !== false
|
||||||
|
}, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage])
|
||||||
|
|
||||||
|
const specificLang = useMemo(() => {
|
||||||
|
const mode = settings?.ttsLanguageMode
|
||||||
|
// If mode is not 'system' or 'content', it's a specific language code
|
||||||
|
if (mode && mode !== 'system' && mode !== 'content') {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [settings?.ttsLanguageMode])
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!canPlay) return
|
||||||
|
|
||||||
|
if (!speaking) {
|
||||||
|
let langOverride: string | undefined
|
||||||
|
|
||||||
|
// Priority: specific language > content detection > system language
|
||||||
|
if (specificLang) {
|
||||||
|
langOverride = specificLang
|
||||||
|
} else if (detectContentLang && text) {
|
||||||
|
try {
|
||||||
|
const lang = detect(text)
|
||||||
|
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||||
|
} catch (err) {
|
||||||
|
// ignore detection errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!langOverride && resolvedSystemLang) {
|
||||||
|
langOverride = resolvedSystemLang
|
||||||
|
}
|
||||||
|
speak(text, langOverride)
|
||||||
|
} else if (paused) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCycleSpeed = () => {
|
||||||
|
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
|
const next = SPEED_OPTIONS[nextIndex]
|
||||||
|
setRate(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause')
|
||||||
|
|
||||||
|
if (!supported) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className || 'tts-controls'} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
title={playLabel}
|
||||||
|
disabled={!canPlay}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={!speaking ? faPlay : (paused ? faPlay : faPause)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleCycleSpeed}
|
||||||
|
title="Cycle speed"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGauge} />
|
||||||
|
<span>{rate}x</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TTSControls
|
||||||
|
|
||||||
@@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
summary={props.readerContent?.summary}
|
summary={props.readerContent?.summary}
|
||||||
published={props.readerContent?.published}
|
published={props.readerContent?.published}
|
||||||
selectedUrl={props.selectedUrl}
|
selectedUrl={props.selectedUrl}
|
||||||
highlights={props.classifiedHighlights}
|
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
|
? props.highlights // article-specific highlights only
|
||||||
|
: props.classifiedHighlights}
|
||||||
showHighlights={props.showHighlights}
|
showHighlights={props.showHighlights}
|
||||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||||
@@ -385,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
currentArticle={props.currentArticle}
|
currentArticle={props.currentArticle}
|
||||||
isSidebarCollapsed={props.isCollapsed}
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
|
onOpenHighlights={() => {
|
||||||
|
if (props.isHighlightsCollapsed) {
|
||||||
|
props.onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -411,10 +418,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.hasActiveAccount && (
|
{props.hasActiveAccount && props.readerContent && (
|
||||||
<HighlightButton
|
<HighlightButton
|
||||||
ref={props.highlightButtonRef}
|
ref={props.highlightButtonRef}
|
||||||
onHighlight={props.onCreateHighlight}
|
onHighlight={props.onCreateHighlight}
|
||||||
|
|||||||
32
src/components/VersionFooter.tsx
Normal file
32
src/components/VersionFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const VersionFooter: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>
|
||||||
|
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
|
||||||
|
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·{' '}
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionFooter
|
||||||
212
src/components/VideoEmbedProcessor.tsx
Normal file
212
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useMemo, forwardRef } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
|
||||||
|
interface VideoEmbedProcessorProps {
|
||||||
|
html: string
|
||||||
|
renderVideoLinksAsEmbeds: boolean
|
||||||
|
className?: string
|
||||||
|
onMouseUp?: (e: React.MouseEvent) => void
|
||||||
|
onTouchEnd?: (e: React.TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that processes HTML content and optionally embeds video links
|
||||||
|
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
|
||||||
|
*/
|
||||||
|
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||||
|
html,
|
||||||
|
renderVideoLinksAsEmbeds,
|
||||||
|
className,
|
||||||
|
onMouseUp,
|
||||||
|
onTouchEnd
|
||||||
|
}, ref) => {
|
||||||
|
const processedHtml = useMemo(() => {
|
||||||
|
if (!renderVideoLinksAsEmbeds || !html) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||||
|
let result = html
|
||||||
|
|
||||||
|
const collectedUrls: string[] = []
|
||||||
|
let placeholderIndex = 0
|
||||||
|
|
||||||
|
// 1) Replace entire <video>...</video> blocks when they reference a video URL
|
||||||
|
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||||
|
const videoBlocks = result.match(videoBlockPattern) || []
|
||||||
|
videoBlocks.forEach((block) => {
|
||||||
|
// Try src on <video>
|
||||||
|
let url: string | null = null
|
||||||
|
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||||
|
url = videoSrcMatch[1]
|
||||||
|
} else {
|
||||||
|
// Try nested <source>
|
||||||
|
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||||
|
url = sourceSrcMatch[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
collectedUrls.push(url)
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
result = result.replace(new RegExp(escaped, 'g'), placeholder)
|
||||||
|
placeholderIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Replace entire <img ...> tags if their src points to a video
|
||||||
|
const imgTagPattern = /<img[^>]*>/gi
|
||||||
|
const allImgTags = result.match(imgTagPattern) || []
|
||||||
|
allImgTags.forEach((imgTag) => {
|
||||||
|
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||||
|
if (srcMatch && srcMatch[1]) {
|
||||||
|
const videoUrl = srcMatch[1]
|
||||||
|
collectedUrls.push(videoUrl)
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
|
||||||
|
placeholderIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
|
||||||
|
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||||
|
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
|
||||||
|
|
||||||
|
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||||
|
const allUrls: string[] = result.match(allUrlPattern) || []
|
||||||
|
const platformVideoUrls = allUrls.filter(url => {
|
||||||
|
// include URLs classified as video and not already collected
|
||||||
|
const classification = classifyUrl(url)
|
||||||
|
return classification.type === 'video' && !collectedUrls.includes(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||||
|
|
||||||
|
let processedHtml = result
|
||||||
|
remainingUrls.forEach((url) => {
|
||||||
|
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||||
|
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||||
|
collectedUrls.push(url)
|
||||||
|
placeholderIndex++
|
||||||
|
})
|
||||||
|
|
||||||
|
// If nothing collected, return original html
|
||||||
|
if (collectedUrls.length === 0) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedHtml
|
||||||
|
}, [html, renderVideoLinksAsEmbeds])
|
||||||
|
|
||||||
|
const videoUrls = useMemo(() => {
|
||||||
|
if (!renderVideoLinksAsEmbeds || !html) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
// 1) Extract from <video> blocks first (video src or nested source src)
|
||||||
|
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||||
|
const videoBlocks = html.match(videoBlockPattern) || []
|
||||||
|
videoBlocks.forEach((block) => {
|
||||||
|
let url: string | null = null
|
||||||
|
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||||
|
url = videoSrcMatch[1]
|
||||||
|
} else {
|
||||||
|
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||||
|
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||||
|
url = sourceSrcMatch[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url && !urls.includes(url)) urls.push(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Extract from <img> tags with video src
|
||||||
|
const imgTagPattern = /<img[^>]*>/gi
|
||||||
|
const allImgTags = html.match(imgTagPattern) || []
|
||||||
|
allImgTags.forEach((imgTag) => {
|
||||||
|
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||||
|
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
|
||||||
|
urls.push(srcMatch[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) Extract remaining direct file URLs and platform-classified video URLs
|
||||||
|
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||||
|
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
|
||||||
|
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
|
||||||
|
|
||||||
|
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||||
|
const allUrls: string[] = html.match(allUrlPattern) || []
|
||||||
|
allUrls.forEach(u => {
|
||||||
|
const classification = classifyUrl(u)
|
||||||
|
if (classification.type === 'video' && !urls.includes(u)) {
|
||||||
|
urls.push(u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}, [html, renderVideoLinksAsEmbeds])
|
||||||
|
|
||||||
|
// If no video embedding is enabled, just render the HTML normally
|
||||||
|
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the HTML by video placeholders and render with embedded players
|
||||||
|
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||||
|
if (videoMatch) {
|
||||||
|
const videoIndex = parseInt(videoMatch[1])
|
||||||
|
const videoUrl = videoUrls[videoIndex]
|
||||||
|
if (videoUrl) {
|
||||||
|
return (
|
||||||
|
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
|
||||||
|
<ReactPlayer
|
||||||
|
url={videoUrl}
|
||||||
|
controls
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16/9'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular HTML content
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
dangerouslySetInnerHTML={{ __html: part }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
|
||||||
|
|
||||||
|
export default VideoEmbedProcessor
|
||||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||||
|
* These are accounts known to be bots or automated services
|
||||||
|
*/
|
||||||
|
export const BOT_PUBKEYS = new Set([
|
||||||
|
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||||
|
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pubkey corresponds to a known bot
|
||||||
|
*/
|
||||||
|
export function isKnownBot(pubkey: string): boolean {
|
||||||
|
return BOT_PUBKEYS.has(pubkey)
|
||||||
|
}
|
||||||
22
src/config/kinds.ts
Normal file
22
src/config/kinds.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-84 user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data
|
||||||
|
ReadingProgress: 39802, // NIP-85 reading progress
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
|
// Reading progress tracking configuration
|
||||||
|
export const READING_PROGRESS = {
|
||||||
|
// Minimum character count to track reading progress (roughly 150 words)
|
||||||
|
MIN_CONTENT_LENGTH: 1000
|
||||||
|
} as const
|
||||||
|
|
||||||
@@ -7,16 +7,15 @@
|
|||||||
export const RELAYS = [
|
export const RELAYS = [
|
||||||
'ws://localhost:10547',
|
'ws://localhost:10547',
|
||||||
'ws://localhost:4869',
|
'ws://localhost:4869',
|
||||||
|
'wss://relay.nsec.app',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.nostr.band',
|
'wss://relay.nostr.band',
|
||||||
'wss://relay.dergigi.com',
|
|
||||||
'wss://wot.dergigi.com',
|
'wss://wot.dergigi.com',
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
'wss://relay.current.fyi',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
'wss://nostr-pub.wellorder.net',
|
||||||
'wss://purplepag.es',
|
'wss://purplepag.es',
|
||||||
'wss://relay.primal.net',
|
'wss://relay.primal.net',
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
|
|||||||
height: Math.floor(height * 0.25)
|
height: Math.floor(height * 0.25)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Adaptive color detected:', {
|
// Color analysis complete
|
||||||
hex: color.hex,
|
|
||||||
rgb: color.rgb,
|
|
||||||
isLight: color.isLight,
|
|
||||||
isDark: color.isDark
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use library's built-in isLight check for optimal contrast
|
// Use library's built-in isLight check for optimal contrast
|
||||||
if (color.isLight) {
|
if (color.isLight) {
|
||||||
console.log('Light background detected, using black text')
|
|
||||||
setColors({
|
setColors({
|
||||||
textColor: '#000000'
|
textColor: '#000000'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log('Dark background detected, using white text')
|
|
||||||
setColors({
|
setColors({
|
||||||
textColor: '#ffffff'
|
textColor: '#ffffff'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { fetchArticleByNaddr } from '../services/articleService'
|
import { fetchArticleByNaddr } from '../services/articleService'
|
||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
@@ -14,7 +14,7 @@ interface UseArticleLoaderProps {
|
|||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: Dispatch<SetStateAction<Highlight[]>>
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
@@ -36,18 +36,26 @@ export function useArticleLoader({
|
|||||||
setCurrentArticle,
|
setCurrentArticle,
|
||||||
settings
|
settings
|
||||||
}: UseArticleLoaderProps) {
|
}: UseArticleLoaderProps) {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !naddr) return
|
if (!relayPool || !naddr) return
|
||||||
|
|
||||||
const loadArticle = async () => {
|
const loadArticle = async () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
// Keep highlights panel collapsed by default - only open on user interaction
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
markdown: article.markdown,
|
markdown: article.markdown,
|
||||||
@@ -63,52 +71,67 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleCoordinate(articleCoordinate)
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
setCurrentArticleEventId(article.event.id)
|
setCurrentArticleEventId(article.event.id)
|
||||||
setCurrentArticle?.(article.event)
|
setCurrentArticle?.(article.event)
|
||||||
|
|
||||||
console.log('📰 Article loaded:', article.title)
|
|
||||||
console.log('📍 Coordinate:', articleCoordinate)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after article content is ready
|
|
||||||
// Don't wait for highlights to finish loading
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights asynchronously without blocking article display
|
// Fetch highlights asynchronously without blocking article display
|
||||||
// Stream them as they arrive for instant rendering
|
|
||||||
try {
|
try {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
setHighlights([]) // Clear old highlights
|
setHighlights([])
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
articleCoordinate,
|
articleCoordinate,
|
||||||
article.event.id,
|
article.event.id,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
// Deduplicate highlights by ID as they arrive
|
if (!mountedRef.current) return
|
||||||
if (!highlightsMap.has(highlight.id)) {
|
|
||||||
highlightsMap.set(highlight.id, highlight)
|
setHighlights((prev: Highlight[]) => {
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
const next = [highlight, ...prev]
|
||||||
}
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load article:', err)
|
console.error('Failed to load article:', err)
|
||||||
setReaderContent({
|
if (mountedRef.current) {
|
||||||
title: 'Error Loading Article',
|
setReaderContent({
|
||||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
title: 'Error Loading Article',
|
||||||
url: `nostr:${naddr}`
|
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
})
|
url: `nostr:${naddr}`
|
||||||
setReaderLoading(false)
|
})
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadArticle()
|
loadArticle()
|
||||||
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
naddr,
|
||||||
|
relayPool,
|
||||||
|
settings,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
|
setHighlights,
|
||||||
|
setHighlightsLoading,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId,
|
||||||
|
setCurrentArticle
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,189 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
|
||||||
import { fetchContacts } from '../services/contactService'
|
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
accountManager: AccountManager
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
externalUrl?: string
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||||
|
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksData = ({
|
export const useBookmarksData = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
}: UseBookmarksDataParams) => {
|
eventStore,
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
onRefreshBookmarks
|
||||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
// Determine effective article coordinate as early as possible
|
||||||
if (!relayPool || !activeAccount) return
|
// Prefer state-derived coordinate, but fall back to route naddr before content loads
|
||||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
const effectiveArticleCoordinate = useMemo(() => {
|
||||||
setFollowedPubkeys(contacts)
|
if (currentArticleCoordinate) return currentArticleCoordinate
|
||||||
}, [relayPool, activeAccount])
|
if (!naddr) return undefined
|
||||||
|
|
||||||
const handleFetchBookmarks = useCallback(async () => {
|
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
|
||||||
setBookmarksLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const fullAccount = accountManager.getActive()
|
const decoded = nip19.decode(naddr)
|
||||||
// merge-friendly: updater form that preserves visible list until replacement
|
if (decoded.type === 'naddr') {
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||||
setBookmarks(() => next)
|
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||||
}, settings)
|
}
|
||||||
} finally {
|
} catch {
|
||||||
setBookmarksLoading(false)
|
// ignore decode failure; treat as no coordinate yet
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, accountManager, settings])
|
return undefined
|
||||||
|
}, [currentArticleCoordinate, naddr])
|
||||||
|
|
||||||
|
// Load cached article-specific highlights from event store
|
||||||
|
const articleFilter = useMemo(() => {
|
||||||
|
if (!effectiveArticleCoordinate) return null
|
||||||
|
return {
|
||||||
|
kinds: [KINDS.Highlights],
|
||||||
|
'#a': [effectiveArticleCoordinate],
|
||||||
|
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||||
|
}
|
||||||
|
}, [effectiveArticleCoordinate, currentArticleEventId])
|
||||||
|
|
||||||
|
const cachedArticleHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||||
|
eventToHighlight,
|
||||||
|
[effectiveArticleCoordinate, currentArticleEventId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to centralized controllers
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setMyHighlights(highlightsController.getHighlights())
|
||||||
|
setFollowedPubkeys(new Set(contactsController.getContacts()))
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
unsubContacts()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleFetchHighlights = useCallback(async () => {
|
const handleFetchHighlights = useCallback(async () => {
|
||||||
if (!relayPool) return
|
if (!relayPool) return
|
||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
try {
|
try {
|
||||||
if (currentArticleCoordinate) {
|
if (effectiveArticleCoordinate) {
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedArticleHighlights.length > 0) {
|
||||||
|
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh article-specific highlights (from all users)
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
// Seed map with cached highlights
|
||||||
|
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
currentArticleCoordinate,
|
effectiveArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
// Deduplicate highlights by ID as they arrive
|
// Deduplicate highlights by ID as they arrive
|
||||||
if (!highlightsMap.has(highlight.id)) {
|
if (!highlightsMap.has(highlight.id)) {
|
||||||
highlightsMap.set(highlight.id, highlight)
|
highlightsMap.set(highlight.id, highlight)
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
const highlightsList = Array.from(highlightsMap.values())
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings
|
settings,
|
||||||
|
false, // force
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
} else {
|
||||||
} else if (activeAccount) {
|
// No article selected - clear article highlights
|
||||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
setArticleHighlights([])
|
||||||
setHighlights(fetchedHighlights)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(async () => {
|
const handleRefreshAll = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount || isRefreshing) return
|
if (!relayPool || !activeAccount || isRefreshing) return
|
||||||
|
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await handleFetchBookmarks()
|
await onRefreshBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
// Contacts and own highlights are managed by controllers
|
||||||
setLastFetchTime(Date.now())
|
setLastFetchTime(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh data:', err)
|
console.error('Failed to refresh data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
|
||||||
|
|
||||||
// Load initial data (avoid clearing on route-only changes)
|
// Fetch article-specific highlights when viewing an article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
// Fetch article-specific highlights when viewing an article
|
||||||
handleFetchBookmarks()
|
|
||||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
|
||||||
|
|
||||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
|
||||||
useEffect(() => {
|
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
|
||||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
if (!naddr && !externalUrl) {
|
if (effectiveArticleCoordinate && !externalUrl) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
|
} else if (!naddr && !externalUrl) {
|
||||||
|
// Clear article highlights when not viewing an article
|
||||||
|
setArticleHighlights([])
|
||||||
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
|
||||||
|
// When viewing an article, show only article-specific highlights
|
||||||
|
// Otherwise, show user's highlights from controller
|
||||||
|
const highlights = effectiveArticleCoordinate || externalUrl
|
||||||
|
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
: myHighlights
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/hooks/useEventLoader.ts
Normal file
132
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { ReadableContent } from '../services/readerService'
|
||||||
|
import { eventManager } from '../services/eventManager'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
|
||||||
|
interface UseEventLoaderProps {
|
||||||
|
eventId?: string
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
setSelectedUrl: (url: string) => void
|
||||||
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
|
setReaderLoading: (loading: boolean) => void
|
||||||
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventLoader({
|
||||||
|
eventId,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed
|
||||||
|
}: UseEventLoaderProps) {
|
||||||
|
const displayEvent = useCallback((event: NostrEvent) => {
|
||||||
|
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||||
|
const escapedContent = event.content
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br />')
|
||||||
|
|
||||||
|
// Initial title
|
||||||
|
let title = `Note (${event.kind})`
|
||||||
|
if (event.kind === 1) {
|
||||||
|
title = `Note by @${event.pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately
|
||||||
|
const baseContent: ReadableContent = {
|
||||||
|
url: '',
|
||||||
|
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
|
||||||
|
title,
|
||||||
|
published: event.created_at
|
||||||
|
}
|
||||||
|
setReaderContent(baseContent)
|
||||||
|
|
||||||
|
// Background: resolve author profile for kind:1 and update title
|
||||||
|
if (event.kind === 1 && eventStore) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let resolved = ''
|
||||||
|
|
||||||
|
// First, try to get from event store cache
|
||||||
|
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
|
||||||
|
if (storedProfile) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||||
|
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found in event store, fetch from relays
|
||||||
|
if (!resolved && relayPool) {
|
||||||
|
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
|
||||||
|
if (profiles && profiles.length > 0) {
|
||||||
|
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
|
||||||
|
resolved = obj.display_name || obj.name || obj.nip05 || ''
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore profile failures; keep fallback title
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}, [setReaderContent, relayPool, eventStore])
|
||||||
|
|
||||||
|
// Initialize event manager with services
|
||||||
|
useEffect(() => {
|
||||||
|
eventManager.setServices(eventStore || null, relayPool || null)
|
||||||
|
}, [eventStore, relayPool])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventId) return
|
||||||
|
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
|
||||||
|
setIsCollapsed(false)
|
||||||
|
|
||||||
|
// Fetch using event manager (handles cache, deduplication, and retry)
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
eventManager.fetchEvent(eventId).then(
|
||||||
|
(event) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
displayEvent(event)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const errorContent: ReadableContent = {
|
||||||
|
url: '',
|
||||||
|
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||||
|
title: 'Error'
|
||||||
|
}
|
||||||
|
setReaderContent(errorContent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
// Helper to extract filename from URL
|
// Helper to extract filename from URL
|
||||||
function getFilenameFromUrl(url: string): string {
|
function getFilenameFromUrl(url: string): string {
|
||||||
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
|
|||||||
interface UseExternalUrlLoaderProps {
|
interface UseExternalUrlLoaderProps {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
setSelectedUrl: (url: string) => void
|
setSelectedUrl: (url: string) => void
|
||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
|
|||||||
export function useExternalUrlLoader({
|
export function useExternalUrlLoader({
|
||||||
url,
|
url,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -42,73 +48,138 @@ export function useExternalUrlLoader({
|
|||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
}: UseExternalUrlLoaderProps) {
|
}: UseExternalUrlLoaderProps) {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
// Load cached URL-specific highlights from event store
|
||||||
|
const urlFilter = useMemo(() => {
|
||||||
|
if (!url) return null
|
||||||
|
return { kinds: [KINDS.Highlights], '#r': [url] }
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
const cachedUrlHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
|
||||||
|
eventToHighlight,
|
||||||
|
[url]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load content and start streaming highlights when URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
const loadExternalUrl = async () => {
|
const loadExternalUrl = async () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(url)
|
setSelectedUrl(url)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
// Clear article-specific state
|
|
||||||
setCurrentArticleCoordinate(undefined)
|
setCurrentArticleCoordinate(undefined)
|
||||||
setCurrentArticleEventId(undefined)
|
setCurrentArticleEventId(undefined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(url)
|
const content = await fetchReadableContent(url)
|
||||||
|
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
|
|
||||||
console.log('🌐 External URL loaded:', content.title)
|
|
||||||
|
|
||||||
// Set reader loading to false immediately after content is ready
|
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
// Fetch highlights for this URL asynchronously
|
// Fetch highlights for this URL asynchronously
|
||||||
try {
|
try {
|
||||||
setHighlightsLoading(true)
|
if (!mountedRef.current) return
|
||||||
setHighlights([])
|
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedUrlHighlights.length > 0) {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
|
||||||
|
const localOnly = prev.filter(h => !seen.has(h.id))
|
||||||
|
const next = [...cachedUrlHighlights, ...localOnly]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setHighlights([])
|
||||||
|
}
|
||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||||
|
|
||||||
await fetchHighlightsForUrl(
|
await fetchHighlightsForUrl(
|
||||||
relayPool,
|
relayPool,
|
||||||
url,
|
url,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
if (seen.has(highlight.id)) return
|
if (seen.has(highlight.id)) return
|
||||||
seen.add(highlight.id)
|
seen.add(highlight.id)
|
||||||
setHighlights((prev) => {
|
setHighlights((prev) => {
|
||||||
if (prev.some(h => h.id === highlight.id)) return prev
|
if (prev.some(h => h.id === highlight.id)) return prev
|
||||||
const next = [...prev, highlight]
|
const next = [highlight, ...prev]
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
// Highlights are already set via the streaming callback
|
|
||||||
// No need to set them again as that could cause a flash/disappearance
|
|
||||||
console.log(`📌 Finished fetching highlights for URL`)
|
|
||||||
} else {
|
|
||||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
// For videos and other media files, use the filename as the title
|
if (mountedRef.current) {
|
||||||
const filename = getFilenameFromUrl(url)
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: filename,
|
title: filename,
|
||||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadExternalUrl()
|
loadExternalUrl()
|
||||||
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
cachedUrlHighlights,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
|
setSelectedUrl,
|
||||||
|
setHighlights,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId,
|
||||||
|
setHighlightsLoading
|
||||||
|
])
|
||||||
|
|
||||||
|
// Keep UI highlights synced with cached store updates without reloading content
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return
|
||||||
|
if (cachedUrlHighlights.length === 0) return
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const seen = new Set<string>(prev.map(h => h.id))
|
||||||
|
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
|
||||||
|
if (additions.length === 0) return prev
|
||||||
|
const next = [...additions, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}, [cachedUrlHighlights, url, setHighlights])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
|
|||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
}: UseHighlightCreationParams) => {
|
}: UseHighlightCreationParams) => {
|
||||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const handleTextSelection = useCallback((text: string) => {
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
highlightButtonRef.current?.updateSelection(text)
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
@@ -58,7 +60,6 @@ export const useHighlightCreation = ({
|
|||||||
? currentArticle.content
|
? currentArticle.content
|
||||||
: readerContent?.markdown || readerContent?.html
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
|
||||||
|
|
||||||
const newHighlight = await createHighlight(
|
const newHighlight = await createHighlight(
|
||||||
text,
|
text,
|
||||||
@@ -71,12 +72,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ Highlight created successfully!', {
|
// Highlight created successfully
|
||||||
id: newHighlight.id,
|
|
||||||
isLocalOnly: newHighlight.isLocalOnly,
|
|
||||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
|
||||||
publishedRelays: newHighlight.publishedRelays
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear the browser's text selection immediately to allow DOM update
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
@@ -92,10 +88,19 @@ export const useHighlightCreation = ({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
showToast('Reconnect bunker and approve signing permissions to create highlights')
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to create highlight: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
|
|||||||
}: UseHighlightedContentParams) => {
|
}: UseHighlightedContentParams) => {
|
||||||
// Filter highlights by URL and visibility settings
|
// Filter highlights by URL and visibility settings
|
||||||
const relevantHighlights = useMemo(() => {
|
const relevantHighlights = useMemo(() => {
|
||||||
console.log('🔍 ContentPanel: Processing highlights', {
|
|
||||||
totalHighlights: highlights.length,
|
|
||||||
selectedUrl,
|
|
||||||
showHighlights
|
|
||||||
})
|
|
||||||
|
|
||||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
|
||||||
|
|
||||||
// Apply visibility filtering
|
// Apply visibility filtering
|
||||||
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
||||||
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
|
|||||||
return highlightVisibility.nostrverse
|
return highlightVisibility.nostrverse
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||||
|
|
||||||
// Prepare the final HTML with highlights applied
|
// Prepare the final HTML with highlights applied
|
||||||
const finalHtml = useMemo(() => {
|
const finalHtml = useMemo(() => {
|
||||||
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
||||||
|
|
||||||
console.log('🎨 Preparing final HTML:', {
|
// Prepare final HTML
|
||||||
hasMarkdown: !!markdown,
|
|
||||||
hasHtml: !!html,
|
|
||||||
renderedHtmlLength: renderedMarkdownHtml.length,
|
|
||||||
sourceHtmlLength: sourceHtml?.length || 0,
|
|
||||||
showHighlights,
|
|
||||||
relevantHighlightsCount: relevantHighlights.length
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!sourceHtml) {
|
if (!sourceHtml) {
|
||||||
console.warn('⚠️ No source HTML available')
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showHighlights && relevantHighlights.length > 0) {
|
if (showHighlights && relevantHighlights.length > 0) {
|
||||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
|
||||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
|
||||||
return highlightedHtml
|
return highlightedHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📄 Returning source HTML without highlights')
|
|
||||||
return sourceHtml
|
return sourceHtml
|
||||||
|
|
||||||
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||||
|
|
||||||
return { finalHtml, relevantHighlights }
|
return { finalHtml, relevantHighlights }
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const useMarkdownToHTML = (
|
|||||||
|
|
||||||
// Replace nostr URIs with resolved titles
|
// Replace nostr URIs with resolved titles
|
||||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch article titles:', error)
|
console.warn('Failed to fetch article titles:', error)
|
||||||
// Fall back to basic replacement
|
// Fall back to basic replacement
|
||||||
@@ -58,12 +57,10 @@ export const useMarkdownToHTML = (
|
|||||||
|
|
||||||
setProcessedMarkdown(processed)
|
setProcessedMarkdown(processed)
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
if (previewRef.current && !isCancelled) {
|
if (previewRef.current && !isCancelled) {
|
||||||
const html = previewRef.current.innerHTML
|
const html = previewRef.current.innerHTML
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
setRenderedHtml(html)
|
||||||
} else if (!isCancelled) {
|
} else if (!isCancelled) {
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||||
|
|||||||
28
src/hooks/useMountedState.ts
Normal file
28
src/hooks/useMountedState.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track if component is mounted and prevent state updates after unmount.
|
||||||
|
* Returns a function to check if still mounted.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isMounted = useMountedState()
|
||||||
|
*
|
||||||
|
* async function loadData() {
|
||||||
|
* const data = await fetch(...)
|
||||||
|
* if (isMounted()) {
|
||||||
|
* setState(data)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function useMountedState(): () => boolean {
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(() => mountedRef.current, [])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -50,16 +50,10 @@ export function useOfflineSync({
|
|||||||
const isNowOnline = hasRemoteRelays
|
const isNowOnline = hasRemoteRelays
|
||||||
|
|
||||||
if (wasLocalOnly && isNowOnline) {
|
if (wasLocalOnly && isNowOnline) {
|
||||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
// Coming back online, sync events
|
||||||
console.log('📊 Relay state:', {
|
|
||||||
connectedRelays: connectedRelays.length,
|
|
||||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
|
||||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait a moment for relays to fully establish connections
|
// Wait a moment for relays to fully establish connections
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('🚀 Starting sync after delay...')
|
|
||||||
syncLocalEventsToRemote(relayPool, eventStore)
|
syncLocalEventsToRemote(relayPool, eventStore)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ export function useOnlineStatus() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log('🌐 Back online')
|
|
||||||
setIsOnline(true)
|
setIsOnline(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
console.log('📴 Gone offline')
|
|
||||||
setIsOnline(false)
|
setIsOnline(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,10 @@ export function usePWAInstall() {
|
|||||||
const choiceResult = await deferredPrompt.userChoice
|
const choiceResult = await deferredPrompt.userChoice
|
||||||
|
|
||||||
if (choiceResult.outcome === 'accepted') {
|
if (choiceResult.outcome === 'accepted') {
|
||||||
console.log('✅ PWA installed')
|
|
||||||
setIsInstallable(false)
|
setIsInstallable(false)
|
||||||
setDeferredPrompt(null)
|
setDeferredPrompt(null)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ PWA installation dismissed')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,68 +4,111 @@ interface UseReadingPositionOptions {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
onPositionChange?: (position: number) => void
|
onPositionChange?: (position: number) => void
|
||||||
onReadingComplete?: () => void
|
onReadingComplete?: () => void
|
||||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
|
||||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
onSave?: (position: number) => void // Callback for saving position
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
|
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadingPosition = ({
|
export const useReadingPosition = ({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onReadingComplete,
|
onReadingComplete,
|
||||||
readingCompleteThreshold = 0.9,
|
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||||
syncEnabled = false,
|
syncEnabled = false,
|
||||||
onSave,
|
onSave,
|
||||||
autoSaveInterval = 5000
|
autoSaveInterval = 5000,
|
||||||
|
completionHoldMs = 2000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
|
const positionRef = useRef(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
const lastSavedPosition = useRef(0)
|
const lastSavedPosition = useRef(0)
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const hasSavedOnce = useRef(false)
|
||||||
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lastSavedAtRef = useRef<number>(0)
|
||||||
|
|
||||||
// Debounced save function
|
// Debounced save function
|
||||||
const scheduleSave = useCallback((currentPosition: number) => {
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
if (!syncEnabled || !onSave) return
|
if (!syncEnabled || !onSave) {
|
||||||
|
return
|
||||||
// Don't save if position is too low (< 5%)
|
}
|
||||||
if (currentPosition < 0.05) return
|
|
||||||
|
// Always save instantly when we reach completion (1.0)
|
||||||
// Don't save if position hasn't changed significantly (less than 1%)
|
if (currentPosition === 1 && lastSavedPosition.current < 1) {
|
||||||
// But always save if we've reached 100% (completion)
|
if (saveTimerRef.current) {
|
||||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
clearTimeout(saveTimerRef.current)
|
||||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
if (!hasSignificantChange && !hasReachedCompletion) return
|
lastSavedPosition.current = 1
|
||||||
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
|
onSave(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least 5% progress change to consider saving
|
||||||
|
const MIN_DELTA = 0.05
|
||||||
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
|
||||||
|
|
||||||
|
// Enforce a minimum interval between saves (15s) to avoid spamming
|
||||||
|
const MIN_INTERVAL_MS = 15000
|
||||||
|
const nowMs = Date.now()
|
||||||
|
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
|
||||||
|
|
||||||
|
// Allow the very first meaningful save (when crossing 5%) regardless of interval
|
||||||
|
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
|
||||||
|
|
||||||
|
if (!hasSignificantChange && !isFirstMeaningful) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency
|
||||||
|
if (!enoughTimeElapsed && !isFirstMeaningful) {
|
||||||
|
// Clear and reschedule within the remaining window, but not sooner than MIN_INTERVAL_MS
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
const remaining = Math.max(0, MIN_INTERVAL_MS - (nowMs - lastSavedAtRef.current))
|
||||||
|
const delay = Math.max(autoSaveInterval, remaining)
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, delay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule new save
|
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
|
||||||
|
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
lastSavedPosition.current = currentPosition
|
lastSavedPosition.current = currentPosition
|
||||||
|
hasSavedOnce.current = true
|
||||||
|
lastSavedAtRef.current = Date.now()
|
||||||
onSave(currentPosition)
|
onSave(currentPosition)
|
||||||
}, autoSaveInterval)
|
}, delay)
|
||||||
}, [syncEnabled, onSave, autoSaveInterval])
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
// Immediate save function
|
// Immediate save function
|
||||||
const saveNow = useCallback(() => {
|
const saveNow = useCallback(() => {
|
||||||
if (!syncEnabled || !onSave) return
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
// Cancel any pending saves
|
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
saveTimerRef.current = null
|
saveTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
lastSavedPosition.current = position
|
||||||
// Save if position is meaningful (>= 5%)
|
hasSavedOnce.current = true
|
||||||
if (position >= 0.05) {
|
lastSavedAtRef.current = Date.now()
|
||||||
lastSavedPosition.current = position
|
onSave(position)
|
||||||
onSave(position)
|
|
||||||
}
|
|
||||||
}, [syncEnabled, onSave, position])
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,16 +133,39 @@ export const useReadingPosition = ({
|
|||||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
|
positionRef.current = clampedProgress
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
// Schedule auto-save if sync is enabled
|
// Schedule auto-save if sync is enabled
|
||||||
scheduleSave(clampedProgress)
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
// Check if reading is complete
|
// Completion detection with 2s hold at 100%
|
||||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
if (!hasTriggeredComplete.current) {
|
||||||
setIsReadingComplete(true)
|
// If at exact 100%, start a hold timer; cancel if we scroll up
|
||||||
hasTriggeredComplete.current = true
|
if (clampedProgress === 1) {
|
||||||
onReadingComplete?.()
|
if (!completionTimerRef.current) {
|
||||||
|
completionTimerRef.current = setTimeout(() => {
|
||||||
|
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
completionTimerRef.current = null
|
||||||
|
}, completionHoldMs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we moved off 100%, cancel any pending completion hold
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
completionTimerRef.current = null
|
||||||
|
// still allow threshold-based completion for near-bottom if configured
|
||||||
|
if (clampedProgress >= readingCompleteThreshold) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,14 +184,23 @@ export const useReadingPosition = ({
|
|||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setIsReadingComplete(false)
|
setIsReadingComplete(false)
|
||||||
hasTriggeredComplete.current = false
|
hasTriggeredComplete.current = false
|
||||||
|
hasSavedOnce.current = false
|
||||||
|
lastSavedPosition.current = 0
|
||||||
|
if (completionTimerRef.current) {
|
||||||
|
clearTimeout(completionTimerRef.current)
|
||||||
|
completionTimerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [enabled])
|
}, [enabled])
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
|
||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
import { applyTheme } from '../utils/theme'
|
import { applyTheme } from '../utils/theme'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -16,30 +16,28 @@ interface UseSettingsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||||
const [settings, setSettings] = useState<UserSettings>({})
|
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true })
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||||
|
|
||||||
// Load settings and set up subscription
|
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !pubkey || !eventStore) return
|
if (!relayPool || !pubkey || !eventStore) return
|
||||||
|
|
||||||
const loadAndWatch = async () => {
|
// Start settings stream: seed from store, stream updates to store in background
|
||||||
try {
|
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load settings:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAndWatch()
|
|
||||||
|
|
||||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
// Also watch store reactively for any further updates
|
||||||
|
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||||
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
stopNetwork()
|
||||||
|
}
|
||||||
}, [relayPool, pubkey, eventStore])
|
}, [relayPool, pubkey, eventStore])
|
||||||
|
|
||||||
// Apply settings to document
|
// Apply settings to document
|
||||||
@@ -48,7 +46,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const root = document.documentElement.style
|
const root = document.documentElement.style
|
||||||
const fontKey = settings.readingFont || 'system'
|
const fontKey = settings.readingFont || 'system'
|
||||||
|
|
||||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
|
||||||
|
|
||||||
// Apply theme with color variants (defaults to 'system' if not set)
|
// Apply theme with color variants (defaults to 'system' if not set)
|
||||||
applyTheme(
|
applyTheme(
|
||||||
@@ -59,9 +56,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
|
|
||||||
// Load font first and wait for it to be ready
|
// Load font first and wait for it to be ready
|
||||||
if (fontKey !== 'system') {
|
if (fontKey !== 'system') {
|
||||||
console.log('⏳ Waiting for font to load...')
|
|
||||||
await loadFont(fontKey)
|
await loadFont(fontKey)
|
||||||
console.log('✅ Font loaded, applying styles')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply font settings after font is loaded
|
// Apply font settings after font is loaded
|
||||||
@@ -76,7 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
console.log('✅ All styles applied')
|
// Set image max-width based on full-width setting
|
||||||
|
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStyles()
|
applyStyles()
|
||||||
|
|||||||
33
src/hooks/useStoreTimeline.ts
Normal file
33
src/hooks/useStoreTimeline.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useObservableMemo } from 'applesauce-react/hooks'
|
||||||
|
import { startWith } from 'rxjs'
|
||||||
|
import type { IEventStore } from 'applesauce-core'
|
||||||
|
import type { Filter, NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to EventStore timeline and map events to app types
|
||||||
|
* Provides instant cached results, then updates reactively
|
||||||
|
*
|
||||||
|
* @param eventStore - The applesauce event store
|
||||||
|
* @param filter - Nostr filter to query
|
||||||
|
* @param mapEvent - Function to transform NostrEvent to app type
|
||||||
|
* @param deps - Dependencies for memoization
|
||||||
|
* @returns Array of mapped results
|
||||||
|
*/
|
||||||
|
export function useStoreTimeline<T>(
|
||||||
|
eventStore: IEventStore | null,
|
||||||
|
filter: Filter,
|
||||||
|
mapEvent: (event: NostrEvent) => T,
|
||||||
|
deps: unknown[] = []
|
||||||
|
): T[] {
|
||||||
|
const events = useObservableMemo(
|
||||||
|
() => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined,
|
||||||
|
[eventStore, ...deps]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => events?.map(mapEvent) ?? [],
|
||||||
|
[events, mapEvent]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
288
src/hooks/useTextToSpeech.ts
Normal file
288
src/hooks/useTextToSpeech.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Web Speech API types
|
||||||
|
type SpeechSynthesisVoice = {
|
||||||
|
name: string
|
||||||
|
voiceURI: string
|
||||||
|
lang: string
|
||||||
|
localService: boolean
|
||||||
|
default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTTSOptions {
|
||||||
|
defaultLang?: string
|
||||||
|
defaultRate?: number
|
||||||
|
defaultPitch?: number
|
||||||
|
defaultVolume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTTS {
|
||||||
|
supported: boolean
|
||||||
|
speaking: boolean
|
||||||
|
paused: boolean
|
||||||
|
voices: SpeechSynthesisVoice[]
|
||||||
|
voice: SpeechSynthesisVoice | null
|
||||||
|
rate: number
|
||||||
|
pitch: number
|
||||||
|
volume: number
|
||||||
|
setVoice: (v: SpeechSynthesisVoice | null) => void
|
||||||
|
setRate: (r: number) => void
|
||||||
|
setPitch: (p: number) => void
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
speak: (text: string, langOverride?: string) => void
|
||||||
|
pause: () => void
|
||||||
|
resume: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
||||||
|
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
|
||||||
|
const supported = !!synth
|
||||||
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
||||||
|
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
|
||||||
|
const [speaking, setSpeaking] = useState(false)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [rate, setRate] = useState(options.defaultRate ?? 2.1)
|
||||||
|
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
|
||||||
|
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
|
||||||
|
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||||
|
|
||||||
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||||
|
const spokenTextRef = useRef<string>('')
|
||||||
|
const charIndexRef = useRef<number>(0)
|
||||||
|
// Chunking state to reliably speak long texts from web URLs
|
||||||
|
const chunksRef = useRef<string[]>([])
|
||||||
|
const chunkIndexRef = useRef<number>(0)
|
||||||
|
const globalOffsetRef = useRef<number>(0)
|
||||||
|
const langRef = useRef<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// Update rate when defaultRate option changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (options.defaultRate !== undefined) {
|
||||||
|
setRate(options.defaultRate)
|
||||||
|
}
|
||||||
|
}, [options.defaultRate])
|
||||||
|
|
||||||
|
// Load voices (async in many browsers)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return
|
||||||
|
const load = () => {
|
||||||
|
const v = synth!.getVoices()
|
||||||
|
setVoices(v)
|
||||||
|
if (!voice && v.length) {
|
||||||
|
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||||
|
setVoice(byLang || v[0] || null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
const handleVoicesChanged = () => load()
|
||||||
|
synth!.addEventListener('voiceschanged', handleVoicesChanged)
|
||||||
|
return () => {
|
||||||
|
synth!.removeEventListener('voiceschanged', handleVoicesChanged)
|
||||||
|
}
|
||||||
|
}, [supported, defaultLang, voice, synth])
|
||||||
|
|
||||||
|
const createUtterance = useCallback((text: string, langOverride?: string): SpeechSynthesisUtterance => {
|
||||||
|
const SpeechSynthesisUtteranceConstructor = (window as Window & typeof globalThis).SpeechSynthesisUtterance
|
||||||
|
const u = new SpeechSynthesisUtteranceConstructor(text) as SpeechSynthesisUtterance
|
||||||
|
const resolvedLang = langOverride || voice?.lang || defaultLang
|
||||||
|
u.lang = resolvedLang
|
||||||
|
if (langOverride) {
|
||||||
|
const match = voices.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase()))
|
||||||
|
if (match) {
|
||||||
|
u.voice = match
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
|
} else if (voice) {
|
||||||
|
u.voice = voice
|
||||||
|
}
|
||||||
|
u.rate = rate
|
||||||
|
u.pitch = pitch
|
||||||
|
u.volume = volume
|
||||||
|
|
||||||
|
const self = u
|
||||||
|
|
||||||
|
u.onstart = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
setSpeaking(true)
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
u.onpause = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
u.onresume = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
u.onend = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
// Continue with next chunk if available
|
||||||
|
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||||
|
if (hasMore) {
|
||||||
|
chunkIndexRef.current++
|
||||||
|
charIndexRef.current += self.text.length
|
||||||
|
const nextChunk = chunksRef.current[chunkIndexRef.current]
|
||||||
|
const nextUtterance = createUtterance(nextChunk, langRef.current)
|
||||||
|
utteranceRef.current = nextUtterance
|
||||||
|
synth!.speak(nextUtterance)
|
||||||
|
} else {
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.onerror = () => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||||
|
if (utteranceRef.current !== self) return
|
||||||
|
if (typeof ev.charIndex === 'number') {
|
||||||
|
const newIndex = globalOffsetRef.current + ev.charIndex
|
||||||
|
if (newIndex > charIndexRef.current) {
|
||||||
|
charIndexRef.current = newIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}, [voice, defaultLang, rate, pitch, volume, voices, synth])
|
||||||
|
|
||||||
|
const splitIntoChunks = useCallback((text: string, maxLen = 2400): string[] => {
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||||
|
if (normalized.length <= maxLen) return [normalized]
|
||||||
|
const sentences = normalized.split(/(?<=[.!?])\s+/)
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const s of sentences) {
|
||||||
|
if ((current + (current ? ' ' : '') + s).length > maxLen) {
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
if (s.length > maxLen) {
|
||||||
|
// Hard split very long sentence
|
||||||
|
for (let i = 0; i < s.length; i += maxLen) {
|
||||||
|
chunks.push(s.slice(i, i + maxLen))
|
||||||
|
}
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current = s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current ? `${current} ${s}` : s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
return chunks
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startSpeakingChunks = useCallback((text: string) => {
|
||||||
|
chunksRef.current = splitIntoChunks(text)
|
||||||
|
chunkIndexRef.current = 0
|
||||||
|
globalOffsetRef.current = 0
|
||||||
|
const first = chunksRef.current[0] || ''
|
||||||
|
const u = createUtterance(first, langRef.current)
|
||||||
|
utteranceRef.current = u
|
||||||
|
synth!.speak(u)
|
||||||
|
}, [createUtterance, splitIntoChunks, synth])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
synth!.cancel()
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
|
utteranceRef.current = null
|
||||||
|
charIndexRef.current = 0
|
||||||
|
spokenTextRef.current = ''
|
||||||
|
chunksRef.current = []
|
||||||
|
chunkIndexRef.current = 0
|
||||||
|
globalOffsetRef.current = 0
|
||||||
|
}, [supported, synth])
|
||||||
|
|
||||||
|
const speak = useCallback((text: string, langOverride?: string) => {
|
||||||
|
if (!supported || !text?.trim()) return
|
||||||
|
synth!.cancel()
|
||||||
|
spokenTextRef.current = text
|
||||||
|
charIndexRef.current = 0
|
||||||
|
langRef.current = langOverride
|
||||||
|
startSpeakingChunks(text)
|
||||||
|
}, [supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (synth!.speaking && !synth!.paused) {
|
||||||
|
synth!.pause()
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
}, [supported, synth])
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (synth!.speaking && synth!.paused) {
|
||||||
|
synth!.resume()
|
||||||
|
setPaused(false)
|
||||||
|
}
|
||||||
|
}, [supported, synth])
|
||||||
|
|
||||||
|
// Update rate in real-time: while speaking, restart from last boundary with new rate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return
|
||||||
|
if (!utteranceRef.current) return
|
||||||
|
|
||||||
|
if (synth!.speaking && !synth!.paused) {
|
||||||
|
const fullText = spokenTextRef.current
|
||||||
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||||
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
|
||||||
|
synth!.cancel()
|
||||||
|
// restart chunked from current global index
|
||||||
|
spokenTextRef.current = remainingText
|
||||||
|
charIndexRef.current = 0
|
||||||
|
// keep current language selection; no change needed here
|
||||||
|
startSpeakingChunks(remainingText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (utteranceRef.current) {
|
||||||
|
utteranceRef.current.rate = rate
|
||||||
|
}
|
||||||
|
}, [rate, supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
|
const updateRate = useCallback((newRate: number) => {
|
||||||
|
setRate(newRate)
|
||||||
|
if (!supported) return
|
||||||
|
if (!utteranceRef.current) return
|
||||||
|
|
||||||
|
if (synth!.speaking && !synth!.paused) {
|
||||||
|
const fullText = spokenTextRef.current
|
||||||
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||||
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
synth!.cancel()
|
||||||
|
const u = createUtterance(remainingText)
|
||||||
|
// ensure the new rate is applied immediately on the new utterance
|
||||||
|
u.rate = newRate
|
||||||
|
utteranceRef.current = u
|
||||||
|
synth!.speak(u)
|
||||||
|
} else if (utteranceRef.current) {
|
||||||
|
utteranceRef.current.rate = newRate
|
||||||
|
}
|
||||||
|
}, [supported, synth, createUtterance])
|
||||||
|
|
||||||
|
// stop TTS when unmounting
|
||||||
|
useEffect(() => stop, [stop])
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
supported,
|
||||||
|
speaking,
|
||||||
|
paused,
|
||||||
|
voices,
|
||||||
|
voice,
|
||||||
|
rate,
|
||||||
|
setRate: updateRate,
|
||||||
|
pitch, setPitch,
|
||||||
|
volume, setVolume,
|
||||||
|
setVoice,
|
||||||
|
speak, pause, resume, stop
|
||||||
|
}), [supported, speaking, paused, voices, voice, rate, updateRate, pitch, volume, setVoice, speak, pause, resume, stop])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
@import './styles/components/me.css';
|
@import './styles/components/me.css';
|
||||||
@import './styles/components/pull-to-refresh.css';
|
@import './styles/components/pull-to-refresh.css';
|
||||||
@import './styles/components/skeletons.css';
|
@import './styles/components/skeletons.css';
|
||||||
|
@import './styles/components/login.css';
|
||||||
@import './styles/utils/animations.css';
|
@import './styles/utils/animations.css';
|
||||||
@import './styles/utils/utilities.css';
|
@import './styles/utils/utilities.css';
|
||||||
@import './styles/utils/legacy.css';
|
@import './styles/utils/legacy.css';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js', { type: 'module' })
|
.register('/sw.js', { type: 'module' })
|
||||||
.then(registration => {
|
.then(registration => {
|
||||||
console.log('✅ Service Worker registered:', registration.scope)
|
|
||||||
|
|
||||||
// Check for updates periodically
|
// Check for updates periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -25,7 +24,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// New service worker available
|
// New service worker available
|
||||||
console.log('🔄 New version available! Reload to update.')
|
|
||||||
|
|
||||||
// Optionally show a toast notification
|
// Optionally show a toast notification
|
||||||
const updateAvailable = new CustomEvent('sw-update-available')
|
const updateAvailable = new CustomEvent('sw-update-available')
|
||||||
|
|||||||
197
src/services/archiveController.ts
Normal file
197
src/services/archiveController.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ARCHIVE_EMOJI } from './reactionService'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
type MarkedChangeCallback = (markedIds: Set<string>) => void
|
||||||
|
|
||||||
|
class ArchiveController {
|
||||||
|
private markedIds: Set<string> = new Set()
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private listeners: MarkedChangeCallback[] = []
|
||||||
|
private generation = 0
|
||||||
|
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||||
|
private pendingEventIds: Set<string> = new Set()
|
||||||
|
|
||||||
|
onMarked(cb: MarkedChangeCallback): () => void {
|
||||||
|
this.listeners.push(cb)
|
||||||
|
// Emit current state immediately to new subscribers
|
||||||
|
cb(new Set(this.markedIds))
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
const snapshot = new Set(this.markedIds)
|
||||||
|
this.listeners.forEach(cb => cb(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
mark(id: string): void {
|
||||||
|
if (!this.markedIds.has(id)) {
|
||||||
|
this.markedIds.add(id)
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmark(id: string): void {
|
||||||
|
if (this.markedIds.delete(id)) {
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMarked(id: string): boolean {
|
||||||
|
return this.markedIds.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkedIds(): string[] {
|
||||||
|
return Array.from(this.markedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
this.markedIds = new Set()
|
||||||
|
this.pendingEventIds = new Set()
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = options
|
||||||
|
const startGen = this.generation
|
||||||
|
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as loaded immediately (fetch runs non-blocking)
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
|
||||||
|
// Handlers for streaming queries
|
||||||
|
const handleUrlReaction = (evt: NostrEvent) => {
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||||
|
if (!rTag) return
|
||||||
|
this.markedIds.add(rTag)
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventReaction = (evt: NostrEvent) => {
|
||||||
|
if (evt.content !== ARCHIVE_EMOJI) return
|
||||||
|
// Direct coordinate tag ('a') - can be mapped immediately
|
||||||
|
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
|
||||||
|
if (aTag) {
|
||||||
|
try {
|
||||||
|
const [kindStr, pubkey, identifier] = aTag.split(':')
|
||||||
|
const kind = Number(kindStr)
|
||||||
|
if (kind === KINDS.BlogPost && pubkey && identifier) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
this.emit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed a-tag */ }
|
||||||
|
}
|
||||||
|
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
|
||||||
|
if (!eTag) return
|
||||||
|
this.pendingEventIds.add(eTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stream kind:17 and kind:7 in parallel
|
||||||
|
const [kind17, kind7] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }),
|
||||||
|
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (startGen !== this.generation) return
|
||||||
|
|
||||||
|
// Include EOSE events
|
||||||
|
kind17.forEach(handleUrlReaction)
|
||||||
|
kind7.forEach(handleEventReaction)
|
||||||
|
|
||||||
|
if (this.pendingEventIds.size > 0) {
|
||||||
|
// Fetch referenced articles (kind:30023) and map event IDs to naddr
|
||||||
|
const ids = Array.from(this.pendingEventIds)
|
||||||
|
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids })
|
||||||
|
for (const article of articleEvents) {
|
||||||
|
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) continue
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
} catch {
|
||||||
|
// skip invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try immediate mapping via eventStore for any still-pending e-ids
|
||||||
|
if (this.pendingEventIds.size > 0) {
|
||||||
|
const stillPending = new Set<string>()
|
||||||
|
for (const eId of this.pendingEventIds) {
|
||||||
|
try {
|
||||||
|
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
|
||||||
|
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
|
||||||
|
if (evt && evt.kind === KINDS.BlogPost) {
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stillPending.add(eId)
|
||||||
|
}
|
||||||
|
} catch (e) { stillPending.add(eId) }
|
||||||
|
}
|
||||||
|
this.pendingEventIds = stillPending
|
||||||
|
if (stillPending.size > 0) {
|
||||||
|
// Subscribe to future 30023 arrivals to finalize mapping
|
||||||
|
if (this.timelineSubscription) {
|
||||||
|
try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ }
|
||||||
|
this.timelineSubscription = null
|
||||||
|
}
|
||||||
|
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
|
||||||
|
const genAtSub = this.generation
|
||||||
|
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
|
||||||
|
if (genAtSub !== this.generation) return
|
||||||
|
for (const evt of events) {
|
||||||
|
if (!this.pendingEventIds.has(evt.id)) continue
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) continue
|
||||||
|
try {
|
||||||
|
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
|
||||||
|
this.markedIds.add(naddr)
|
||||||
|
this.pendingEventIds.delete(evt.id)
|
||||||
|
this.emit()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-blocking fetch; ignore errors here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const archiveController = new ArchiveController()
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +48,6 @@ function getFromCache(naddr: string): ArticleContent | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📦 Loaded article from cache:', naddr)
|
|
||||||
return content
|
return content
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -63,7 +62,6 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
console.log('💾 Saved article to cache:', naddr)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to cache article:', err)
|
console.warn('Failed to cache article:', err)
|
||||||
// Silently fail if storage is full or unavailable
|
// Silently fail if storage is full or unavailable
|
||||||
|
|||||||
521
src/services/bookmarkController.ts
Normal file
521
src/services/bookmarkController.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Helpers, EventStore } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||||
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import {
|
||||||
|
AccountWithExtension,
|
||||||
|
hydrateItems,
|
||||||
|
dedupeBookmarksById,
|
||||||
|
extractUrlsFromContent
|
||||||
|
} from './bookmarkHelpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique key for event deduplication (from Debug)
|
||||||
|
*/
|
||||||
|
function getEventKey(evt: NostrEvent): string {
|
||||||
|
if (evt.kind === 30003 || evt.kind === 30001) {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
} else if (evt.kind === 10003) {
|
||||||
|
return `${evt.kind}:${evt.pubkey}`
|
||||||
|
}
|
||||||
|
return evt.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event has encrypted content (from Debug)
|
||||||
|
*/
|
||||||
|
function hasEncryptedContent(evt: NostrEvent): boolean {
|
||||||
|
if (Helpers.hasHiddenContent(evt)) return true
|
||||||
|
if (evt.content && evt.content.includes('?iv=')) return true
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawEventCallback = (event: NostrEvent) => void
|
||||||
|
type BookmarksCallback = (bookmarks: Bookmark[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
type DecryptCompleteCallback = (eventId: string, publicCount: number, privateCount: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared bookmark streaming controller
|
||||||
|
* Encapsulates the Debug flow: stream events, dedupe, decrypt, build bookmarks
|
||||||
|
*/
|
||||||
|
class BookmarkController {
|
||||||
|
private rawEventListeners: RawEventCallback[] = []
|
||||||
|
private bookmarksListeners: BookmarksCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
private decryptCompleteListeners: DecryptCompleteCallback[] = []
|
||||||
|
|
||||||
|
private currentEvents: Map<string, NostrEvent> = new Map()
|
||||||
|
private decryptedResults: Map<string, {
|
||||||
|
publicItems: IndividualBookmark[]
|
||||||
|
privateItems: IndividualBookmark[]
|
||||||
|
newestCreatedAt?: number
|
||||||
|
latestContent?: string
|
||||||
|
allTags?: string[][]
|
||||||
|
}> = new Map()
|
||||||
|
private isLoading = false
|
||||||
|
private hydrationGeneration = 0
|
||||||
|
private externalEventStore: EventStore | null = null
|
||||||
|
private relayPool: RelayPool | null = null
|
||||||
|
|
||||||
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
|
this.rawEventListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.rawEventListeners = this.rawEventListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBookmarks(cb: BookmarksCallback): () => void {
|
||||||
|
this.bookmarksListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.bookmarksListeners = this.bookmarksListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDecryptComplete(cb: DecryptCompleteCallback): () => void {
|
||||||
|
this.decryptCompleteListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.decryptCompleteListeners = this.decryptCompleteListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.hydrationGeneration++
|
||||||
|
this.currentEvents.clear()
|
||||||
|
this.decryptedResults.clear()
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
if (this.isLoading !== loading) {
|
||||||
|
this.isLoading = loading
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRawEvent(evt: NostrEvent): void {
|
||||||
|
this.rawEventListeners.forEach(cb => cb(evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate events by IDs using queryEvents (local-first, streaming)
|
||||||
|
*/
|
||||||
|
private async hydrateByIds(
|
||||||
|
ids: string[],
|
||||||
|
idToEvent: Map<string, NostrEvent>,
|
||||||
|
onProgress: () => void,
|
||||||
|
generation: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to unique IDs not already hydrated
|
||||||
|
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
||||||
|
if (unique.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events using local-first queryEvents
|
||||||
|
await queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ ids: unique },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Also index by coordinate for addressable events
|
||||||
|
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
|
||||||
|
*/
|
||||||
|
private async hydrateByCoordinates(
|
||||||
|
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||||
|
idToEvent: Map<string, NostrEvent>,
|
||||||
|
onProgress: () => void,
|
||||||
|
generation: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by kind and pubkey for efficient batching
|
||||||
|
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||||
|
|
||||||
|
for (const coord of coords) {
|
||||||
|
if (!filtersByKind.has(coord.kind)) {
|
||||||
|
filtersByKind.set(coord.kind, new Map())
|
||||||
|
}
|
||||||
|
const byPubkey = filtersByKind.get(coord.kind)!
|
||||||
|
if (!byPubkey.has(coord.pubkey)) {
|
||||||
|
byPubkey.set(coord.pubkey, [])
|
||||||
|
}
|
||||||
|
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off all queries in parallel (fire-and-forget)
|
||||||
|
const promises: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (const [kind, byPubkey] of filtersByKind) {
|
||||||
|
for (const [pubkey, identifiers] of byPubkey) {
|
||||||
|
// Separate empty and non-empty identifiers
|
||||||
|
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||||
|
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||||
|
|
||||||
|
// Fetch events with non-empty d-tags
|
||||||
|
if (nonEmptyIdentifiers.length > 0) {
|
||||||
|
promises.push(
|
||||||
|
queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// Query completed successfully
|
||||||
|
}).catch(() => {
|
||||||
|
// Silent error - individual query failed
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||||
|
if (hasEmptyIdentifier) {
|
||||||
|
promises.push(
|
||||||
|
queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ kinds: [kind], authors: [pubkey] },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
// Only process events with empty d-tag
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
if (dTag !== '') return
|
||||||
|
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// Query completed successfully
|
||||||
|
}).catch(() => {
|
||||||
|
// Silent error - individual query failed
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all queries to complete
|
||||||
|
await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAndEmitBookmarks(
|
||||||
|
activeAccount: AccountWithExtension,
|
||||||
|
signerCandidate: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const allEvents = Array.from(this.currentEvents.values())
|
||||||
|
|
||||||
|
// Include unencrypted events OR encrypted events that have been decrypted
|
||||||
|
const readyEvents = allEvents.filter(evt => {
|
||||||
|
const isEncrypted = hasEncryptedContent(evt)
|
||||||
|
if (!isEncrypted) return true // Include unencrypted
|
||||||
|
// Include encrypted if already decrypted
|
||||||
|
return this.decryptedResults.has(getEventKey(evt))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (readyEvents.length === 0) {
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Separate unencrypted and decrypted events
|
||||||
|
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
||||||
|
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
||||||
|
|
||||||
|
// Process unencrypted events
|
||||||
|
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
||||||
|
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
||||||
|
|
||||||
|
// Merge in decrypted results
|
||||||
|
let publicItemsAll = [...publicUnencrypted]
|
||||||
|
let privateItemsAll = [...privateUnencrypted]
|
||||||
|
|
||||||
|
decryptedEvents.forEach(evt => {
|
||||||
|
const eventKey = getEventKey(evt)
|
||||||
|
const decrypted = this.decryptedResults.get(eventKey)
|
||||||
|
if (decrypted) {
|
||||||
|
publicItemsAll = [...publicItemsAll, ...decrypted.publicItems]
|
||||||
|
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
const deduped = dedupeBookmarksById(allItems)
|
||||||
|
|
||||||
|
// Separate hex IDs from coordinates for fetching
|
||||||
|
const noteIds: string[] = []
|
||||||
|
const coordinates: string[] = []
|
||||||
|
|
||||||
|
// Request hydration for all items that don't have content yet
|
||||||
|
deduped.forEach(i => {
|
||||||
|
// If item has no content, we need to fetch it
|
||||||
|
if (!i.content || i.content.length === 0) {
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||||
|
noteIds.push(i.id)
|
||||||
|
} else if (i.id.includes(':')) {
|
||||||
|
coordinates.push(i.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to build and emit bookmarks
|
||||||
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
|
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
||||||
|
// This preserves the original public/private split while still getting all the content
|
||||||
|
const allBookmarks = [
|
||||||
|
...hydrateItems(publicItemsAll, idToEvent),
|
||||||
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
|
]
|
||||||
|
|
||||||
|
const enriched = allBookmarks.map(b => ({
|
||||||
|
...b,
|
||||||
|
tags: b.tags || [],
|
||||||
|
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
|
||||||
|
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sortedBookmarks = enriched
|
||||||
|
.map(b => ({
|
||||||
|
...b,
|
||||||
|
urlReferences: extractUrlsFromContent(b.content)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
|
||||||
|
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
|
||||||
|
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
|
||||||
|
return bTs - aTs
|
||||||
|
})
|
||||||
|
|
||||||
|
const bookmark: Bookmark = {
|
||||||
|
id: `${activeAccount.pubkey}-bookmarks`,
|
||||||
|
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||||
|
url: '',
|
||||||
|
content: latestContent,
|
||||||
|
created_at: newestCreatedAt || 0,
|
||||||
|
tags: allTags,
|
||||||
|
bookmarkCount: sortedBookmarks.length,
|
||||||
|
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||||
|
individualBookmarks: sortedBookmarks,
|
||||||
|
isPrivate: privateItemsAll.length > 0,
|
||||||
|
encryptedContent: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately with empty metadata (show placeholders)
|
||||||
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
|
emitBookmarks(idToEvent)
|
||||||
|
|
||||||
|
// Now fetch events progressively in background using local-first queries
|
||||||
|
|
||||||
|
const generation = this.hydrationGeneration
|
||||||
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
|
|
||||||
|
// Parse coordinates from strings to objects
|
||||||
|
const coordObjs = coordinates.map(c => {
|
||||||
|
const parts = c.split(':')
|
||||||
|
return {
|
||||||
|
kind: parseInt(parts[0]),
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2] || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kick off hydration (streaming, non-blocking, local-first)
|
||||||
|
// Fire-and-forget - don't await, let it run in background
|
||||||
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||||
|
// Silent error - hydration will retry or show partial results
|
||||||
|
})
|
||||||
|
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
|
||||||
|
// Silent error - hydration will retry or show partial results
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to build bookmarks:', error)
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
activeAccount: unknown
|
||||||
|
accountManager: { getActive: () => unknown }
|
||||||
|
eventStore?: EventStore
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||||
|
|
||||||
|
// Store references for hydration
|
||||||
|
this.relayPool = relayPool
|
||||||
|
this.externalEventStore = eventStore || null
|
||||||
|
|
||||||
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = activeAccount as { pubkey: string; [key: string]: unknown }
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight hydration
|
||||||
|
this.hydrationGeneration++
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get signer for auto-decryption
|
||||||
|
const fullAccount = accountManager.getActive() as AccountWithExtension | null
|
||||||
|
const maybeAccount = (fullAccount || account) as AccountWithExtension
|
||||||
|
let signerCandidate: unknown = maybeAccount
|
||||||
|
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
||||||
|
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
||||||
|
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
||||||
|
signerCandidate = maybeAccount.signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream events with live deduplication (same as Debug)
|
||||||
|
await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [account.pubkey] },
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
const key = getEventKey(evt)
|
||||||
|
const existing = this.currentEvents.get(key)
|
||||||
|
|
||||||
|
if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) {
|
||||||
|
return // Keep existing (it's newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update event
|
||||||
|
this.currentEvents.set(key, evt)
|
||||||
|
|
||||||
|
// Emit raw event for Debug UI
|
||||||
|
this.emitRawEvent(evt)
|
||||||
|
|
||||||
|
// Build bookmarks immediately for unencrypted events
|
||||||
|
const isEncrypted = hasEncryptedContent(evt)
|
||||||
|
if (!isEncrypted) {
|
||||||
|
// For unencrypted events, build bookmarks immediately (progressive update)
|
||||||
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
.catch(() => {
|
||||||
|
// Silent error - will retry on next event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
||||||
|
if (isEncrypted) {
|
||||||
|
// Don't await - let it run in background
|
||||||
|
collectBookmarksFromEvents([evt], account, signerCandidate)
|
||||||
|
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
||||||
|
const eventKey = getEventKey(evt)
|
||||||
|
// Store the actual decrypted items, not just counts
|
||||||
|
this.decryptedResults.set(eventKey, {
|
||||||
|
publicItems: publicItemsAll,
|
||||||
|
privateItems: privateItemsAll,
|
||||||
|
newestCreatedAt,
|
||||||
|
latestContent,
|
||||||
|
allTags
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit decrypt complete for Debug UI
|
||||||
|
this.decryptCompleteListeners.forEach(cb =>
|
||||||
|
cb(evt.id, publicItemsAll.length, privateItemsAll.length)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rebuild bookmarks with newly decrypted content (progressive update)
|
||||||
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
.catch(() => {
|
||||||
|
// Silent error - will retry on next event
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silent error - decrypt failed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Final update after EOSE
|
||||||
|
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load bookmarks:', error)
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const bookmarkController = new BookmarkController()
|
||||||
|
|
||||||
@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
}
|
}
|
||||||
const unique = Array.from(byId.values())
|
const unique = Array.from(byId.values())
|
||||||
|
|
||||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
|
||||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
|
||||||
|
|
||||||
const bookmarkLists = unique
|
const bookmarkLists = unique
|
||||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||||
|
|
||||||
|
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
|
||||||
const byD = new Map<string, NostrEvent>()
|
const byD = new Map<string, NostrEvent>()
|
||||||
for (const e of unique) {
|
for (const e of unique) {
|
||||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
|
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
|
||||||
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
const prev = byD.get(d)
|
const prev = byD.get(d)
|
||||||
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setsAndNamedLists = Array.from(byD.values())
|
// Separate web bookmarks from bookmark sets/lists
|
||||||
|
const allReplaceable = Array.from(byD.values())
|
||||||
|
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
|
||||||
|
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
|
||||||
|
|
||||||
const out: NostrEvent[] = []
|
const out: NostrEvent[] = []
|
||||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||||
out.push(...setsAndNamedLists)
|
out.push(...setsAndNamedLists)
|
||||||
// Add web bookmarks as individual events
|
// Add deduplicated web bookmarks as individual events
|
||||||
out.push(...webBookmarks)
|
out.push(...webBookmarks)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
identifier: string
|
identifier: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventPointer {
|
export interface EventPointer {
|
||||||
id: string
|
id: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
author?: string
|
author?: string
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplesauceBookmarks {
|
export interface ApplesauceBookmarks {
|
||||||
@@ -62,7 +66,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
|
|||||||
export const processApplesauceBookmarks = (
|
export const processApplesauceBookmarks = (
|
||||||
bookmarks: unknown,
|
bookmarks: unknown,
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
isPrivate: boolean
|
isPrivate: boolean,
|
||||||
|
parentCreatedAt?: number
|
||||||
): IndividualBookmark[] => {
|
): IndividualBookmark[] => {
|
||||||
if (!bookmarks) return []
|
if (!bookmarks) return []
|
||||||
|
|
||||||
@@ -76,14 +81,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: note.created_at ?? null,
|
||||||
pubkey: note.author || activeAccount.pubkey,
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
kind: 1, // Short note kind
|
kind: 1, // Short note kind
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -96,14 +101,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: coordinate,
|
id: coordinate,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: article.created_at ?? null,
|
||||||
pubkey: article.pubkey,
|
pubkey: article.pubkey,
|
||||||
kind: article.kind, // Usually 30023 for long-form articles
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,14 +119,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `hashtag-${hashtag}`,
|
id: `hashtag-${hashtag}`,
|
||||||
content: `#${hashtag}`,
|
content: `#${hashtag}`,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: 0, // Hashtags don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['t', hashtag]],
|
tags: [['t', hashtag]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -132,14 +137,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: `url-${url}`,
|
id: `url-${url}`,
|
||||||
content: url,
|
content: url,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: 0, // URLs don't have their own creation time
|
||||||
pubkey: activeAccount.pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['r', url]],
|
tags: [['r', url]],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: Math.floor(Date.now() / 1000)
|
listUpdatedAt: parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,20 +153,24 @@ export const processApplesauceBookmarks = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||||
return bookmarkArray
|
const processed = bookmarkArray
|
||||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||||
.map((bookmark: BookmarkData) => ({
|
.map((bookmark: BookmarkData) => {
|
||||||
id: bookmark.id!,
|
return {
|
||||||
content: bookmark.content || '',
|
id: bookmark.id!,
|
||||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
content: bookmark.content || '',
|
||||||
pubkey: activeAccount.pubkey,
|
created_at: bookmark.created_at ?? null,
|
||||||
kind: bookmark.kind || 30001,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: bookmark.tags || [],
|
kind: bookmark.kind || 30001,
|
||||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
tags: bookmark.tags || [],
|
||||||
type: 'event' as const,
|
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||||
isPrivate,
|
type: 'event' as const,
|
||||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
isPrivate,
|
||||||
}))
|
listUpdatedAt: parentCreatedAt ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return processed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types and guards around signer/decryption APIs
|
// Types and guards around signer/decryption APIs
|
||||||
@@ -169,29 +178,38 @@ export function hydrateItems(
|
|||||||
items: IndividualBookmark[],
|
items: IndividualBookmark[],
|
||||||
idToEvent: Map<string, NostrEvent>
|
idToEvent: Map<string, NostrEvent>
|
||||||
): IndividualBookmark[] {
|
): IndividualBookmark[] {
|
||||||
return items.map(item => {
|
return items
|
||||||
const ev = idToEvent.get(item.id)
|
.map(item => {
|
||||||
if (!ev) return item
|
const ev = idToEvent.get(item.id)
|
||||||
|
if (!ev) return item
|
||||||
// For long-form articles (kind:30023), use the article title as content
|
|
||||||
let content = ev.content || item.content || ''
|
// For long-form articles (kind:30023), use the article title as content
|
||||||
if (ev.kind === 30023) {
|
let content = ev.content || item.content || ''
|
||||||
const articleTitle = getArticleTitle(ev)
|
if (ev.kind === 30023) {
|
||||||
if (articleTitle) {
|
const articleTitle = getArticleTitle(ev)
|
||||||
content = articleTitle
|
if (articleTitle) {
|
||||||
|
content = articleTitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Ensure all events with content get parsed content for proper rendering
|
||||||
return {
|
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||||
...item,
|
|
||||||
pubkey: ev.pubkey || item.pubkey,
|
return {
|
||||||
content,
|
...item,
|
||||||
created_at: ev.created_at || item.created_at,
|
pubkey: ev.pubkey || item.pubkey,
|
||||||
kind: ev.kind || item.kind,
|
content,
|
||||||
tags: ev.tags || item.tags,
|
created_at: ev.created_at || item.created_at,
|
||||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
kind: ev.kind || item.kind,
|
||||||
}
|
tags: ev.tags || item.tags,
|
||||||
})
|
parsedContent: parsedContent || item.parsedContent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => {
|
||||||
|
// Filter out bookmark list events (they're containers, not content)
|
||||||
|
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
|
||||||
|
return !isBookmarkListEvent
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
|
||||||
|
|||||||
@@ -11,6 +11,96 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
|
|||||||
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt/unlock a single event and return private bookmarks
|
||||||
|
*/
|
||||||
|
async function decryptEvent(
|
||||||
|
evt: NostrEvent,
|
||||||
|
activeAccount: ActiveAccount,
|
||||||
|
signerCandidate: unknown,
|
||||||
|
metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string }
|
||||||
|
): Promise<IndividualBookmark[]> {
|
||||||
|
const { dTag, setTitle, setDescription, setImage } = metadata
|
||||||
|
const privateItems: IndividualBookmark[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) {
|
||||||
|
try {
|
||||||
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore unlock errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (evt.content && evt.content.length > 0) {
|
||||||
|
let decryptedContent: string | undefined
|
||||||
|
|
||||||
|
// Try to detect encryption method from content format
|
||||||
|
// NIP-44 starts with version byte (currently 0x02), NIP-04 is base64
|
||||||
|
const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=')
|
||||||
|
|
||||||
|
// Try the likely method first (no timeout - let it fail naturally like debug page)
|
||||||
|
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
||||||
|
try {
|
||||||
|
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore NIP-44 decryption errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to nip04 if nip44 failed or content looks like nip04
|
||||||
|
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
||||||
|
try {
|
||||||
|
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore NIP-04 decryption errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decryptedContent) {
|
||||||
|
try {
|
||||||
|
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||||
|
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||||
|
privateItems.push(
|
||||||
|
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||||
|
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||||
|
} catch (err) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
|
if (priv) {
|
||||||
|
privateItems.push(
|
||||||
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore individual event failures
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateItems
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectBookmarksFromEvents(
|
export async function collectBookmarksFromEvents(
|
||||||
bookmarkListEvents: NostrEvent[],
|
bookmarkListEvents: NostrEvent[],
|
||||||
activeAccount: ActiveAccount,
|
activeAccount: ActiveAccount,
|
||||||
@@ -23,47 +113,56 @@ export async function collectBookmarksFromEvents(
|
|||||||
allTags: string[][]
|
allTags: string[][]
|
||||||
}> {
|
}> {
|
||||||
const publicItemsAll: IndividualBookmark[] = []
|
const publicItemsAll: IndividualBookmark[] = []
|
||||||
const privateItemsAll: IndividualBookmark[] = []
|
|
||||||
let newestCreatedAt = 0
|
let newestCreatedAt = 0
|
||||||
let latestContent = ''
|
let latestContent = ''
|
||||||
let allTags: string[][] = []
|
let allTags: string[][] = []
|
||||||
|
|
||||||
|
// Build list of events needing decrypt and collect public items immediately
|
||||||
|
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
|
||||||
|
|
||||||
for (const evt of bookmarkListEvents) {
|
for (const evt of bookmarkListEvents) {
|
||||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||||
|
|
||||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
|
||||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||||
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||||
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||||
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||||
|
const metadata = { dTag, setTitle, setDescription, setImage }
|
||||||
|
|
||||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
|
// Use coordinate format for web bookmarks to enable proper deduplication
|
||||||
|
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
|
||||||
|
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
|
||||||
|
|
||||||
publicItemsAll.push({
|
publicItemsAll.push({
|
||||||
id: evt.id,
|
id: webBookmarkId,
|
||||||
content: evt.content || '',
|
content: evt.content || '',
|
||||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
created_at: evt.created_at ?? null,
|
||||||
pubkey: evt.pubkey,
|
pubkey: evt.pubkey,
|
||||||
kind: evt.kind,
|
kind: evt.kind,
|
||||||
tags: evt.tags || [],
|
tags: evt.tags || [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'web' as const,
|
type: 'web' as const,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
|
||||||
sourceKind: 39701,
|
sourceKind: 39701,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
setTitle,
|
setTitle,
|
||||||
setDescription,
|
setDescription,
|
||||||
setImage
|
setImage,
|
||||||
|
listUpdatedAt: evt.created_at ?? null
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
|
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||||
|
|
||||||
|
|
||||||
publicItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
...processedPub.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -73,70 +172,23 @@ export async function collectBookmarksFromEvents(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
// Schedule decrypt if needed
|
||||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||||
try {
|
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
const needsDecrypt = signerCandidate && (
|
||||||
} catch {
|
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||||
try {
|
Helpers.hasHiddenContent(evt) ||
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
hasNip04Content
|
||||||
} catch {
|
)
|
||||||
// ignore
|
|
||||||
}
|
if (needsDecrypt) {
|
||||||
}
|
decryptJobs.push({ evt, metadata })
|
||||||
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
} else {
|
||||||
let decryptedContent: string | undefined
|
// Check for already-unlocked hidden bookmarks
|
||||||
try {
|
|
||||||
if (hasNip44Decrypt(signerCandidate)) {
|
|
||||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
|
||||||
evt.pubkey,
|
|
||||||
evt.content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!decryptedContent) {
|
|
||||||
try {
|
|
||||||
if (hasNip04Decrypt(signerCandidate)) {
|
|
||||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
|
||||||
evt.pubkey,
|
|
||||||
evt.content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decryptedContent) {
|
|
||||||
try {
|
|
||||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
|
||||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
|
||||||
privateItemsAll.push(
|
|
||||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
|
||||||
...i,
|
|
||||||
sourceKind: evt.kind,
|
|
||||||
setName: dTag,
|
|
||||||
setTitle,
|
|
||||||
setDescription,
|
|
||||||
setImage
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
|
||||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
|
||||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
setName: dTag,
|
setName: dTag,
|
||||||
@@ -146,8 +198,17 @@ export async function collectBookmarksFromEvents(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
}
|
||||||
// ignore individual event failures
|
}
|
||||||
|
|
||||||
|
// Decrypt events sequentially
|
||||||
|
const privateItemsAll: IndividualBookmark[] = []
|
||||||
|
if (decryptJobs.length > 0 && signerCandidate) {
|
||||||
|
for (const job of decryptJobs) {
|
||||||
|
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
|
||||||
|
if (privateItems && privateItems.length > 0) {
|
||||||
|
privateItemsAll.push(...privateItems)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import {
|
|
||||||
AccountWithExtension,
|
|
||||||
NostrEvent,
|
|
||||||
dedupeNip51Events,
|
|
||||||
hydrateItems,
|
|
||||||
isAccountWithExtension,
|
|
||||||
hasNip04Decrypt,
|
|
||||||
hasNip44Decrypt,
|
|
||||||
dedupeBookmarksById,
|
|
||||||
extractUrlsFromContent
|
|
||||||
} from './bookmarkHelpers'
|
|
||||||
import { Bookmark } from '../types/bookmarks'
|
|
||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
|
||||||
import { UserSettings } from './settingsService'
|
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
|
||||||
import { queryEvents } from './dataFetch'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const fetchBookmarks = async (
|
|
||||||
relayPool: RelayPool,
|
|
||||||
activeAccount: unknown, // Full account object with extension capabilities
|
|
||||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
|
||||||
settings?: UserSettings
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
|
|
||||||
if (!isAccountWithExtension(activeAccount)) {
|
|
||||||
throw new Error('Invalid account object provided')
|
|
||||||
}
|
|
||||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
|
||||||
console.log('🔍 Fetching bookmark events')
|
|
||||||
|
|
||||||
const rawEvents = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
|
||||||
|
|
||||||
// Rebroadcast bookmark events to local/all relays based on settings
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
|
||||||
// Check for events with potentially encrypted content
|
|
||||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
|
||||||
if (eventsWithContent.length > 0) {
|
|
||||||
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
|
|
||||||
eventsWithContent.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
|
|
||||||
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
rawEvents.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
|
||||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
|
||||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
|
||||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
|
||||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
|
||||||
|
|
||||||
// Log which events made it through deduplication
|
|
||||||
bookmarkListEvents.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check specifically for Primal's "reads" list
|
|
||||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
|
||||||
if (primalReads) {
|
|
||||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
|
||||||
} else {
|
|
||||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bookmarkListEvents.length === 0) {
|
|
||||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Aggregate across events
|
|
||||||
const maybeAccount = activeAccount as AccountWithExtension
|
|
||||||
console.log('🔐 Account object:', {
|
|
||||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
|
||||||
hasSigner: !!maybeAccount?.signer,
|
|
||||||
accountType: typeof maybeAccount,
|
|
||||||
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
|
|
||||||
})
|
|
||||||
|
|
||||||
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
|
|
||||||
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
|
|
||||||
let signerCandidate: unknown = maybeAccount
|
|
||||||
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
|
||||||
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
|
||||||
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
|
||||||
// Fallback to the raw signer if account doesn't have nip04/nip44
|
|
||||||
signerCandidate = maybeAccount.signer
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
|
||||||
if (signerCandidate) {
|
|
||||||
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
|
||||||
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
|
||||||
}
|
|
||||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
|
||||||
bookmarkListEvents,
|
|
||||||
activeAccount,
|
|
||||||
signerCandidate
|
|
||||||
)
|
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
|
||||||
|
|
||||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
|
||||||
const noteIds: string[] = []
|
|
||||||
const coordinates: string[] = []
|
|
||||||
|
|
||||||
allItems.forEach(i => {
|
|
||||||
// Check if it's a hex ID (64 character hex string)
|
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
|
||||||
noteIds.push(i.id)
|
|
||||||
} else if (i.id.includes(':')) {
|
|
||||||
// Coordinate format: kind:pubkey:identifier
|
|
||||||
coordinates.push(i.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
|
||||||
|
|
||||||
// Fetch regular events by ID
|
|
||||||
if (noteIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const events = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ ids: Array.from(new Set(noteIds)) },
|
|
||||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
|
||||||
)
|
|
||||||
events.forEach((e: NostrEvent) => {
|
|
||||||
idToEvent.set(e.id, e)
|
|
||||||
// Also store by coordinate if it's an addressable event
|
|
||||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
|
||||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch events by ID:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch addressable events by coordinates
|
|
||||||
if (coordinates.length > 0) {
|
|
||||||
try {
|
|
||||||
// Group by kind for more efficient querying
|
|
||||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
|
||||||
|
|
||||||
coordinates.forEach(coord => {
|
|
||||||
const parts = coord.split(':')
|
|
||||||
const kind = parseInt(parts[0])
|
|
||||||
const pubkey = parts[1]
|
|
||||||
const identifier = parts[2] || ''
|
|
||||||
|
|
||||||
if (!byKind.has(kind)) {
|
|
||||||
byKind.set(kind, [])
|
|
||||||
}
|
|
||||||
byKind.get(kind)!.push({ pubkey, identifier })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query each kind group
|
|
||||||
for (const [kind, items] of byKind.entries()) {
|
|
||||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
|
||||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
|
||||||
|
|
||||||
const events = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ kinds: [kind], authors, '#d': identifiers },
|
|
||||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
|
||||||
)
|
|
||||||
|
|
||||||
events.forEach((e: NostrEvent) => {
|
|
||||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, e)
|
|
||||||
// Also store by event ID
|
|
||||||
idToEvent.set(e.id, e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch addressable events:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
|
||||||
const allBookmarks = dedupeBookmarksById([
|
|
||||||
...hydrateItems(publicItemsAll, idToEvent),
|
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Sort individual bookmarks by "added" timestamp first (most recently added first),
|
|
||||||
// falling back to event created_at when unknown.
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
|
||||||
...b,
|
|
||||||
tags: b.tags || [],
|
|
||||||
content: b.content || ''
|
|
||||||
}))
|
|
||||||
const sortedBookmarks = enriched
|
|
||||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
|
||||||
|
|
||||||
const bookmark: Bookmark = {
|
|
||||||
id: `${activeAccount.pubkey}-bookmarks`,
|
|
||||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
|
||||||
url: '',
|
|
||||||
content: latestContent,
|
|
||||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
|
||||||
tags: allTags,
|
|
||||||
bookmarkCount: sortedBookmarks.length,
|
|
||||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
|
||||||
individualBookmarks: sortedBookmarks,
|
|
||||||
isPrivate: privateItemsAll.length > 0,
|
|
||||||
encryptedContent: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
setBookmarks([bookmark])
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch bookmarks:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the contact list (follows) for a specific user
|
* Fetches the contact list (follows) for a specific user
|
||||||
@@ -16,7 +15,6 @@ export const fetchContacts = async (
|
|||||||
): Promise<Set<string>> => {
|
): Promise<Set<string>> => {
|
||||||
try {
|
try {
|
||||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
|
||||||
|
|
||||||
const partialFollowed = new Set<string>()
|
const partialFollowed = new Set<string>()
|
||||||
const events = await queryEvents(
|
const events = await queryEvents(
|
||||||
@@ -24,7 +22,6 @@ export const fetchContacts = async (
|
|||||||
{ kinds: [3], authors: [pubkey] },
|
{ kinds: [3], authors: [pubkey] },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||||
// Stream partials as we see any contact list
|
// Stream partials as we see any contact list
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
@@ -53,9 +50,7 @@ export const fetchContacts = async (
|
|||||||
}
|
}
|
||||||
// merged already via streams
|
// merged already via streams
|
||||||
|
|
||||||
console.log('📊 Contact events fetched:', events.length)
|
|
||||||
|
|
||||||
console.log('👥 Followed contacts:', followed.size)
|
|
||||||
return followed
|
return followed
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch contacts:', error)
|
console.error('Failed to fetch contacts:', error)
|
||||||
|
|||||||
110
src/services/contactsController.ts
Normal file
110
src/services/contactsController.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchContacts } from './contactService'
|
||||||
|
|
||||||
|
type ContactsCallback = (contacts: Set<string>) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared contacts/friends controller
|
||||||
|
* Manages the user's follow list centrally, similar to bookmarkController
|
||||||
|
*/
|
||||||
|
class ContactsController {
|
||||||
|
private contactsListeners: ContactsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentContacts: Set<string> = new Set()
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
|
||||||
|
onContacts(cb: ContactsCallback): () => void {
|
||||||
|
this.contactsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.contactsListeners = this.contactsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitContacts(contacts: Set<string>): void {
|
||||||
|
this.contactsListeners.forEach(cb => cb(contacts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current contacts without triggering a reload
|
||||||
|
*/
|
||||||
|
getContacts(): Set<string> {
|
||||||
|
return new Set(this.currentContacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if contacts are loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.currentContacts.clear()
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load contacts for a user
|
||||||
|
* Streams partial results and caches the final list
|
||||||
|
*/
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, pubkey, force = false } = options
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contacts = await fetchContacts(
|
||||||
|
relayPool,
|
||||||
|
pubkey,
|
||||||
|
(partial) => {
|
||||||
|
// Stream partial updates
|
||||||
|
this.currentContacts = new Set(partial)
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store final result
|
||||||
|
this.currentContacts = new Set(contacts)
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||||
|
this.currentContacts.clear()
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const contactsController = new ContactsController()
|
||||||
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
import { Observable, merge, toArray, tap, lastValueFrom } from 'rxjs'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Filter } from 'nostr-tools/filter'
|
import { Filter } from 'nostr-tools/filter'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
export interface QueryOptions {
|
export interface QueryOptions {
|
||||||
relayUrls?: string[]
|
relayUrls?: string[]
|
||||||
localTimeoutMs?: number
|
|
||||||
remoteTimeoutMs?: number
|
|
||||||
onEvent?: (event: NostrEvent) => void
|
onEvent?: (event: NostrEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified local-first query helper with optional streaming callback.
|
* Unified local-first query helper with optional streaming callback.
|
||||||
* Returns all collected events (deduped by id) after both streams complete or time out.
|
* Returns all collected events (deduped by id) after both streams complete (EOSE).
|
||||||
|
* Trusts relay EOSE signals - no artificial timeouts.
|
||||||
*/
|
*/
|
||||||
export async function queryEvents(
|
export async function queryEvents(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
|||||||
): Promise<NostrEvent[]> {
|
): Promise<NostrEvent[]> {
|
||||||
const {
|
const {
|
||||||
relayUrls,
|
relayUrls,
|
||||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
|
||||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent
|
onEvent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(localTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(remoteTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,10 @@ export async function createDeletionRequest(
|
|||||||
|
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays
|
||||||
await relayPool.publish(RELAYS, signed)
|
await relayPool.publish(RELAYS, signed)
|
||||||
|
|
||||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
|
||||||
|
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/services/eventManager.ts
Normal file
162
src/services/eventManager.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { createEventLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (event: NostrEvent) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized event manager for event fetching and caching
|
||||||
|
* Handles deduplication of concurrent requests and coordinate with relay pool
|
||||||
|
*/
|
||||||
|
class EventManager {
|
||||||
|
private eventStore: IEventStore | null = null
|
||||||
|
private relayPool: RelayPool | null = null
|
||||||
|
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||||
|
|
||||||
|
// Track pending requests to deduplicate and resolve all at once
|
||||||
|
private pendingRequests = new Map<string, PendingRequest[]>()
|
||||||
|
|
||||||
|
// Safety timeout for event fetches (ms)
|
||||||
|
private fetchTimeoutMs = 12000
|
||||||
|
// Retry policy
|
||||||
|
private maxAttempts = 4
|
||||||
|
private baseBackoffMs = 700
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the event manager with event store and relay pool
|
||||||
|
*/
|
||||||
|
setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void {
|
||||||
|
this.eventStore = eventStore
|
||||||
|
this.relayPool = relayPool
|
||||||
|
|
||||||
|
// Recreate loader when services change
|
||||||
|
if (relayPool) {
|
||||||
|
this.eventLoader = createEventLoader(relayPool, {
|
||||||
|
eventStore: eventStore || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retry any pending requests now that we have a loader
|
||||||
|
this.retryAllPending()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached event from event store
|
||||||
|
*/
|
||||||
|
getCachedEvent(eventId: string): NostrEvent | null {
|
||||||
|
if (!this.eventStore) return null
|
||||||
|
return this.eventStore.getEvent(eventId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an event by ID, returning a promise
|
||||||
|
* Automatically deduplicates concurrent requests for the same event
|
||||||
|
*/
|
||||||
|
fetchEvent(eventId: string): Promise<NostrEvent> {
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.getCachedEvent(eventId)
|
||||||
|
if (cached) {
|
||||||
|
return Promise.resolve(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<NostrEvent>((resolve, reject) => {
|
||||||
|
// Check if we're already fetching this event
|
||||||
|
if (this.pendingRequests.has(eventId)) {
|
||||||
|
// Add to existing request queue
|
||||||
|
this.pendingRequests.get(eventId)!.push({ resolve, reject })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new fetch request
|
||||||
|
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
||||||
|
this.fetchFromRelayWithRetry(eventId, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePending(eventId: string, event: NostrEvent): void {
|
||||||
|
const requests = this.pendingRequests.get(eventId) || []
|
||||||
|
this.pendingRequests.delete(eventId)
|
||||||
|
requests.forEach(req => req.resolve(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPending(eventId: string, error: Error): void {
|
||||||
|
const requests = this.pendingRequests.get(eventId) || []
|
||||||
|
this.pendingRequests.delete(eventId)
|
||||||
|
requests.forEach(req => req.reject(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
|
||||||
|
// If no loader yet, schedule retry
|
||||||
|
if (!this.relayPool || !this.eventLoader) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pendingRequests.has(eventId)) {
|
||||||
|
this.fetchFromRelayWithRetry(eventId, attempt)
|
||||||
|
}
|
||||||
|
}, this.baseBackoffMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let delivered = false
|
||||||
|
const subscription = this.eventLoader({ id: eventId }).subscribe({
|
||||||
|
next: (event: NostrEvent) => {
|
||||||
|
delivered = true
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
this.resolvePending(eventId, event)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err))
|
||||||
|
// Retry on error until attempts exhausted
|
||||||
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
|
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||||
|
} else {
|
||||||
|
this.rejectPending(eventId, error)
|
||||||
|
}
|
||||||
|
subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
// Completed without next - consider not found, but retry a few times
|
||||||
|
if (!delivered) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
|
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
|
||||||
|
} else {
|
||||||
|
this.rejectPending(eventId, new Error('Event not found'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Safety timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!delivered) {
|
||||||
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
this.fetchFromRelayWithRetry(eventId, attempt + 1)
|
||||||
|
} else {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.fetchTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry all pending requests after relay pool becomes available
|
||||||
|
*/
|
||||||
|
private retryAllPending(): void {
|
||||||
|
const pendingIds = Array.from(this.pendingRequests.keys())
|
||||||
|
pendingIds.forEach(eventId => {
|
||||||
|
this.fetchFromRelayWithRetry(eventId, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const eventManager = new EventManager()
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -19,32 +20,44 @@ export interface BlogPostPreview {
|
|||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
|
* @param onPost - Optional callback for streaming posts
|
||||||
|
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
|
||||||
|
* @param eventStore - Optional event store to persist fetched events
|
||||||
* @returns Array of blog post previews
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPostsFromAuthors = async (
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
onPost?: (post: BlogPostPreview) => void
|
onPost?: (post: BlogPostPreview) => void,
|
||||||
|
limit: number | null = 100,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
|
||||||
|
|
||||||
// Deduplicate replaceable events by keeping the most recent version
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
// Group by author + d-tag identifier
|
// Group by author + d-tag identifier
|
||||||
const uniqueEvents = new Map<string, NostrEvent>()
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
await queryEvents(
|
const filter = limit !== null
|
||||||
|
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
|
||||||
|
: { kinds: [KINDS.BlogPost], authors: pubkeys }
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
filter,
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
// Store in event store immediately if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const key = `${event.pubkey}:${dTag}`
|
const key = `${event.pubkey}:${dTag}`
|
||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
@@ -67,7 +80,10 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
// Store all events in event store if provided (safety net for any missed during streaming)
|
||||||
|
if (eventStore) {
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
@@ -89,7 +105,6 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function createHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create EventFactory with the account as signer
|
// Create EventFactory with the account as signer
|
||||||
const factory = new EventFactory({ signer: account })
|
const factory = new EventFactory({ signer: account.signer })
|
||||||
|
|
||||||
let blueprintSource: NostrEvent | AddressPointer | string
|
let blueprintSource: NostrEvent | AddressPointer | string
|
||||||
let context: string | undefined
|
let context: string | undefined
|
||||||
|
|||||||
96
src/services/highlights/cache.ts
Normal file
96
src/services/highlights/cache.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Highlight } from '../../types/highlights'
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
highlights: Highlight[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory session cache for highlight queries with TTL
|
||||||
|
*/
|
||||||
|
class HighlightCache {
|
||||||
|
private cache = new Map<string, CacheEntry>()
|
||||||
|
private ttlMs = 60000 // 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for article coordinate
|
||||||
|
*/
|
||||||
|
articleKey(coordinate: string): string {
|
||||||
|
return `article:${coordinate}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for URL
|
||||||
|
*/
|
||||||
|
urlKey(url: string): string {
|
||||||
|
// Normalize URL for consistent caching
|
||||||
|
try {
|
||||||
|
const normalized = new URL(url)
|
||||||
|
normalized.hash = '' // Remove hash
|
||||||
|
return `url:${normalized.toString()}`
|
||||||
|
} catch {
|
||||||
|
return `url:${url}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for author pubkey
|
||||||
|
*/
|
||||||
|
authorKey(pubkey: string): string {
|
||||||
|
return `author:${pubkey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached highlights if not expired
|
||||||
|
*/
|
||||||
|
get(key: string): Highlight[] | null {
|
||||||
|
const entry = this.cache.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - entry.timestamp > this.ttlMs) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store highlights in cache
|
||||||
|
*/
|
||||||
|
set(key: string, highlights: Highlight[]): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
highlights,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear specific cache entry
|
||||||
|
*/
|
||||||
|
clear(key: string): void {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache entries
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache stats
|
||||||
|
*/
|
||||||
|
stats(): { size: number; keys: string[] } {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
keys: Array.from(this.cache.keys())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const highlightCache = new HighlightCache()
|
||||||
|
|
||||||
@@ -1,60 +1,75 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { KINDS } from '../../config/kinds'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
|
// Check cache first unless force refresh
|
||||||
|
if (!force) {
|
||||||
|
const cacheKey = highlightCache.authorKey(pubkey)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const ordered = prioritizeLocalRelays(relayUrls)
|
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const local$ = localRelays.length > 0
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
? relayPool
|
relayPool,
|
||||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
.pipe(
|
{
|
||||||
onlyEvents(),
|
onEvent: (event: NostrEvent) => {
|
||||||
tap((event: NostrEvent) => {
|
if (seenIds.has(event.id)) return
|
||||||
if (!seenIds.has(event.id)) {
|
seenIds.add(event.id)
|
||||||
seenIds.add(event.id)
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
// Store in event store if provided
|
||||||
}
|
if (eventStore) {
|
||||||
}),
|
eventStore.add(event)
|
||||||
completeOnEose(),
|
}
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
}
|
||||||
const remote$ = remoteRelays.length > 0
|
}
|
||||||
? relayPool
|
)
|
||||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
// Store all events in event store if provided
|
||||||
tap((event: NostrEvent) => {
|
if (eventStore) {
|
||||||
if (!seenIds.has(event.id)) {
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
seenIds.add(event.id)
|
}
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
|
||||||
}
|
try {
|
||||||
}),
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
completeOnEose(),
|
} catch (err) {
|
||||||
takeUntil(timer(6000))
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
)
|
}
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
|
||||||
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.authorKey(pubkey)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,79 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForArticle = async (
|
export const fetchHighlightsForArticle = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
articleCoordinate: string,
|
articleCoordinate: string,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
|
// Check cache first unless force refresh
|
||||||
|
if (!force) {
|
||||||
|
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
const onEvent = (event: NostrEvent) => {
|
||||||
if (seenIds.has(event.id)) return null
|
if (seenIds.has(event.id)) return
|
||||||
seenIds.add(event.id)
|
seenIds.add(event.id)
|
||||||
return eventToHighlight(event)
|
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
// Query for both #a and #e tags in parallel
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
const [aTagEvents, eTagEvents] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
|
||||||
const aLocal$ = localRelays.length > 0
|
eventId
|
||||||
? relayPool
|
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
|
||||||
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
: Promise.resolve([] as NostrEvent[])
|
||||||
.pipe(
|
])
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
|
||||||
|
|
||||||
let eTagEvents: NostrEvent[] = []
|
|
||||||
if (eventId) {
|
|
||||||
const eLocal$ = localRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const eRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
// Store all events in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,78 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForUrl = async (
|
export const fetchHighlightsForUrl = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
url: string,
|
url: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
const seenIds = new Set<string>()
|
// Check cache first unless force refresh
|
||||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
if (!force) {
|
||||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const local$ = localRelaysUrl.length > 0
|
const seenIds = new Set<string>()
|
||||||
? relayPool
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
relayPool,
|
||||||
.pipe(
|
{ kinds: [KINDS.Highlights], '#r': [url] },
|
||||||
onlyEvents(),
|
{
|
||||||
tap((event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
seenIds.add(event.id)
|
if (seenIds.has(event.id)) return
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
seenIds.add(event.id)
|
||||||
}),
|
|
||||||
completeOnEose(),
|
// Store in event store if provided
|
||||||
takeUntil(timer(1200))
|
if (eventStore) {
|
||||||
)
|
eventStore.add(event)
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
}
|
||||||
const remote$ = remoteRelaysUrl.length > 0
|
|
||||||
? relayPool
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
|
}
|
||||||
.pipe(
|
}
|
||||||
onlyEvents(),
|
)
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
seenIds.add(event.id)
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
// Store all events in event store if provided
|
||||||
}),
|
if (eventStore) {
|
||||||
completeOnEose(),
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
takeUntil(timer(6000))
|
}
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
|
||||||
|
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
|
||||||
|
|
||||||
// Rebroadcast events - but don't let errors here break the highlight display
|
// Rebroadcast events - but don't let errors here break the highlight display
|
||||||
try {
|
try {
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to rebroadcast highlight events:', err)
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching highlights for URL:', err)
|
console.error('Error fetching highlights for URL:', err)
|
||||||
// Return highlights that were already streamed via callback
|
|
||||||
// Don't return empty array as that would clear already-displayed highlights
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { queryEvents } from '../dataFetch'
|
import { queryEvents } from '../dataFetch'
|
||||||
@@ -9,20 +10,20 @@ import { queryEvents } from '../dataFetch'
|
|||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||||
|
* @param eventStore - Optional event store to persist events
|
||||||
* @returns Array of highlights
|
* @returns Array of highlights
|
||||||
*/
|
*/
|
||||||
export const fetchHighlightsFromAuthors = async (
|
export const fetchHighlightsFromAuthors = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
onHighlight?: (highlight: Highlight) => void
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
@@ -32,16 +33,26 @@ export const fetchHighlightsFromAuthors = async (
|
|||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
if (!seenIds.has(event.id)) {
|
if (!seenIds.has(event.id)) {
|
||||||
seenIds.add(event.id)
|
seenIds.add(event.id)
|
||||||
|
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
|
||||||
|
|
||||||
return sortHighlights(highlights)
|
return sortHighlights(highlights)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
203
src/services/highlightsController.ts
Normal file
203
src/services/highlightsController.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||||
|
|
||||||
|
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared highlights controller
|
||||||
|
* Manages the user's highlights centrally, similar to bookmarkController
|
||||||
|
*/
|
||||||
|
class HighlightsController {
|
||||||
|
private highlightsListeners: HighlightsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentHighlights: Highlight[] = []
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
onHighlights(cb: HighlightsCallback): () => void {
|
||||||
|
this.highlightsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitHighlights(highlights: Highlight[]): void {
|
||||||
|
this.highlightsListeners.forEach(cb => cb(highlights))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current highlights without triggering a reload
|
||||||
|
*/
|
||||||
|
getHighlights(): Highlight[] {
|
||||||
|
return [...this.currentHighlights]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if highlights are loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
this.currentHighlights = []
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last synced timestamp for incremental loading
|
||||||
|
*/
|
||||||
|
private getLastSyncedAt(pubkey: string): number | null {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!data) return null
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return parsed[pubkey] || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last synced timestamp
|
||||||
|
*/
|
||||||
|
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
const parsed = data ? JSON.parse(data) : {}
|
||||||
|
parsed[pubkey] = timestamp
|
||||||
|
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load highlights for a user
|
||||||
|
* Streams results and stores in event store
|
||||||
|
*/
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = options
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight work
|
||||||
|
this.generation++
|
||||||
|
const currentGeneration = this.generation
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
|
||||||
|
// Get last synced timestamp for incremental loading
|
||||||
|
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||||
|
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||||
|
kinds: [KINDS.Highlights],
|
||||||
|
authors: [pubkey]
|
||||||
|
}
|
||||||
|
if (lastSyncedAt) {
|
||||||
|
filter.since = lastSyncedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
filter,
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Check if this generation is still active
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
// Store in event store immediately
|
||||||
|
eventStore.add(evt)
|
||||||
|
|
||||||
|
// Convert to highlight and add to map
|
||||||
|
const highlight = eventToHighlight(evt)
|
||||||
|
highlightsMap.set(highlight.id, highlight)
|
||||||
|
|
||||||
|
// Stream to listeners
|
||||||
|
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
|
||||||
|
this.currentHighlights = sortedHighlights
|
||||||
|
this.emitHighlights(sortedHighlights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if still active after async operation
|
||||||
|
if (currentGeneration !== this.generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store all events in event store
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
|
||||||
|
// Final processing
|
||||||
|
const highlights = events.map(eventToHighlight)
|
||||||
|
const uniqueHighlights = Array.from(
|
||||||
|
new Map(highlights.map(h => [h.id, h])).values()
|
||||||
|
)
|
||||||
|
const sorted = sortHighlights(uniqueHighlights)
|
||||||
|
|
||||||
|
this.currentHighlights = sorted
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
this.emitHighlights(sorted)
|
||||||
|
|
||||||
|
// Update last synced timestamp
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
||||||
|
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||||
|
this.currentHighlights = []
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
} finally {
|
||||||
|
// Only clear loading if this generation is still active
|
||||||
|
if (currentGeneration === this.generation) {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const highlightsController = new HighlightsController()
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ const CACHE_NAME = 'boris-image-cache-v1'
|
|||||||
export async function clearImageCache(): Promise<void> {
|
export async function clearImageCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await caches.delete(CACHE_NAME)
|
await caches.delete(CACHE_NAME)
|
||||||
console.log('🗑️ Cleared all cached images')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear image cache:', err)
|
console.error('Failed to clear image cache:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { KINDS } from '../config/kinds'
|
||||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
import { ARCHIVE_EMOJI } from './reactionService'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
|
||||||
@@ -29,15 +29,15 @@ export async function fetchReadArticles(
|
|||||||
try {
|
try {
|
||||||
// Fetch kind:7 and kind:17 reactions in parallel
|
// Fetch kind:7 and kind:17 reactions in parallel
|
||||||
const [kind7Events, kind17Events] = await Promise.all([
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }),
|
||||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] })
|
||||||
])
|
])
|
||||||
|
|
||||||
const readArticles: ReadArticle[] = []
|
const readArticles: ReadArticle[] = []
|
||||||
|
|
||||||
// Process kind:7 reactions (nostr-native articles)
|
// Process kind:7 reactions (nostr-native articles)
|
||||||
for (const event of kind7Events) {
|
for (const event of kind7Events) {
|
||||||
if (event.content === MARK_AS_READ_EMOJI) {
|
if (event.content === ARCHIVE_EMOJI) {
|
||||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||||
@@ -57,7 +57,7 @@ export async function fetchReadArticles(
|
|||||||
|
|
||||||
// Process kind:17 reactions (external URLs)
|
// Process kind:17 reactions (external URLs)
|
||||||
for (const event of kind17Events) {
|
for (const event of kind17Events) {
|
||||||
if (event.content === MARK_AS_READ_EMOJI) {
|
if (event.content === ARCHIVE_EMOJI) {
|
||||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||||
|
|
||||||
if (rTag && rTag[1]) {
|
if (rTag && rTag[1]) {
|
||||||
@@ -102,7 +102,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
// Filter to only nostr-native articles (kind 30023)
|
// Filter to only nostr-native articles (kind 30023)
|
||||||
const nostrArticles = readArticles.filter(
|
const nostrArticles = readArticles.filter(
|
||||||
article => article.eventKind === 30023 && article.eventId
|
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (nostrArticles.length === 0) {
|
if (nostrArticles.length === 0) {
|
||||||
@@ -114,8 +114,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
const articleEvents = await queryEvents(
|
const articleEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], ids: eventIds },
|
{ kinds: [KINDS.BlogPost], ids: eventIds }
|
||||||
{ relayUrls: RELAYS }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deduplicate article events by ID
|
// Deduplicate article events by ID
|
||||||
|
|||||||
83
src/services/linksService.ts
Normal file
83
src/services/linksService.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches external URL links with reading progress from:
|
||||||
|
* - URLs with reading progress (kind:39802)
|
||||||
|
* - Manually marked as read URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchLinks(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(linksMap, item)) {
|
||||||
|
onItem(linksMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
linksMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [progressEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Process reading progress events (kind 39802)
|
||||||
|
processReadingProgress(progressEvents, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit external items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for external URLs only with reading progress
|
||||||
|
const links = Array.from(linksMap.values())
|
||||||
|
.filter(item => {
|
||||||
|
// Only external URLs
|
||||||
|
if (item.type !== 'external') return false
|
||||||
|
|
||||||
|
// Only include if there's reading progress or marked as read
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
return hasProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply common validation and sorting
|
||||||
|
const validLinks = filterValidItems(links)
|
||||||
|
const sortedLinks = sortByReadingActivity(validLinks)
|
||||||
|
|
||||||
|
return sortedLinks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch links:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
readArticles: BlogPostPreview[]
|
readArticles: BlogPostPreview[]
|
||||||
|
reads?: ReadItem[]
|
||||||
|
links?: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/services/nostrConnect.ts
Normal file
26
src/services/nostrConnect.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default NIP-46 permissions for bunker connections
|
||||||
|
* These permissions cover all event kinds and encryption/decryption operations Boris needs
|
||||||
|
*/
|
||||||
|
export function getDefaultBunkerPermissions(): string[] {
|
||||||
|
return [
|
||||||
|
// Signing permissions for event kinds we create
|
||||||
|
...NostrConnectSigner.buildSigningPermissions([
|
||||||
|
0, // Profile metadata
|
||||||
|
5, // Event deletion
|
||||||
|
7, // Reactions (nostr events)
|
||||||
|
17, // Reactions (websites)
|
||||||
|
9802, // Highlights
|
||||||
|
30078, // Settings & reading positions
|
||||||
|
39701, // Web bookmarks
|
||||||
|
]),
|
||||||
|
// Encryption/decryption for hidden content
|
||||||
|
'nip04_encrypt',
|
||||||
|
'nip04_decrypt',
|
||||||
|
'nip44_encrypt',
|
||||||
|
'nip44_decrypt',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user