mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
1046 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f52b94d72a | ||
|
|
d0833b5ed4 | ||
|
|
2f20b393bc | ||
|
|
13fa6cd485 | ||
|
|
e6e7240cd5 | ||
|
|
c1ff3b44d1 | ||
|
|
0577f862fd | ||
|
|
883cb352ff | ||
|
|
238cc9bc00 | ||
|
|
1800ee324e | ||
|
|
7d2dac2f1a | ||
|
|
7875f1d0bd | ||
|
|
d9263e07d1 | ||
|
|
9a345a7347 | ||
|
|
55d1af3bf9 | ||
|
|
feb3134b65 | ||
|
|
7d222e099f | ||
|
|
59436b5b9e | ||
|
|
2e08954e83 | ||
|
|
9cb1791a3a | ||
|
|
28ba620967 | ||
|
|
56f2d33e93 | ||
|
|
312c742969 | ||
|
|
0781c4ebfc | ||
|
|
85f4cd3590 | ||
|
|
89bc6258b1 | ||
|
|
534b628aea | ||
|
|
317d2e0b53 | ||
|
|
9ea69589fa | ||
|
|
89eaa97d30 | ||
|
|
0283405fb5 | ||
|
|
5eade913d1 | ||
|
|
15a7129b6d | ||
|
|
b9e17e0982 | ||
|
|
1be8c62c94 | ||
|
|
e2bf243b01 | ||
|
|
85d816b2a7 | ||
|
|
623bee4632 | ||
|
|
e68b97bde8 | ||
|
|
ca32dfca51 | ||
|
|
9de8b00d5d | ||
|
|
033ef5e995 | ||
|
|
c986b0d517 | ||
|
|
1729a5b066 | ||
|
|
c6186ea84e | ||
|
|
c798376411 | ||
|
|
e83c301e6a | ||
|
|
2c0aee3fe4 | ||
|
|
d0f043fb5a | ||
|
|
039b988869 | ||
|
|
d285003e1d | ||
|
|
530abeeb33 | ||
|
|
3ac6954cb7 | ||
|
|
1c0f619a47 | ||
|
|
0fcfd200a4 | ||
|
|
e01c8d33fc | ||
|
|
51c0f7d923 | ||
|
|
8c79b5fd75 | ||
|
|
29746f1042 | ||
|
|
829ec4bf6e | ||
|
|
30ae0d9dfb | ||
|
|
8924f1b307 | ||
|
|
f92fa2cc93 | ||
|
|
cc70b533e5 | ||
|
|
003c439658 | ||
|
|
019958073c | ||
|
|
3d47dddbd2 | ||
|
|
cabf897df8 | ||
|
|
4801c0d621 | ||
|
|
ae76d6e4ea | ||
|
|
a611e99ff6 | ||
|
|
1c039e164f | ||
|
|
ffa4b38106 | ||
|
|
3b22cb5c5d | ||
|
|
7bc4522be4 | ||
|
|
048e0d802b | ||
|
|
b282bc4972 | ||
|
|
c1a23c1f8f | ||
|
|
8a5aacfe7b | ||
|
|
9126910de5 | ||
|
|
496bbc36f4 | ||
|
|
90f25420b2 | ||
|
|
9167134a89 | ||
|
|
b5717f1ebf | ||
|
|
0c8eaaf220 | ||
|
|
80b2720838 | ||
|
|
ea69740fc8 | ||
|
|
d650997ff9 | ||
|
|
ba3554b173 | ||
|
|
2cc39d0200 | ||
|
|
9aa914a704 | ||
|
|
497b6fa4be | ||
|
|
4c838b0123 | ||
|
|
d551f66ef1 | ||
|
|
34514199ee | ||
|
|
228304f68a | ||
|
|
ba263acdff | ||
|
|
5131cbe12c | ||
|
|
fa8eed4f4e | ||
|
|
3ff57c4b67 | ||
|
|
51c364ea53 | ||
|
|
4d032372dc | ||
|
|
48b5aa3a30 | ||
|
|
d4483a2f91 | ||
|
|
c62cb21962 | ||
|
|
3f7d726ae6 | ||
|
|
ac0e5eb585 | ||
|
|
5a0dd49e4e | ||
|
|
d067193f21 | ||
|
|
774e2ba67c | ||
|
|
6f1c31058f | ||
|
|
7551a05aee | ||
|
|
df485b883d | ||
|
|
6f428af1bc | ||
|
|
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 | ||
|
|
63f58e010f | ||
|
|
85649ae283 | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 | ||
|
|
c0332f08d6 | ||
|
|
38a1d6caec | ||
|
|
39dd607e7b | ||
|
|
9dc0db3e06 | ||
|
|
b1eb58a385 | ||
|
|
f3c6404f76 | ||
|
|
1a42a6422d | ||
|
|
2e2de4ccda | ||
|
|
4325d3a519 | ||
|
|
51115c5f68 | ||
|
|
2aa6fe860b | ||
|
|
86f39eacf8 | ||
|
|
d15daef3ea | ||
|
|
281c70cdea | ||
|
|
d6d6087543 | ||
|
|
d06e38bc19 | ||
|
|
cfc8eb0bbc | ||
|
|
b85f9b79c3 | ||
|
|
1b0045c737 | ||
|
|
3dc8d7d440 | ||
|
|
bf9ca48d64 | ||
|
|
70441f3d59 | ||
|
|
431f28e861 | ||
|
|
3b1fc095c4 | ||
|
|
9a6c7a29d0 | ||
|
|
c1d173f40e | ||
|
|
f03ec5df8c | ||
|
|
6c74a12636 | ||
|
|
39797803d3 | ||
|
|
c66c1e928d | ||
|
|
f934b641bb | ||
|
|
1128a11603 | ||
|
|
9f90718918 | ||
|
|
067a07fc00 | ||
|
|
1811cf045e | ||
|
|
270b4f429f | ||
|
|
380acbb55f | ||
|
|
c384f0b4fb | ||
|
|
27cf393a03 | ||
|
|
8831726913 | ||
|
|
2f4327874c | ||
|
|
483845962e | ||
|
|
c44b1d6349 | ||
|
|
79f28a142d | ||
|
|
02dd537cd9 | ||
|
|
5af1f14a0b | ||
|
|
664f59a9cc | ||
|
|
7d3641aab7 | ||
|
|
7924df4c67 | ||
|
|
68a8eed4af | ||
|
|
887db84ce7 | ||
|
|
05348fbfeb | ||
|
|
38eb6716f8 | ||
|
|
d7f9cd30eb | ||
|
|
922d041e0e | ||
|
|
76f4588c85 | ||
|
|
e163b92a7e | ||
|
|
11925a42b0 | ||
|
|
acf45530ca | ||
|
|
3792ad6abf | ||
|
|
bf98b307e8 | ||
|
|
d15392f41e | ||
|
|
f26a024255 | ||
|
|
bf9f894c0d | ||
|
|
53a7b7d1c5 | ||
|
|
a12a883cc6 | ||
|
|
0cf076b010 | ||
|
|
e2c712033f | ||
|
|
e38237ca8e | ||
|
|
1fff44fc6c | ||
|
|
4e50073e07 | ||
|
|
0ce64fe83f | ||
|
|
ef848aa93e | ||
|
|
67b287d75d | ||
|
|
b795dfd2c6 | ||
|
|
c68d855983 | ||
|
|
fb1c19e64b | ||
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab | ||
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f |
@@ -2,4 +2,4 @@
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Keep files below 210 lines.
|
||||
Keep files below 420 lines.
|
||||
21
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
21
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: fetching data from relays
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Fetching Data with Controllers
|
||||
|
||||
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.
|
||||
@@ -3,6 +3,8 @@ description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Mobile-First UI/UX
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
|
||||
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,8 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
# Reference Projects
|
||||
applesauce
|
||||
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.
|
||||
|
||||
|
||||
1448
CHANGELOG.md
1448
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
- **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.
|
||||
- **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).
|
||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
- **Profiles**: View your own (`/my`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
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: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:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
@@ -25,6 +26,7 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<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:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.9",
|
||||
"version": "0.10.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.6.9",
|
||||
"version": "0.10.19",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tinyld": "^1.3.4",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6086,6 +6088,15 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-average-color": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
|
||||
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -11205,6 +11216,22 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.11",
|
||||
"version": "0.10.20",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -25,6 +25,7 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tinyld": "^1.3.4",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"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",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "link"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"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"]
|
||||
]
|
||||
}
|
||||
```
|
||||
215
public/pwa.svg
Normal file
215
public/pwa.svg
Normal file
@@ -0,0 +1,215 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="649.67538"
|
||||
height="568.22024"
|
||||
viewBox="0 0 649.67538 568.22024"
|
||||
role="img"
|
||||
artist="Katerina Limpitsouni"
|
||||
source="https://undraw.co/"
|
||||
version="1.1"
|
||||
id="svg31"
|
||||
sodipodi:docname="pwa.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs31" />
|
||||
<sodipodi:namedview
|
||||
id="namedview31"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.6866359"
|
||||
inkscape:cx="303.56285"
|
||||
inkscape:cy="531.82789"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg31" />
|
||||
<path
|
||||
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#f2f2f2"
|
||||
id="path1" />
|
||||
<path
|
||||
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path3" />
|
||||
<path
|
||||
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path4" />
|
||||
<path
|
||||
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path5" />
|
||||
<path
|
||||
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path6" />
|
||||
<path
|
||||
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path7" />
|
||||
<path
|
||||
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path8" />
|
||||
<path
|
||||
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path9" />
|
||||
<path
|
||||
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path10" />
|
||||
<path
|
||||
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path11" />
|
||||
<path
|
||||
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path12" />
|
||||
<path
|
||||
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path13" />
|
||||
<path
|
||||
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path14" />
|
||||
<path
|
||||
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path15" />
|
||||
<path
|
||||
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path16" />
|
||||
<polygon
|
||||
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
|
||||
fill="#9e616a"
|
||||
id="polygon16" />
|
||||
<path
|
||||
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path17" />
|
||||
<polygon
|
||||
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
|
||||
fill="#9e616a"
|
||||
id="polygon17" />
|
||||
<path
|
||||
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path18" />
|
||||
<path
|
||||
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#9e616a"
|
||||
id="path19" />
|
||||
<path
|
||||
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path20" />
|
||||
<circle
|
||||
cx="443.5739"
|
||||
cy="133.65539"
|
||||
r="26.72083"
|
||||
fill="#9e616a"
|
||||
id="circle20" />
|
||||
<rect
|
||||
x="722.98731"
|
||||
y="465.33587"
|
||||
width="24.29166"
|
||||
height="31.57916"
|
||||
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
|
||||
fill="#2f2e41"
|
||||
id="rect20" />
|
||||
<path
|
||||
d="M593.23271,362.65743"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path21" />
|
||||
<path
|
||||
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path22" />
|
||||
<path
|
||||
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path23" />
|
||||
<path
|
||||
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#9e616a"
|
||||
id="path24" />
|
||||
<path
|
||||
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path25" />
|
||||
<path
|
||||
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path26" />
|
||||
<path
|
||||
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path27" />
|
||||
<path
|
||||
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path28" />
|
||||
<path
|
||||
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path29" />
|
||||
<path
|
||||
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path31" />
|
||||
<circle
|
||||
cx="255.31291"
|
||||
cy="19.52819"
|
||||
r="2"
|
||||
fill="#fff"
|
||||
id="circle31" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
235
public/zaps.svg
Normal file
235
public/zaps.svg
Normal file
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="720.44"
|
||||
height="718.635"
|
||||
viewBox="0 0 720.44 718.635"
|
||||
role="img"
|
||||
artist="Katerina Limpitsouni"
|
||||
source="https://undraw.co/"
|
||||
version="1.1"
|
||||
id="svg30"
|
||||
sodipodi:docname="zaps.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs30" /><sodipodi:namedview
|
||||
id="namedview30"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="2.6746164"
|
||||
inkscape:cx="38.510195"
|
||||
inkscape:cy="485.67712"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g27" /><g
|
||||
transform="translate(-600 -181)"
|
||||
id="g30"><g
|
||||
transform="translate(783.85 181)"
|
||||
id="g2"><path
|
||||
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
|
||||
transform="translate(-266.365 -79.193)"
|
||||
fill="#090814"
|
||||
id="path1" /><path
|
||||
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
|
||||
transform="translate(-266.577 -79.397)"
|
||||
fill="#fff"
|
||||
id="path2" /></g><path
|
||||
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
|
||||
transform="translate(828 265)"
|
||||
fill="#f2f2f2"
|
||||
id="path3" /><path
|
||||
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
|
||||
transform="translate(828 358.398)"
|
||||
fill="#f2f2f2"
|
||||
id="path4" /><g
|
||||
transform="translate(623.104 296.398)"
|
||||
id="g9"><rect
|
||||
width="278.304"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect4" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect5" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-53.047 -325.676)"
|
||||
fill="#6c63ff"
|
||||
id="path5" /><g
|
||||
transform="translate(17.038 13.546)"
|
||||
id="g7"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path6" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(72.886 17.036)"
|
||||
fill="#e6e6e6"
|
||||
id="path8" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(72.886 37.98)"
|
||||
fill="#e6e6e6"
|
||||
id="path9" /></g><g
|
||||
transform="translate(1003.278 402.469)"
|
||||
id="g14"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect9" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect10" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.751 -325.287)"
|
||||
fill="#6c63ff"
|
||||
id="path10" /><g
|
||||
transform="translate(17.334 13.936)"
|
||||
id="g12"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path11" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-7"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.181 17.426)"
|
||||
fill="#e6e6e6"
|
||||
id="path13" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.181 38.369)"
|
||||
fill="#e6e6e6"
|
||||
id="path14" /></g><g
|
||||
transform="translate(663.012 510.639)"
|
||||
id="g19"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect14" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect15" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.814 -325.25)"
|
||||
fill="#6c63ff"
|
||||
id="path15" /><g
|
||||
transform="translate(17.272 13.972)"
|
||||
id="g17"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path16" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-0"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.119 17.463)"
|
||||
fill="#e6e6e6"
|
||||
id="path18" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.119 38.406)"
|
||||
fill="#e6e6e6"
|
||||
id="path19" /></g><g
|
||||
transform="translate(1041.086 616.711)"
|
||||
id="g24"><rect
|
||||
width="279.354"
|
||||
height="70.364"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect19" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(4.201 4.201)"
|
||||
fill="#fff"
|
||||
id="rect20" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.163 -324.86)"
|
||||
fill="#6c63ff"
|
||||
id="path20" /><g
|
||||
transform="translate(17.922 14.362)"
|
||||
id="g22"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path21" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-9"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.77 17.853)"
|
||||
fill="#e6e6e6"
|
||||
id="path23" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.77 38.796)"
|
||||
fill="#e6e6e6"
|
||||
id="path24" /></g><g
|
||||
transform="translate(600 723.832)"
|
||||
id="g29"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect24" /><rect
|
||||
width="273.053"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect25" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.631 -325.518)"
|
||||
fill="#6c63ff"
|
||||
id="path25" /><g
|
||||
transform="translate(17.454 13.704)"
|
||||
id="g27"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path26" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-98"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.301 17.194)"
|
||||
fill="#e6e6e6"
|
||||
id="path28" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.301 38.138)"
|
||||
fill="#e6e6e6"
|
||||
id="path29" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
532
src/App.tsx
532
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
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 Debug from './components/Debug'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import ShareTargetHandler from './components/ShareTargetHandler'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
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 ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -21,26 +39,140 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
// AppRoutes component that has access to hooks
|
||||
function AppRoutes({
|
||||
relayPool,
|
||||
eventStore,
|
||||
showToast
|
||||
}: {
|
||||
relayPool: RelayPool
|
||||
eventStore: EventStore | null
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
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 = () => {
|
||||
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')
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/share-target"
|
||||
element={<ShareTargetHandler relayPool={relayPool} />}
|
||||
/>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -50,6 +182,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -59,6 +194,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -68,6 +206,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -77,6 +218,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -86,46 +230,97 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
path="/my"
|
||||
element={<Navigate to="/my/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
path="/my/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
path="/my/bookmarks"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
path="/my/reads"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/writings"
|
||||
path="/my/reads/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my/links"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my/links/:filter"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -135,6 +330,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -144,6 +342,34 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
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 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)
|
||||
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
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
const accountsJson = localStorage.getItem('accounts')
|
||||
|
||||
const json = JSON.parse(accountsJson || '[]')
|
||||
|
||||
await accounts.fromJSON(json)
|
||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
||||
|
||||
// Load active account from storage
|
||||
const activeId = localStorage.getItem('active')
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('Restored active account:', activeId)
|
||||
|
||||
if (activeId) {
|
||||
const account = accounts.getAccount(activeId)
|
||||
|
||||
if (account) {
|
||||
accounts.setActive(activeId)
|
||||
}
|
||||
}
|
||||
} catch (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
|
||||
pool.group(RELAYS)
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||
|
||||
if (account && account.type === 'nostr-connect') {
|
||||
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||
try {
|
||||
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||
}
|
||||
} 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
|
||||
// This prevents disconnection when no other subscriptions are active
|
||||
// Create a minimal subscription that never completes to keep connections alive
|
||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||
next: () => {}, // No-op, we don't care about events
|
||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
||||
next: () => {},
|
||||
error: () => {}
|
||||
})
|
||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||
|
||||
// Store subscription for cleanup
|
||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||
@@ -233,6 +716,8 @@ function App() {
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
bunkerReconnectSub.unsubscribe()
|
||||
userRelaysSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
@@ -249,7 +734,7 @@ function App() {
|
||||
return () => {
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
}, [isOnline, showToast])
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
@@ -284,7 +769,8 @@ function App() {
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<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>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
|
||||
47
src/components/ArchiveFilters.tsx
Normal file
47
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||
|
||||
interface ArchiveFiltersProps {
|
||||
selectedFilter: ArchiveFilterType
|
||||
onFilterChange: (filter: ArchiveFilterType) => void
|
||||
}
|
||||
|
||||
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => {
|
||||
const isActive = selectedFilter === filter.type
|
||||
// Only "completed" gets green color, everything else uses default blue
|
||||
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
style={activeStyle}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchiveFilters
|
||||
|
||||
@@ -6,26 +6,58 @@ import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { isKnownBot } from '../config/bots'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
hideBotByName?: boolean // default true
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${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 formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
// Calculate progress percentage and determine color (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)
|
||||
}
|
||||
|
||||
// Debug log - reading progress shown as visual indicator
|
||||
if (readingProgress !== undefined) {
|
||||
// Reading progress display
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
state={{
|
||||
previewData: {
|
||||
title: post.title,
|
||||
image: post.image,
|
||||
summary: post.summary,
|
||||
published: post.published
|
||||
}
|
||||
}}
|
||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
@@ -47,7 +79,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
|
||||
{/* Reading progress indicator - replaces the dividing line */}
|
||||
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||
<div
|
||||
className="blog-post-reading-progress"
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '1rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: 'var(--color-border)',
|
||||
marginTop: '1rem'
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
|
||||
44
src/components/BookmarkFilters.tsx
Normal file
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||
|
||||
interface BookmarkFiltersProps {
|
||||
selectedFilter: BookmarkFilterType
|
||||
onFilterChange: (filter: BookmarkFilterType) => void
|
||||
}
|
||||
|
||||
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||
selectedFilter,
|
||||
onFilterChange
|
||||
}) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BookmarkFilters
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState } from 'react'
|
||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
import { npubEncode, naddrEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
@@ -17,9 +20,11 @@ interface BookmarkItemProps {
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
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 navigate = useNavigate()
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -55,8 +60,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
// Resolve author profile using applesauce
|
||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||
const authorNpub = npubEncode(bookmark.pubkey)
|
||||
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
|
||||
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
|
||||
|
||||
// Get display name for author
|
||||
const getAuthorDisplayName = () => {
|
||||
@@ -66,28 +69,57 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return short(bookmark.pubkey) // fallback to short pubkey
|
||||
}
|
||||
|
||||
// use helper from kindIcon.ts
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = (): IconDefinition => {
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassification) {
|
||||
switch (firstUrlClassification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faLink // External article
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUrls) return faStickyNote // Just a text note
|
||||
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||
return faFileLines
|
||||
}
|
||||
|
||||
const getIconForUrlType = (url: string) => {
|
||||
const classification = classifyUrl(url)
|
||||
switch (classification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faPlay
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faEye
|
||||
return faCamera
|
||||
default:
|
||||
return faBookOpen
|
||||
return faFileLines
|
||||
}
|
||||
}
|
||||
|
||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// For kind:30023 articles, pass the bookmark data instead of URL
|
||||
// For kind:30023 articles, navigate to /a/:naddr route
|
||||
if (bookmark.kind === 30023) {
|
||||
if (onSelectUrl) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -109,15 +141,29 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
return <CompactView {...sharedProps} />
|
||||
const compactProps = {
|
||||
bookmark,
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
if (viewMode === 'large') {
|
||||
@@ -125,5 +171,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import React, { useRef } from 'react'
|
||||
import React, { useRef, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import CompactButton from './CompactButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
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 {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -29,6 +43,7 @@ interface BookmarkListProps {
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -43,12 +58,86 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false
|
||||
isMobile = false,
|
||||
settings
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
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 [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[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||
}
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||
@@ -62,36 +151,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
|
||||
const sections = useMemo(() => {
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
|
||||
|
||||
// Check if has URL
|
||||
let hasUrl = false
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
// For other bookmarks, extract URLs from content
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
// 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: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb },
|
||||
{ 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 }
|
||||
]
|
||||
|
||||
// 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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Always show articles (kind:30023) as they have special handling
|
||||
if (ib.kind === 30023) return true
|
||||
|
||||
// Otherwise, must have either content or URL
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
return sectionsArray
|
||||
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
// Re-sort after flattening to ensure newest first across all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
// Get all filtered bookmarks for empty state checks
|
||||
const allIndividualBookmarks = useMemo(() =>
|
||||
bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
|
||||
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
|
||||
)
|
||||
|
||||
const filteredBookmarks = useMemo(() =>
|
||||
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||
[allIndividualBookmarks, selectedFilter]
|
||||
)
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -121,11 +232,23 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
relayPool={relayPool}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
{allIndividualBookmarks.length > 0 && (
|
||||
<BookmarkFilters
|
||||
selectedFilter={selectedFilter}
|
||||
onFilterChange={setSelectedFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeAccount ? (
|
||||
<LoginOptions />
|
||||
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks match this filter.</p>
|
||||
</div>
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
@@ -138,7 +261,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</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>
|
||||
)
|
||||
) : (
|
||||
@@ -150,53 +272,88 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isRefreshing={isPulling || isRefreshing || false}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||
{section.key === 'web' && activeAccount && (
|
||||
<CompactButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add web bookmark"
|
||||
ariaLabel="Add web bookmark"
|
||||
className="bookmark-section-action"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
icon={faHeart}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support Boris"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
|
||||
onClick={toggleGroupingMode}
|
||||
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="view-mode-right">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
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>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
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>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import RichContent from '../RichContent'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,13 +17,13 @@ interface CardViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -33,13 +32,13 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
@@ -52,7 +51,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// 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)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
@@ -74,6 +80,29 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Get internal route for the bookmark
|
||||
const getInternalRoute = (): string | null => {
|
||||
if (bookmark.kind === 30023) {
|
||||
// Nostr-native article - use /a/ route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
} else if (bookmark.kind === 1) {
|
||||
// Note - use /e/ route
|
||||
return `/e/${bookmark.id}`
|
||||
} else if (firstUrl) {
|
||||
// External URL - use /r/ route
|
||||
return `/r/${encodeURIComponent(firstUrl)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -92,34 +121,20 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -127,23 +142,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
key={urlIndex}
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{extractedUrls.length > 1 && (
|
||||
@@ -160,19 +166,15 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
)}
|
||||
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="bookmark-content article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="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)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<div className="bookmark-content">
|
||||
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||
</div>
|
||||
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||
)}
|
||||
|
||||
{contentLength > 210 && (
|
||||
@@ -186,6 +188,28 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
</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-meta-minimal">
|
||||
<Link
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import RichContent from '../RichContent'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,8 +13,9 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -22,30 +24,43 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
const isNote = bookmark.kind === 1
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl(extractedUrls[0])
|
||||
}
|
||||
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
|
||||
|
||||
// Calculate progress color
|
||||
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)
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
const handleCompactClick = () => {
|
||||
if (isArticle) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
} else if (isNote) {
|
||||
navigate(`/e/${bookmark.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
@@ -55,36 +70,44 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
{displayText && (
|
||||
{displayText ? (
|
||||
<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>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
{/* CTA removed */}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import RichContent from '../RichContent'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -17,10 +18,11 @@ interface LargeViewProps {
|
||||
getIconForUrlType: IconGetter
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -32,14 +34,25 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getIconForUrlType,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
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 handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -48,6 +61,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Get internal route for the bookmark
|
||||
const getInternalRoute = (): string | null => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
if (bookmark.kind === 30023) {
|
||||
// Nostr-native article - use /a/ route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
const naddr = naddrEncode({
|
||||
kind: bookmark.kind,
|
||||
pubkey: bookmark.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
} else if (bookmark.kind === 1) {
|
||||
// Note - use /e/ route
|
||||
return `/e/${bookmark.id}`
|
||||
} else if (firstUrl) {
|
||||
// External URL - use /r/ route
|
||||
return `/r/${encodeURIComponent(firstUrl)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
@@ -80,16 +117,37 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
|
||||
<div className="large-content">
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="large-text article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
<RichContent content={articleSummary} className="large-text article-summary" />
|
||||
) : bookmark.content && (
|
||||
<div className="large-text">
|
||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
||||
<RichContent content={bookmark.content} className="large-text" />
|
||||
)}
|
||||
|
||||
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
<span className="large-author">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
@@ -100,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{eventNevent && (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
|
||||
{/* CTA removed */}
|
||||
|
||||
@@ -13,9 +13,12 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import { useEventLoader } from '../hooks/useEventLoader'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import Profile from './Profile'
|
||||
import Support from './Support'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
@@ -24,10 +27,19 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
interface BookmarksProps {
|
||||
relayPool: RelayPool | null
|
||||
onLogout: () => void
|
||||
bookmarks: Bookmark[]
|
||||
bookmarksLoading: boolean
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
relayPool,
|
||||
onLogout,
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks
|
||||
}) => {
|
||||
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
@@ -41,37 +53,41 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showMe = location.pathname.startsWith('/my')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
const eventId = eventIdParam
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/archive' ? 'archive' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
const meTab = location.pathname === '/my' ? 'highlights' :
|
||||
location.pathname === '/my/highlights' ? 'highlights' :
|
||||
location.pathname === '/my/bookmarks' ? 'bookmarks' :
|
||||
location.pathname.startsWith('/my/reads') ? 'reads' :
|
||||
location.pathname.startsWith('/my/links') ? 'links' :
|
||||
location.pathname === '/my/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
// Decode npub or nprofile to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
profilePubkey = decoded.data.pubkey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
console.error('Failed to decode npub/nprofile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
// Track previous location for going back from settings/my/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
@@ -149,8 +165,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -163,11 +177,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -214,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -230,6 +247,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -240,6 +258,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
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
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
@@ -313,10 +342,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
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}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } 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 { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
@@ -29,13 +30,23 @@ import {
|
||||
hasMarkedEventAsRead,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
|
||||
import { archiveController } from '../services/archiveController'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import TTSControls from './TTSControls'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -65,6 +76,7 @@ interface ContentPanelProps {
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -92,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
isHighlightsCollapsed = false,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
@@ -100,6 +113,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -118,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
currentUserPubkey,
|
||||
followedPubkeys
|
||||
})
|
||||
// Key used to force re-mount of markdown preview/render when content changes
|
||||
const contentKey = useMemo(() => {
|
||||
// Prefer selectedUrl as a stable per-article key; fallback to title+length
|
||||
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
|
||||
}, [selectedUrl, title, markdown, html])
|
||||
|
||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||
onHighlightClick,
|
||||
@@ -126,17 +147,192 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content that's loaded and long enough
|
||||
// Wait for content to load, check it's not a video, and verify it's long enough to track
|
||||
const isTextContent = useMemo(() => {
|
||||
if (loading) return false
|
||||
if (!markdown && !html) return false
|
||||
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
|
||||
if (!shouldTrackReadingProgress(html, markdown)) return false
|
||||
|
||||
return true
|
||||
}, [loading, markdown, html, selectedUrl])
|
||||
|
||||
// Generate article identifier for saving/loading position
|
||||
const articleIdentifier = useMemo(() => {
|
||||
if (!selectedUrl) return null
|
||||
return generateArticleIdentifier(selectedUrl)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Use refs for content to avoid recreating callback on every content change
|
||||
const htmlRef = useRef(html)
|
||||
const markdownRef = useRef(markdown)
|
||||
useEffect(() => {
|
||||
htmlRef.current = html
|
||||
markdownRef.current = markdown
|
||||
}, [html, markdown])
|
||||
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if content is long enough to track reading progress
|
||||
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
await saveReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
factory,
|
||||
articleIdentifier,
|
||||
{
|
||||
position,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
scrollTop
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[reading-position] Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||
|
||||
// Delay enabling position tracking to ensure content is stable
|
||||
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
|
||||
|
||||
// Reset tracking when article changes
|
||||
useEffect(() => {
|
||||
setIsTrackingEnabled(false)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Enable/disable tracking based on content state
|
||||
useEffect(() => {
|
||||
if (!isTextContent) {
|
||||
// Disable tracking if content is not suitable
|
||||
if (isTrackingEnabled) {
|
||||
setIsTrackingEnabled(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTrackingEnabled) {
|
||||
// Wait 500ms after content loads before enabling tracking
|
||||
const timer = setTimeout(() => {
|
||||
setIsTrackingEnabled(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isTextContent, isTrackingEnabled])
|
||||
|
||||
const { progressPercentage, suppressSavesFor } = useReadingPosition({
|
||||
enabled: isTrackingEnabled,
|
||||
syncEnabled: settings?.syncReadingPosition !== false,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
// Auto-mark as read when reading is complete (if enabled in settings)
|
||||
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
|
||||
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 (using pre-loaded data from controller)
|
||||
const suppressSavesForRef = useRef(suppressSavesFor)
|
||||
useEffect(() => {
|
||||
suppressSavesForRef.current = suppressSavesFor
|
||||
}, [suppressSavesFor])
|
||||
|
||||
// Track if we've successfully started restore for this article + tracking state
|
||||
// Use a composite key to ensure we only restore once per article when tracking is enabled
|
||||
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
|
||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
return
|
||||
}
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
return
|
||||
}
|
||||
if (settings?.autoScrollToReadingPosition === false) {
|
||||
return
|
||||
}
|
||||
if (!isTrackingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only attempt restore once per article (after tracking is enabled)
|
||||
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as attempted using composite key
|
||||
hasAttemptedRestoreRef.current = restoreKey
|
||||
|
||||
// Get the saved position from the controller (already loaded and displayed on card)
|
||||
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||
|
||||
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(1500)
|
||||
}
|
||||
|
||||
// Wait for content to be fully rendered
|
||||
setTimeout(() => {
|
||||
const docH = document.documentElement.scrollHeight
|
||||
const winH = window.innerHeight
|
||||
const maxScroll = Math.max(0, docH - winH)
|
||||
const currentTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const targetTop = savedProgress * maxScroll
|
||||
|
||||
// Skip if delta is too small (< 48px or < 5%)
|
||||
const deltaPx = Math.abs(targetTop - currentTop)
|
||||
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
|
||||
if (deltaPx < 48 || deltaPct < 0.05) {
|
||||
// Allow saves immediately since no scroll happened
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Perform smooth animated restore
|
||||
window.scrollTo({
|
||||
top: targetTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||
|
||||
// Note: We intentionally do NOT save on unmount because:
|
||||
// 1. Browser may scroll to top during back navigation, causing 0% saves
|
||||
// 2. The auto-save with 1s throttle already captures position during reading
|
||||
// 3. Position state may not reflect actual reading position during navigation
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -161,6 +357,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
// Check available space and position menu upward if needed
|
||||
useEffect(() => {
|
||||
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
|
||||
if (!menuRef.current) return
|
||||
|
||||
const menuWrapper = menuRef.current
|
||||
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
|
||||
if (!menuElement) return
|
||||
|
||||
const rect = menuWrapper.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
|
||||
|
||||
// Open upward if there's not enough space below (with 20px buffer)
|
||||
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
|
||||
}
|
||||
|
||||
if (showArticleMenu) {
|
||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||
}
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
if (showExternalMenu) {
|
||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -170,6 +395,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
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/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
@@ -207,7 +451,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (!currentArticle) return null
|
||||
|
||||
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')
|
||||
).slice(0, 3)
|
||||
|
||||
@@ -218,9 +463,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
relays: relayHints
|
||||
})
|
||||
|
||||
// Check for source URL in 'r' tags
|
||||
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(naddr),
|
||||
native: `nostr:${naddr}`
|
||||
native: `nostr:${naddr}`,
|
||||
naddr,
|
||||
sourceUrl,
|
||||
borisUrl: `${window.location.origin}/a/${naddr}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +496,78 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
const handleShareBoris = async () => {
|
||||
try {
|
||||
if (!articleLinks) return
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: articleLinks.borisUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareOriginal = async () => {
|
||||
try {
|
||||
if (!articleLinks?.sourceUrl) return
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: articleLinks.sourceUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyBoris = async () => {
|
||||
try {
|
||||
if (!articleLinks) return
|
||||
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyOriginal = async () => {
|
||||
try {
|
||||
if (!articleLinks?.sourceUrl) return
|
||||
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
// 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')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
@@ -307,10 +630,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
if (!selectedUrl) return
|
||||
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: borisUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(borisUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
@@ -318,6 +647,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
if (selectedUrl) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
@@ -337,12 +679,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
activeAccount.pubkey,
|
||||
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 {
|
||||
hasRead = await hasMarkedWebsiteAsRead(
|
||||
selectedUrl,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
// Also check archiveController
|
||||
const ctrl = archiveController.isMarked(selectedUrl)
|
||||
hasRead = hasRead || ctrl
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
@@ -356,7 +711,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -378,16 +761,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
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) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
archiveController.mark(selectedUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
@@ -421,7 +822,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{isTextContent && (
|
||||
<ReadingProgressIndicator
|
||||
progress={progressPercentage}
|
||||
isComplete={isReadingComplete}
|
||||
// Consider complete only at 95%+
|
||||
isComplete={progressPercentage >= 95}
|
||||
showPercentage={true}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
@@ -431,16 +833,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -461,7 +862,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
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 ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
@@ -501,7 +908,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
@@ -527,8 +934,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
@@ -545,10 +953,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -560,10 +970,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -582,13 +994,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
@@ -597,12 +1009,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
onClick={handleSearchExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -623,13 +1045,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</button>
|
||||
|
||||
{showArticleMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareBoris}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
{articleLinks.sourceUrl && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareOriginal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyBoris}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
{articleLinks.sourceUrl && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyOriginal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenSearch}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
<span>Open with njump</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
@@ -644,21 +1105,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{/* Archive button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
disabled={isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
|
||||
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
|
||||
</span>
|
||||
</button>
|
||||
</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 { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -8,20 +8,33 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
// Contacts are managed via controller subscription
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
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 RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
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 {
|
||||
relayPool: RelayPool
|
||||
@@ -41,14 +54,193 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
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>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
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
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -56,162 +248,164 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Load initial data and refresh on triggers
|
||||
const loadData = useCallback(() => {
|
||||
if (!relayPool) return
|
||||
|
||||
try {
|
||||
// show spinner but keep existing data
|
||||
setLoading(true)
|
||||
// Seed from cache for instant UI
|
||||
if (activeAccount) {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
setLoading(true)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(
|
||||
try {
|
||||
// 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,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
relayUrls,
|
||||
50,
|
||||
eventStore || undefined,
|
||||
(post) => {
|
||||
setBlogPosts(prev => {
|
||||
const merged = dedupeWritingsByReplaceable([...prev, post])
|
||||
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||
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) }
|
||||
}
|
||||
)
|
||||
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (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)
|
||||
).then((nostrversePosts) => {
|
||||
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// loading is already turned off after seeding
|
||||
}
|
||||
}
|
||||
}, [relayPool, activeAccount, eventStore, visibility.nostrverse])
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
@@ -237,35 +431,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
@@ -278,15 +443,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
})
|
||||
}, [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
|
||||
const filteredBlogPosts = useMemo(() => {
|
||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||
return blogPosts
|
||||
return uniqueSortedPosts
|
||||
.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
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
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
@@ -305,7 +486,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
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 = () => {
|
||||
switch (activeTab) {
|
||||
@@ -320,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No blog posts yet. Pull to refresh!</p>
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -331,6 +536,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
readingProgress={getReadingProgress(post)}
|
||||
hideBotByName={settings?.hideBotArticlesByName !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -347,8 +554,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No highlights yet. Pull to refresh!</p>
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<span>No highlights to show for the selected scope.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -357,7 +564,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -368,7 +574,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 showSkeletons = loading && !hasData
|
||||
|
||||
@@ -380,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<FontAwesomeIcon icon={faPersonHiking} />
|
||||
Explore
|
||||
</h1>
|
||||
|
||||
@@ -397,7 +603,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||
onClick={() => toggleScope('nostrverse')}
|
||||
title="Toggle nostrverse content"
|
||||
ariaLabel="Toggle nostrverse content"
|
||||
variant="ghost"
|
||||
@@ -408,7 +614,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||
onClick={() => toggleScope('friends')}
|
||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||
ariaLabel="Toggle friends content"
|
||||
variant="ghost"
|
||||
@@ -420,7 +626,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||
onClick={() => toggleScope('mine')}
|
||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||
ariaLabel="Toggle my content"
|
||||
variant="ghost"
|
||||
@@ -452,7 +658,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
// Fallback: extract directly from p tag
|
||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||
if (pTag && pTag[1]) {
|
||||
console.log('📝 Found author from p tag:', pTag[1])
|
||||
return pTag[1]
|
||||
}
|
||||
|
||||
@@ -45,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
try {
|
||||
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
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
|
||||
@@ -8,15 +8,16 @@ import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import NostrMentionLink from './NostrMentionLink'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
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
|
||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||
// Pattern to match both http(s) URLs and nostr: URIs
|
||||
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// Handle nostr: URIs
|
||||
// Handle nostr: URIs - now with profile resolution
|
||||
if (part.startsWith('nostr:')) {
|
||||
return renderNostrId(part, index)
|
||||
return (
|
||||
<NostrMentionLink
|
||||
key={index}
|
||||
nostrUri={part}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle http(s) URLs
|
||||
@@ -207,6 +121,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -235,10 +150,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
// Update the highlight with all relays after successful sync
|
||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
||||
if (onHighlightUpdate && highlight.isLocalOnly && relayPool) {
|
||||
const updatedHighlight = {
|
||||
...highlight,
|
||||
publishedRelays: RELAYS,
|
||||
publishedRelays: getActiveRelayUrls(relayPool),
|
||||
isLocalOnly: false,
|
||||
isOfflineCreated: false
|
||||
}
|
||||
@@ -249,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [highlight, onHighlightUpdate])
|
||||
}, [highlight, onHighlightUpdate, relayPool])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
@@ -257,32 +172,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}, [isSelected])
|
||||
|
||||
// Close menu when clicking outside
|
||||
// Close menu and reset delete confirm when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMenu) {
|
||||
if (showMenu || showDeleteConfirm) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showMenu])
|
||||
}, [showMenu, showDeleteConfirm])
|
||||
|
||||
const handleItemClick = () => {
|
||||
// If onHighlightClick is provided, use it (legacy behavior)
|
||||
if (onHighlightClick) {
|
||||
onHighlightClick(highlight.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, navigate to the article that this highlight references
|
||||
if (highlight.eventReference) {
|
||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||
const parts = highlight.eventReference.split(':')
|
||||
|
||||
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||
if (parts.length === 3) {
|
||||
const [kind, pubkey, identifier] = parts
|
||||
|
||||
if (kind === '30023') {
|
||||
// Encode as naddr and navigate
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
// Pass highlight ID in navigation state to trigger scroll
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
// Navigate to external URL with highlight ID to trigger scroll
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getHighlightLinks = () => {
|
||||
// Encode the highlight event itself (kind 9802) as a nevent
|
||||
// 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')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
@@ -318,13 +272,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
console.log('✅ Rebroadcast successful!')
|
||||
|
||||
// Update the highlight with new relay info
|
||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||
@@ -388,7 +340,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
|
||||
// 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(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
@@ -421,7 +374,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight deletion request published')
|
||||
|
||||
// Notify parent to remove this highlight from the list
|
||||
if (onHighlightDelete) {
|
||||
@@ -434,12 +386,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
|
||||
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Reset delete confirm state when opening/closing menu
|
||||
if (!showMenu) {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
setShowMenu(!showMenu)
|
||||
}
|
||||
|
||||
@@ -461,6 +413,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
handleConfirmDelete()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -468,7 +425,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
@@ -476,7 +433,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
// Navigate within app using same logic as handleItemClick
|
||||
if (highlight.eventReference) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
if (parts.length === 3 && parts[0] === '30023') {
|
||||
const [, pubkey, identifier] = parts
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
@@ -533,6 +514,33 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
{showDeleteConfirm && canDelete && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
|
||||
<button
|
||||
onClick={handleConfirmDeleteClick}
|
||||
disabled={isDeleting}
|
||||
title="Confirm deletion"
|
||||
style={{
|
||||
color: 'rgb(220 38 38)',
|
||||
background: 'rgba(220, 38, 38, 0.1)',
|
||||
border: '1px solid rgb(220 38 38)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.375rem',
|
||||
cursor: isDeleting ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '33px',
|
||||
minHeight: '33px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CompactButton
|
||||
icon={faEllipsisH}
|
||||
onClick={handleMenuToggle}
|
||||
@@ -546,7 +554,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
<span>Open with njump</span>
|
||||
</button>
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
@@ -571,17 +579,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Highlight?"
|
||||
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore,
|
||||
settings
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
@@ -116,15 +118,14 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<HighlightsPanelHeader
|
||||
loading={loading}
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
showHighlights={showHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
currentUserPubkey={currentUserPubkey}
|
||||
onToggleHighlights={handleToggleHighlights}
|
||||
onRefresh={onRefresh}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onHighlightVisibilityChange={onHighlightVisibilityChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import React from 'react'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
hasHighlights: boolean
|
||||
showHighlights: boolean
|
||||
highlightVisibility: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
onToggleHighlights: () => void
|
||||
onRefresh?: () => void
|
||||
onToggleCollapse: () => void
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
loading,
|
||||
hasHighlights,
|
||||
showHighlights,
|
||||
highlightVisibility,
|
||||
currentUserPubkey,
|
||||
onToggleHighlights,
|
||||
onRefresh,
|
||||
onToggleCollapse,
|
||||
onHighlightVisibilityChange
|
||||
onHighlightVisibilityChange,
|
||||
isMobile = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-actions">
|
||||
<div className="highlights-actions-left">
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
)}
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<IconButton
|
||||
@@ -46,49 +55,42 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
{currentUserPubkey && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
title="Toggle friends highlights"
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
title="Toggle my highlights"
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="highlights-actions-right">
|
||||
{hasHighlights && (
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
@@ -99,14 +101,6 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse highlights panel"
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
</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
|
||||
|
||||
269
src/components/Profile.tsx
Normal file
269
src/components/Profile.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
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 } from '../services/exploreService'
|
||||
import { KINDS } from '../config/kinds'
|
||||
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'
|
||||
import { writingsController } from '../services/writingsController'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
|
||||
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 via controllers to populate event store
|
||||
useEffect(() => {
|
||||
if (!pubkey || !relayPool || !eventStore) return
|
||||
|
||||
// Start controllers to fetch and populate event store
|
||||
// Controllers handle streaming, deduplication, and storage
|
||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||
.catch(err => console.warn('⚠️ [Profile] Failed to fetch highlights:', err))
|
||||
|
||||
writingsController.start({ relayPool, eventStore, pubkey, force: refreshTrigger > 0 })
|
||||
.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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
@@ -19,6 +20,7 @@ interface ReaderHeaderProps {
|
||||
settings?: UserSettings
|
||||
highlights?: Highlight[]
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightCountClick?: () => void
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -31,9 +33,11 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlightCount,
|
||||
settings,
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightCountClick
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image)
|
||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
@@ -70,13 +74,25 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
if (cachedImage) {
|
||||
// Show hero section if we have an image OR a title
|
||||
if (cachedImage || title) {
|
||||
return (
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
@@ -93,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
@@ -118,7 +136,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
@@ -133,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
className="highlight-indicator clickable"
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
onClick={onHighlightCountClick}
|
||||
title="Open highlights sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<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))
|
||||
|
||||
// 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)
|
||||
const leftOffset = isSidebarCollapsed
|
||||
? 'var(--sidebar-collapsed-width)'
|
||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: ''
|
||||
}`}
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||
style={{
|
||||
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]" />
|
||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
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}%`}
|
||||
</div>
|
||||
|
||||
@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('🔌 Relay Status Indicator:', {
|
||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||
totalStatuses: relayStatuses.length,
|
||||
connectedCount: connectedUrls.length,
|
||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
// Mode and relay status determined
|
||||
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
||||
Local relays only
|
||||
</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,13 +6,15 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||
import ExploreSettings from './Settings/ExploreSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import TTSSettings from './Settings/TTSSettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import VersionFooter from './VersionFooter'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -30,11 +32,25 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
defaultExploreScopeNostrverse: false,
|
||||
defaultExploreScopeFriends: true,
|
||||
defaultExploreScopeMine: false,
|
||||
zapSplitHighlighterWeight: 50,
|
||||
zapSplitBorisWeight: 2.1,
|
||||
zapSplitAuthorWeight: 50,
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
fullWidthImages: true,
|
||||
renderVideoLinksAsEmbeds: true,
|
||||
syncReadingPosition: true,
|
||||
autoScrollToReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
ttsUseSystemLanguage: false,
|
||||
ttsDetectContentLanguage: true,
|
||||
ttsLanguageMode: 'content',
|
||||
ttsDefaultSpeed: 2.1,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -162,13 +178,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
<PWASettings />
|
||||
</div>
|
||||
<VersionFooter />
|
||||
</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
|
||||
|
||||
164
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
164
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface LayoutBehaviorSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Layout & Behavior</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Bookmark View</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'large' })}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
|
||||
<input
|
||||
id="collapseOnArticleOpen"
|
||||
type="checkbox"
|
||||
checked={settings.collapseOnArticleOpen !== false}
|
||||
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Collapse bookmark bar when opening an article</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="syncReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.syncReadingPosition ?? false}
|
||||
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Sync reading position across devices</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="autoScrollToReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.autoScrollToReadingPosition !== false}
|
||||
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-scroll to saved reading position</span>
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutBehaviorSettings
|
||||
|
||||
@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface LayoutNavigationSettingsProps {
|
||||
interface LayoutBehaviorSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Layout & Navigation</h3>
|
||||
<h3 className="section-title">Layout & Behavior</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Bookmark View</label>
|
||||
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
|
||||
<span>Collapse bookmark bar when opening an article</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutNavigationSettings
|
||||
export default LayoutBehaviorSettings
|
||||
|
||||
|
||||
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
|
||||
@@ -1,173 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface OfflineModeSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Flight Mode</h3>
|
||||
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<IconButton
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
variant="ghost"
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
id="useLocalRelayAsCache"
|
||||
type="checkbox"
|
||||
checked={settings.useLocalRelayAsCache ?? true}
|
||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local relays as cache</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
||||
Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OfflineModeSettings
|
||||
|
||||
@@ -1,80 +1,222 @@
|
||||
import React from 'react'
|
||||
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
|
||||
const PWASettings: React.FC = () => {
|
||||
interface PWASettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useIsMobile()
|
||||
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalled) return
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
// Installation successful
|
||||
}
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
<span>Boris is installed as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
You can launch Boris from your home screen or app drawer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
// If it's an internal route (starts with /), navigate directly
|
||||
if (url.startsWith('/')) {
|
||||
navigate(url)
|
||||
} else {
|
||||
// External URL: wrap with /r/ path
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInstallable) {
|
||||
return null
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
<h3 className="section-title">App & Airplane Mode</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Boris is offline‑first by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
|
||||
</p>
|
||||
|
||||
{/* Flight Mode Section - Checkboxes First */}
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PWA Install Section - Paragraphs */}
|
||||
<div className="setting-group">
|
||||
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{', '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{', and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
id="useLocalRelayAsCache"
|
||||
type="checkbox"
|
||||
checked={settings.useLocalRelayAsCache ?? true}
|
||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local relays as cache</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Install Boris on your device for a native app experience.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="zap-preset-btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
disabled={isInstalled || !isInstallable}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
||||
{isInstalled ? 'Installed' : 'Install App'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
</button>
|
||||
|
||||
{!isMobile && (
|
||||
<img
|
||||
src="/pwa.svg"
|
||||
alt="Progressive Web App"
|
||||
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
import ColorPicker from '../ColorPicker'
|
||||
@@ -19,35 +19,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Reading & Display</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
|
||||
<label>Font Size</label>
|
||||
<div className="setting-buttons">
|
||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onUpdate({ fontSize: size })}
|
||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Style</label>
|
||||
<div className="setting-buttons">
|
||||
@@ -69,34 +40,25 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
<label>Paragraph Alignment</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faAlignLeft}
|
||||
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
|
||||
title="Left aligned"
|
||||
ariaLabel="Left aligned"
|
||||
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faAlignJustify}
|
||||
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
|
||||
title="Justified"
|
||||
ariaLabel="Justified"
|
||||
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
@@ -137,6 +99,65 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Font Size</label>
|
||||
<div className="setting-control">
|
||||
<div className="setting-buttons">
|
||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onUpdate({ fontSize: size })}
|
||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
@@ -157,7 +178,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${settings.fontSize || 21}px`,
|
||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
|
||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface StartupPreferencesSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Startup & Behavior</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StartupPreferencesSettings
|
||||
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||
|
||||
interface ZapSettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
|
||||
}
|
||||
|
||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const isMobile = useIsMobile()
|
||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Zap Splits</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Your Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Author(s) Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="highlighter-ticks"
|
||||
/>
|
||||
<datalist id="highlighter-ticks">
|
||||
<option value="50" label="50%"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Support Boris</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Author's Share: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="author-ticks"
|
||||
/>
|
||||
<datalist id="author-ticks">
|
||||
<option value="50" label="50%"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zap-split-description">
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="boris-ticks"
|
||||
/>
|
||||
<datalist id="boris-ticks">
|
||||
<option value="5" label="5"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<img
|
||||
src="/zaps.svg"
|
||||
alt="Zap Splits"
|
||||
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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('/my/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,46 +1,26 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking, faHighlighter, faBookmark, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
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 [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
@@ -54,117 +34,130 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowProfileMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
if (showProfileMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showProfileMenu])
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
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} />
|
||||
</button>
|
||||
{activeAccount && (
|
||||
<div className="profile-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="profile-avatar-button"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
aria-label={`Profile: ${getUserDisplayName()}`}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</button>
|
||||
{showProfileMenu && (
|
||||
<div className="profile-dropdown-menu">
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>My Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span>My Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/reads'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span>My Reads</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/links'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} />
|
||||
<span>My Links</span>
|
||||
</button>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(() => navigate('/my/writings'))}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span>My Writings</span>
|
||||
</button>
|
||||
<div className="profile-menu-separator"></div>
|
||||
<button
|
||||
className="profile-menu-item"
|
||||
onClick={() => handleMenuItemClick(onLogout)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faPersonHiking}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
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={faBolt}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add bookmark"
|
||||
ariaLabel="Add bookmark"
|
||||
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>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
|
||||
|
||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupporters = async () => {
|
||||
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
|
||||
if (zappers.length > 0) {
|
||||
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)
|
||||
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
loadSupporters()
|
||||
}, [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 (
|
||||
<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">
|
||||
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
</p>
|
||||
</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)' }}>
|
||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||
</div>
|
||||
@@ -207,7 +225,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||
style={{ borderColor: 'var(--color-bg)' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
|
||||
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||
</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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -105,24 +105,59 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
// Detect scroll direction and position to hide/show mobile buttons
|
||||
// Only hide on scroll down when viewing article content
|
||||
const isViewingArticle = !!(props.selectedUrl)
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Track if we're at the top of the page
|
||||
const [isAtTop, setIsAtTop] = useState(true)
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isViewingArticle) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsAtTop(window.scrollY <= 10)
|
||||
}
|
||||
|
||||
handleScroll() // Check initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isMobile, isViewingArticle])
|
||||
|
||||
// Bookmark button: hide only when scrolling down
|
||||
const showBookmarkButton = scrollDirection !== 'down'
|
||||
// Highlights button: hide when scrolling down OR at the top
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
const savedScrollPosition = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
// Save current scroll position
|
||||
savedScrollPosition.current = window.scrollY
|
||||
document.body.style.top = `-${savedScrollPosition.current}px`
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
// Restore scroll position
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
if (savedScrollPosition.current > 0) {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, savedScrollPosition.current)
|
||||
savedScrollPosition.current = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
document.body.style.top = ''
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
@@ -229,11 +264,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile bookmark button - always show except on settings page */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -249,11 +284,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile highlights button - only show when viewing article content */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -304,6 +339,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
isMobile={isMobile}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -347,7 +383,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.classifiedHighlights}
|
||||
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
? props.highlights // article-specific highlights only
|
||||
: props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
@@ -364,6 +402,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -390,10 +433,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.hasActiveAccount && (
|
||||
{props.hasActiveAccount && props.readerContent && (
|
||||
<HighlightButton
|
||||
ref={props.highlightButtonRef}
|
||||
onHighlight={props.onCreateHighlight}
|
||||
@@ -402,7 +446,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
)}
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
showOnMobile={showBookmarkButton}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
|
||||
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
|
||||
164
src/components/VideoEmbedProcessor.tsx
Normal file
164
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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) => {
|
||||
// Process HTML and extract video URLs in a single pass to keep them in sync
|
||||
const { processedHtml, videoUrls } = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return { processedHtml: html, videoUrls: [] }
|
||||
}
|
||||
|
||||
// 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 finalHtml = result
|
||||
remainingUrls.forEach((url) => {
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
collectedUrls.push(url)
|
||||
placeholderIndex++
|
||||
})
|
||||
|
||||
// Return both processed HTML and collected URLs (in the same order as placeholders)
|
||||
return {
|
||||
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
|
||||
videoUrls: collectedUrls
|
||||
}
|
||||
}, [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 - only render if not empty
|
||||
if (part.trim()) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</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
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
* Nostr gateway URLs for viewing events and profiles on the web
|
||||
*/
|
||||
|
||||
export const NOSTR_GATEWAY = 'https://ants.sh' as const
|
||||
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
||||
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||
|
||||
/**
|
||||
* Get a profile URL on the gateway
|
||||
*/
|
||||
export function getProfileUrl(npub: string): string {
|
||||
return `${NOSTR_GATEWAY}/p/${npub}`
|
||||
return `${NOSTR_GATEWAY}/${npub}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event URL on the gateway
|
||||
*/
|
||||
export function getEventUrl(nevent: string): string {
|
||||
return `${NOSTR_GATEWAY}/e/${nevent}`
|
||||
return `${NOSTR_GATEWAY}/${nevent}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
|
||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||
*/
|
||||
export function getNostrUrl(identifier: string): string {
|
||||
// Check the prefix to determine if it's a profile or event
|
||||
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
|
||||
return `${NOSTR_GATEWAY}/p/${identifier}`
|
||||
}
|
||||
|
||||
// Everything else (note, nevent, naddr) goes to /e/
|
||||
return `${NOSTR_GATEWAY}/e/${identifier}`
|
||||
// nostr.at uses simple /{identifier} format for all types
|
||||
return `${NOSTR_GATEWAY}/${identifier}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a search portal URL with a query
|
||||
*/
|
||||
export function getSearchUrl(query: string): string {
|
||||
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
export const RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869',
|
||||
'wss://relay.nsec.app',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
||||
]
|
||||
|
||||
|
||||
83
src/hooks/useAdaptiveTextColor.ts
Normal file
83
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
interface AdaptiveTextColor {
|
||||
textColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine optimal text color based on image background
|
||||
* Samples the top-right corner of the image to ensure publication date is readable
|
||||
*
|
||||
* @param imageUrl - The URL of the image to analyze
|
||||
* @returns Object containing textColor for optimal contrast
|
||||
*/
|
||||
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
// No image, use default white text
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const width = img.naturalWidth
|
||||
const height = img.naturalHeight
|
||||
|
||||
// Sample top-right corner (last 25% width, first 25% height)
|
||||
const color = fac.getColor(img, {
|
||||
left: Math.floor(width * 0.75),
|
||||
top: 0,
|
||||
width: Math.floor(width * 0.25),
|
||||
height: Math.floor(height * 0.25)
|
||||
})
|
||||
|
||||
// Color analysis complete
|
||||
|
||||
// Use library's built-in isLight check for optimal contrast
|
||||
if (color.isLight) {
|
||||
setColors({
|
||||
textColor: '#000000'
|
||||
})
|
||||
} else {
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to default on error
|
||||
console.error('Error analyzing image color:', error)
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// Fallback to default if image fails to load
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
|
||||
img.src = imageUrl
|
||||
|
||||
return () => {
|
||||
fac.destroy()
|
||||
}
|
||||
}, [imageUrl])
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import type { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from '../services/dataFetch'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
@@ -7,14 +13,22 @@ import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlights: Dispatch<SetStateAction<Highlight[]>>
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -36,79 +51,251 @@ export function useArticleLoader({
|
||||
setCurrentArticle,
|
||||
settings
|
||||
}: UseArticleLoaderProps) {
|
||||
const location = useLocation()
|
||||
const mountedRef = useRef(true)
|
||||
// Hold latest settings without retriggering effect
|
||||
const settingsRef = useRef<UserSettings | undefined>(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
// Track in-flight request to prevent stale updates from previous naddr
|
||||
const currentRequestIdRef = useRef(0)
|
||||
|
||||
// Extract preview data from navigation state (from blog post cards)
|
||||
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (!relayPool || !naddr) return
|
||||
|
||||
const loadArticle = async () => {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
// Keep highlights panel collapsed by default - only open on user interaction
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
if (previewData) {
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
title: previewData.title,
|
||||
markdown: '', // Will be loaded from store or relay
|
||||
image: previewData.image,
|
||||
summary: previewData.summary,
|
||||
published: previewData.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
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)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
// Stream them as they arrive for instant rendering
|
||||
setReaderLoading(false) // Turn off loading immediately - we have the preview!
|
||||
} else {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode naddr to filter
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type !== 'naddr') {
|
||||
throw new Error('Invalid naddr format')
|
||||
}
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
let firstEmitted = false
|
||||
let latestEvent: NostrEvent | null = null
|
||||
|
||||
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
|
||||
if (eventStore) {
|
||||
try {
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
latestEvent = storedEvent as NostrEvent
|
||||
firstEmitted = true
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
}
|
||||
}
|
||||
|
||||
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
|
||||
const events = await queryEvents(relayPool, filter, {
|
||||
onEvent: (evt) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
// Store in event store for future local reads
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventStore?.add?.(evt as unknown as any)
|
||||
} catch {
|
||||
// Silently ignore store errors
|
||||
}
|
||||
|
||||
// Keep latest by created_at
|
||||
if (!latestEvent || evt.created_at > latestEvent.created_at) {
|
||||
latestEvent = evt
|
||||
}
|
||||
|
||||
// Emit immediately on first event
|
||||
if (!firstEmitted) {
|
||||
firstEmitted = true
|
||||
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(evt)
|
||||
const summary = Helpers.getArticleSummary(evt)
|
||||
const published = Helpers.getArticlePublished(evt)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: evt.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(evt.id)
|
||||
setCurrentArticle?.(evt)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
|
||||
// Finalize with newest version if it's newer than what we first rendered
|
||||
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||
if (finalEvent) {
|
||||
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||
const image = Helpers.getArticleImage(finalEvent)
|
||||
const summary = Helpers.getArticleSummary(finalEvent)
|
||||
const published = Helpers.getArticlePublished(finalEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: finalEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(finalEvent.id)
|
||||
setCurrentArticle?.(finalEvent)
|
||||
} else {
|
||||
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
}
|
||||
|
||||
// Fetch highlights after content is shown
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
if (!mountedRef.current) return
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
},
|
||||
settings
|
||||
)
|
||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
||||
const le = latestEvent as NostrEvent | null
|
||||
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
|
||||
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
|
||||
const eventId = le ? le.id : undefined
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settingsRef.current
|
||||
)
|
||||
} else {
|
||||
// No article event to fetch highlights for - clear and don't show loading
|
||||
setHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load article:', err)
|
||||
setReaderContent({
|
||||
title: 'Error Loading Article',
|
||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false)
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setReaderContent({
|
||||
title: 'Error Loading Article',
|
||||
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadArticle()
|
||||
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
previewData,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,137 +1,195 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
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 { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
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 {
|
||||
relayPool: RelayPool | null
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
externalUrl?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
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 = ({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
|
||||
const handleFetchContacts = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleFetchBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
||||
setBookmarksLoading(true)
|
||||
// Determine effective article coordinate as early as possible
|
||||
// Prefer state-derived coordinate, but fall back to route naddr before content loads
|
||||
const effectiveArticleCoordinate = useMemo(() => {
|
||||
if (currentArticleCoordinate) return currentArticleCoordinate
|
||||
if (!naddr) return undefined
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
// merge-friendly: updater form that preserves visible list until replacement
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
||||
setBookmarks(() => next)
|
||||
}, settings)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
}
|
||||
} catch {
|
||||
// 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 () => {
|
||||
if (!relayPool) return
|
||||
|
||||
setHighlightsLoading(true)
|
||||
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>()
|
||||
// Seed map with cached highlights
|
||||
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
effectiveArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
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 if (activeAccount) {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
||||
setHighlights(fetchedHighlights)
|
||||
} else {
|
||||
// No article selected - clear article highlights
|
||||
setArticleHighlights([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
||||
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||
|
||||
const handleRefreshAll = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await onRefreshBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
// Contacts and own highlights are managed by controllers
|
||||
setLastFetchTime(Date.now())
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh data:', err)
|
||||
} finally {
|
||||
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(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||
handleFetchBookmarks()
|
||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||
|
||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!naddr) {
|
||||
handleFetchHighlights()
|
||||
if (!relayPool || !activeAccount) {
|
||||
setHighlightsLoading(false)
|
||||
return
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
// Fetch article-specific highlights when viewing an article
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (effectiveArticleCoordinate && !externalUrl) {
|
||||
handleFetchHighlights()
|
||||
} else if (!naddr && !externalUrl) {
|
||||
// Clear article highlights when not viewing an article
|
||||
setArticleHighlights([])
|
||||
setHighlightsLoading(false)
|
||||
} else {
|
||||
// For external URLs or other cases, loading is not needed
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||
|
||||
// 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 {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
|
||||
highlightsLoading,
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
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 { IEventStore } from 'applesauce-core'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
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
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
|
||||
export function useExternalUrlLoader({
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -42,73 +48,143 @@ export function useExternalUrlLoader({
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
const mountedRef = useRef(true)
|
||||
// Track in-flight request to prevent stale updates when switching quickly
|
||||
const currentRequestIdRef = useRef(0)
|
||||
|
||||
// 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(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(url)
|
||||
setIsCollapsed(true)
|
||||
// Clear article-specific state
|
||||
setCurrentArticleCoordinate(undefined)
|
||||
setCurrentArticleEventId(undefined)
|
||||
|
||||
try {
|
||||
const content = await fetchReadableContent(url)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
setReaderContent(content)
|
||||
|
||||
console.log('🌐 External URL loaded:', content.title)
|
||||
|
||||
// Set reader loading to false immediately after content is ready
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights for this URL asynchronously
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
if (!mountedRef.current) return
|
||||
|
||||
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') {
|
||||
const seen = new Set<string>()
|
||||
const highlightsList = await fetchHighlightsForUrl(
|
||||
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||
|
||||
await fetchHighlightsForUrl(
|
||||
relayPool,
|
||||
url,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
if (currentRequestIdRef.current !== requestId) return
|
||||
|
||||
if (seen.has(highlight.id)) return
|
||||
seen.add(highlight.id)
|
||||
setHighlights((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)
|
||||
})
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
eventStore || undefined
|
||||
)
|
||||
// Ensure final list is sorted and contains all items
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
} else {
|
||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
// For videos and other media files, use the filename as the title
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: filename,
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
setReaderLoading(false)
|
||||
if (mountedRef.current && currentRequestIdRef.current === requestId) {
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: filename,
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { useToast } from './useToast'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
activeAccount: IAccount | undefined
|
||||
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
|
||||
settings
|
||||
}: UseHighlightCreationParams) => {
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
@@ -58,7 +60,6 @@ export const useHighlightCreation = ({
|
||||
? currentArticle.content
|
||||
: readerContent?.markdown || readerContent?.html
|
||||
|
||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
||||
|
||||
const newHighlight = await createHighlight(
|
||||
text,
|
||||
@@ -71,12 +72,7 @@ export const useHighlightCreation = ({
|
||||
settings
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!', {
|
||||
id: newHighlight.id,
|
||||
isLocalOnly: newHighlight.isLocalOnly,
|
||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
||||
publishedRelays: newHighlight.publishedRelays
|
||||
})
|
||||
// Highlight created successfully
|
||||
|
||||
// Clear the browser's text selection immediately to allow DOM update
|
||||
const selection = window.getSelection()
|
||||
@@ -92,10 +88,19 @@ export const useHighlightCreation = ({
|
||||
})
|
||||
} catch (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
|
||||
throw error
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||
|
||||
return {
|
||||
highlightButtonRef,
|
||||
|
||||
@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
|
||||
}: UseHighlightedContentParams) => {
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
console.log('🔍 ContentPanel: Processing highlights', {
|
||||
totalHighlights: highlights.length,
|
||||
selectedUrl,
|
||||
showHighlights
|
||||
})
|
||||
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
||||
|
||||
// Apply visibility filtering
|
||||
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
|
||||
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
|
||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
||||
return filtered
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
// Prepare the final HTML with highlights applied
|
||||
const finalHtml = useMemo(() => {
|
||||
const sourceHtml = markdown ? renderedMarkdownHtml : html
|
||||
|
||||
console.log('🎨 Preparing final HTML:', {
|
||||
hasMarkdown: !!markdown,
|
||||
hasHtml: !!html,
|
||||
renderedHtmlLength: renderedMarkdownHtml.length,
|
||||
sourceHtmlLength: sourceHtml?.length || 0,
|
||||
showHighlights,
|
||||
relevantHighlightsCount: relevantHighlights.length
|
||||
})
|
||||
|
||||
// Prepare final HTML
|
||||
if (!sourceHtml) {
|
||||
console.warn('⚠️ No source HTML available')
|
||||
return ''
|
||||
}
|
||||
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
||||
return highlightedHtml
|
||||
}
|
||||
|
||||
console.log('📄 Returning source HTML without highlights')
|
||||
return sourceHtml
|
||||
|
||||
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
return { finalHtml, relevantHighlights }
|
||||
|
||||
@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear previous render immediately to avoid showing stale content while processing
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,7 +45,6 @@ export const useMarkdownToHTML = (
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
@@ -58,12 +59,10 @@ export const useMarkdownToHTML = (
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
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
|
||||
|
||||
if (wasLocalOnly && isNowOnline) {
|
||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
||||
console.log('📊 Relay state:', {
|
||||
connectedRelays: connectedRelays.length,
|
||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
||||
})
|
||||
// Coming back online, sync events
|
||||
|
||||
// Wait a moment for relays to fully establish connections
|
||||
setTimeout(() => {
|
||||
console.log('🚀 Starting sync after delay...')
|
||||
syncLocalEventsToRemote(relayPool, eventStore)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ export function useOnlineStatus() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 Back online')
|
||||
setIsOnline(true)
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('📴 Gone offline')
|
||||
setIsOnline(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,10 @@ export function usePWAInstall() {
|
||||
const choiceResult = await deferredPrompt.userChoice
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('✅ PWA installed')
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ PWA installation dismissed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,21 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => 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
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
completionHoldMs = 2000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const positionRef = useRef(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const suppressUntilRef = useRef<number>(0)
|
||||
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
|
||||
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
|
||||
|
||||
// Store callbacks in refs to avoid them being dependencies
|
||||
const onPositionChangeRef = useRef(onPositionChange)
|
||||
const onReadingCompleteRef = useRef(onReadingComplete)
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
||||
useEffect(() => {
|
||||
onPositionChangeRef.current = onPositionChange
|
||||
onReadingCompleteRef.current = onReadingComplete
|
||||
onSaveRef.current = onSave
|
||||
}, [onPositionChange, onReadingComplete, onSave])
|
||||
|
||||
// Suppress auto-saves for a given duration (used after programmatic restore)
|
||||
const suppressSavesFor = useCallback((ms: number) => {
|
||||
const until = Date.now() + ms
|
||||
suppressUntilRef.current = until
|
||||
}, [])
|
||||
|
||||
// Throttled save function - saves at 1s intervals during scrolling
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSaveRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Always save instantly when we reach completion (1.0)
|
||||
if (currentPosition === 1 && !lastSaved100Ref.current) {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
lastSaved100Ref.current = true
|
||||
onSaveRef.current(1)
|
||||
return
|
||||
}
|
||||
|
||||
// Always update the pending position (latest position to save)
|
||||
pendingPositionRef.current = currentPosition
|
||||
|
||||
// Throttle: only schedule a save if one isn't already pending
|
||||
// This ensures saves happen at regular 1s intervals during continuous scrolling
|
||||
if (saveTimerRef.current) {
|
||||
return // Already have a save scheduled, don't reset the timer
|
||||
}
|
||||
|
||||
const THROTTLE_MS = 1000
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
// Save the latest position, not the one from when timer was scheduled
|
||||
const positionToSave = pendingPositionRef.current
|
||||
onSaveRef.current?.(positionToSave)
|
||||
saveTimerRef.current = null
|
||||
}, THROTTLE_MS)
|
||||
}, [syncEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -29,18 +93,55 @@ export const useReadingPosition = ({
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Ignore if document is too small (likely during page transition)
|
||||
if (documentHeight < 100) return
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
const maxScroll = documentHeight - windowHeight
|
||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
|
||||
// Only consider it 100% if we're truly at the bottom AND have scrolled significantly
|
||||
// This prevents false 100% during page transitions
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
positionRef.current = clampedProgress
|
||||
onPositionChangeRef.current?.(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
// Schedule auto-save if sync is enabled (unless suppressed)
|
||||
if (Date.now() >= suppressUntilRef.current) {
|
||||
scheduleSave(clampedProgress)
|
||||
}
|
||||
// Note: Suppression is silent to avoid log spam during scrolling
|
||||
|
||||
// Completion detection with 2s hold at 100%
|
||||
if (!hasTriggeredComplete.current) {
|
||||
// If at exact 100%, start a hold timer; cancel if we scroll up
|
||||
if (clampedProgress === 1) {
|
||||
if (!completionTimerRef.current) {
|
||||
completionTimerRef.current = setTimeout(() => {
|
||||
if (!hasTriggeredComplete.current && positionRef.current === 1) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
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
|
||||
onReadingCompleteRef.current?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,20 +155,32 @@ export const useReadingPosition = ({
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
|
||||
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
|
||||
// Only clear completion timer since that's tied to the current scroll session
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||
}, [enabled, readingCompleteThreshold, scheduleSave, completionHoldMs])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
lastSaved100Ref.current = false
|
||||
if (completionTimerRef.current) {
|
||||
clearTimeout(completionTimerRef.current)
|
||||
completionTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
progressPercentage: Math.round(position * 100),
|
||||
suppressSavesFor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
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 { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
@@ -16,30 +16,28 @@ interface 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 [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||
|
||||
// Load settings and set up subscription
|
||||
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||
useEffect(() => {
|
||||
if (!relayPool || !pubkey || !eventStore) return
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAndWatch()
|
||||
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
// Start settings stream: seed from store, stream updates to store in background
|
||||
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...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])
|
||||
|
||||
// Apply settings to document
|
||||
@@ -48,7 +46,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const root = document.documentElement.style
|
||||
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)
|
||||
applyTheme(
|
||||
@@ -59,9 +56,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
console.log('⏳ Waiting for font to load...')
|
||||
await loadFont(fontKey)
|
||||
console.log('✅ Font loaded, applying styles')
|
||||
}
|
||||
|
||||
// Apply font settings after font is loaded
|
||||
@@ -73,7 +68,12 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
// Set image max-width based on full-width setting
|
||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||
|
||||
}
|
||||
|
||||
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/pull-to-refresh.css';
|
||||
@import './styles/components/skeletons.css';
|
||||
@import './styles/components/login.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
@import './styles/utils/legacy.css';
|
||||
|
||||
11
src/main.tsx
11
src/main.tsx
@@ -5,14 +5,12 @@ import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register Service Worker for PWA functionality (production only)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('✅ Service Worker registered:', registration.scope)
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
@@ -25,9 +23,6 @@ if ('serviceWorker' in navigator) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
console.log('🔄 New version available! Reload to update.')
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
window.dispatchEvent(updateAvailable)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
console.log('📦 Loaded article from cache:', naddr)
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
@@ -63,7 +62,6 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
console.log('💾 Saved article to cache:', naddr)
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
@@ -99,10 +97,10 @@ export async function fetchArticleByNaddr(
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
// Define relays to query - use union of relay hints from naddr and configured relays
|
||||
// This avoids failures when naddr contains stale/unreachable relay hints
|
||||
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
||||
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
@@ -116,7 +114,28 @@ export async function fetchArticleByNaddr(
|
||||
// Parallel local+remote, stream immediate, collect up to first from each
|
||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
||||
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
||||
const events = collected as NostrEvent[]
|
||||
let events = collected as NostrEvent[]
|
||||
|
||||
// Fallback: if nothing found, try a second round against a set of reliable public relays
|
||||
if (events.length === 0) {
|
||||
const reliableRelays = Array.from(new Set<string>([
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
...remoteRelays // keep any configured remote relays
|
||||
]))
|
||||
const { remote$: fallback$ } = createParallelReqStreams(
|
||||
relayPool,
|
||||
[], // no local
|
||||
reliableRelays,
|
||||
filter,
|
||||
1500,
|
||||
12000
|
||||
)
|
||||
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
|
||||
events = fallbackCollected as NostrEvent[]
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error('Article not found')
|
||||
|
||||
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())
|
||||
|
||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || 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))
|
||||
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>()
|
||||
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 prev = byD.get(d)
|
||||
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[] = []
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
out.push(...setsAndNamedLists)
|
||||
// Add web bookmarks as individual events
|
||||
// Add deduplicated web bookmarks as individual events
|
||||
out.push(...webBookmarks)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -16,11 +16,28 @@ export interface BookmarkData {
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface AddressPointer {
|
||||
kind: number
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays?: string[]
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface EventPointer {
|
||||
id: string
|
||||
relays?: string[]
|
||||
author?: string
|
||||
added_at?: number
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface ApplesauceBookmarks {
|
||||
notes?: BookmarkData[]
|
||||
articles?: BookmarkData[]
|
||||
hashtags?: BookmarkData[]
|
||||
urls?: BookmarkData[]
|
||||
notes?: EventPointer[]
|
||||
articles?: AddressPointer[]
|
||||
hashtags?: string[]
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
export interface AccountWithExtension {
|
||||
@@ -49,48 +66,111 @@ export { dedupeNip51Events } from './bookmarkEvents'
|
||||
export const processApplesauceBookmarks = (
|
||||
bookmarks: unknown,
|
||||
activeAccount: ActiveAccount,
|
||||
isPrivate: boolean
|
||||
isPrivate: boolean,
|
||||
parentCreatedAt?: number
|
||||
): IndividualBookmark[] => {
|
||||
if (!bookmarks) return []
|
||||
|
||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||
const allItems: BookmarkData[] = []
|
||||
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
const allItems: IndividualBookmark[] = []
|
||||
|
||||
// Process notes (EventPointer[])
|
||||
if (applesauceBookmarks.notes) {
|
||||
applesauceBookmarks.notes.forEach((note: EventPointer) => {
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: note.created_at ?? null,
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process articles (AddressPointer[])
|
||||
if (applesauceBookmarks.articles) {
|
||||
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
|
||||
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
|
||||
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: article.created_at ?? null,
|
||||
pubkey: article.pubkey,
|
||||
kind: article.kind, // Usually 30023 for long-form articles
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process hashtags (string[])
|
||||
if (applesauceBookmarks.hashtags) {
|
||||
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: 0, // Hashtags don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['t', hashtag]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process URLs (string[])
|
||||
if (applesauceBookmarks.urls) {
|
||||
applesauceBookmarks.urls.forEach((url: string) => {
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: 0, // URLs don't have their own creation time
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['r', url]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
const processed = bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => {
|
||||
return {
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
created_at: bookmark.created_at ?? null,
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
@@ -98,29 +178,38 @@ export function hydrateItems(
|
||||
items: IndividualBookmark[],
|
||||
idToEvent: Map<string, NostrEvent>
|
||||
): IndividualBookmark[] {
|
||||
return items.map(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 || ''
|
||||
if (ev.kind === 30023) {
|
||||
const articleTitle = getArticleTitle(ev)
|
||||
if (articleTitle) {
|
||||
content = articleTitle
|
||||
return items
|
||||
.map(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 || ''
|
||||
if (ev.kind === 30023) {
|
||||
const articleTitle = getArticleTitle(ev)
|
||||
if (articleTitle) {
|
||||
content = articleTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
pubkey: ev.pubkey || item.pubkey,
|
||||
content,
|
||||
created_at: ev.created_at || item.created_at,
|
||||
kind: ev.kind || item.kind,
|
||||
tags: ev.tags || item.tags,
|
||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure all events with content get parsed content for proper rendering
|
||||
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
|
||||
|
||||
return {
|
||||
...item,
|
||||
pubkey: ev.pubkey || item.pubkey,
|
||||
content,
|
||||
created_at: ev.created_at || item.created_at,
|
||||
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`
|
||||
|
||||
@@ -11,6 +11,96 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
|
||||
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
||||
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(
|
||||
bookmarkListEvents: NostrEvent[],
|
||||
activeAccount: ActiveAccount,
|
||||
@@ -23,93 +113,102 @@ export async function collectBookmarksFromEvents(
|
||||
allTags: string[][]
|
||||
}> {
|
||||
const publicItemsAll: IndividualBookmark[] = []
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
let newestCreatedAt = 0
|
||||
let latestContent = ''
|
||||
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) {
|
||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
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 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 metadata = { dTag, setTitle, setDescription, setImage }
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
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({
|
||||
id: evt.id,
|
||||
id: webBookmarkId,
|
||||
content: evt.content || '',
|
||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
created_at: evt.created_at ?? null,
|
||||
pubkey: evt.pubkey,
|
||||
kind: evt.kind,
|
||||
tags: evt.tags || [],
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||
sourceKind: 39701,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage,
|
||||
listUpdatedAt: evt.created_at ?? null
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
||||
} catch {
|
||||
try {
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
||||
let decryptedContent: string | undefined
|
||||
try {
|
||||
if (hasNip44Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!decryptedContent) {
|
||||
try {
|
||||
if (hasNip04Decrypt(signerCandidate)) {
|
||||
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
||||
evt.pubkey,
|
||||
evt.content
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptedContent) {
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
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 processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||
|
||||
|
||||
publicItemsAll.push(
|
||||
...processedPub.map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
|
||||
// Schedule decrypt if needed
|
||||
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||
const needsDecrypt = signerCandidate && (
|
||||
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||
Helpers.hasHiddenContent(evt) ||
|
||||
hasNip04Content
|
||||
)
|
||||
|
||||
if (needsDecrypt) {
|
||||
decryptJobs.push({ evt, metadata })
|
||||
} else {
|
||||
// Check for already-unlocked hidden bookmarks
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
dedupeNip51Events,
|
||||
hydrateItems,
|
||||
isAccountWithExtension,
|
||||
isHexId,
|
||||
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'
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
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]
|
||||
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: noteIds },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
}
|
||||
}
|
||||
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 { prioritizeLocalRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -16,7 +15,6 @@ export const fetchContacts = async (
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
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 events = await queryEvents(
|
||||
@@ -24,7 +22,6 @@ export const fetchContacts = async (
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
{
|
||||
relayUrls,
|
||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||
// Stream partials as we see any contact list
|
||||
for (const tag of event.tags) {
|
||||
@@ -53,9 +50,7 @@ export const fetchContacts = async (
|
||||
}
|
||||
// merged already via streams
|
||||
|
||||
console.log('📊 Contact events fetched:', events.length)
|
||||
|
||||
console.log('👥 Followed contacts:', followed.size)
|
||||
return followed
|
||||
} catch (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 { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { Observable, merge, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
export interface QueryOptions {
|
||||
relayUrls?: string[]
|
||||
localTimeoutMs?: number
|
||||
remoteTimeoutMs?: number
|
||||
onEvent?: (event: NostrEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
relayPool: RelayPool,
|
||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
||||
): Promise<NostrEvent[]> {
|
||||
const {
|
||||
relayUrls,
|
||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||
onEvent
|
||||
} = options
|
||||
|
||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(localTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(remoteTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
|
||||
@@ -36,12 +36,10 @@ export async function createDeletionRequest(
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
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 { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -19,32 +20,44 @@ export interface BlogPostPreview {
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||
* @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
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[],
|
||||
onPost?: (post: BlogPostPreview) => void
|
||||
onPost?: (post: BlogPostPreview) => void,
|
||||
limit: number | null = 100,
|
||||
eventStore?: IEventStore
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
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,
|
||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||
filter,
|
||||
{
|
||||
relayUrls,
|
||||
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 key = `${event.pubkey}:${dTag}`
|
||||
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)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
@@ -89,7 +105,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function createHighlight(
|
||||
}
|
||||
|
||||
// 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 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 { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
import { KINDS } from '../../config/kinds'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
import { highlightCache } from './cache'
|
||||
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
settings?: UserSettings,
|
||||
force = false,
|
||||
eventStore?: IEventStore
|
||||
): 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 {
|
||||
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 local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
const rawEvents: NostrEvent[] = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (seenIds.has(event.id)) return
|
||||
seenIds.add(event.id)
|
||||
|
||||
// Store in event store if provided
|
||||
if (eventStore) {
|
||||
eventStore.add(event)
|
||||
}
|
||||
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
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 {
|
||||
return []
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user