mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
997 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d313c71e24 | ||
|
|
903b4a4ec1 | ||
|
|
a511b25b87 | ||
|
|
e920cf9477 | ||
|
|
708a1bfd54 | ||
|
|
51842f55bf | ||
|
|
52991f8e20 | ||
|
|
e3cd4454b4 | ||
|
|
78bc1f46dd | ||
|
|
c8cd1e6e66 | ||
|
|
5254697fe2 | ||
|
|
13462efaed | ||
|
|
1df00fbfda | ||
|
|
c2e220a1f2 | ||
|
|
00740aab6d | ||
|
|
e12d67cc5f | ||
|
|
e12aaa2b6c | ||
|
|
9880a9ae34 | ||
|
|
603db680f2 | ||
|
|
ae0471946e | ||
|
|
a48308d57d | ||
|
|
f67b358148 | ||
|
|
46a0a3da1f | ||
|
|
c92a620ea8 | ||
|
|
34de372509 | ||
|
|
a422084949 | ||
|
|
bd0e075984 | ||
|
|
38f4b69d48 | ||
|
|
9d1d944daf | ||
|
|
e56461cb12 | ||
|
|
f6b6747f09 | ||
|
|
180c26c47a | ||
|
|
78da0cb3e4 | ||
|
|
3d74c25c7d | ||
|
|
f46f55705b | ||
|
|
205591602d | ||
|
|
c6a42e0304 | ||
|
|
688d4285e3 | ||
|
|
9f806afc45 | ||
|
|
1282e778c6 | ||
|
|
6fd40f2ff6 | ||
|
|
6ac40c8a17 | ||
|
|
92145af2bb | ||
|
|
1ebaf7ccd2 | ||
|
|
5d22819ae3 | ||
|
|
6761b1861e | ||
|
|
1d989eae76 | ||
|
|
33d6e5882d | ||
|
|
0a62924b78 | ||
|
|
e2472606dd | ||
|
|
6f04b8f513 | ||
|
|
a8ad346c5d | ||
|
|
465c24ed3a | ||
|
|
04dea350a4 | ||
|
|
29c4bcb69b | ||
|
|
23ea7f352b | ||
|
|
36b35367f1 | ||
|
|
183463c817 | ||
|
|
7fb91e71f1 | ||
|
|
717f094984 | ||
|
|
c69e50d3bb | ||
|
|
4e4d719d94 | ||
|
|
d453a6439c | ||
|
|
5dfa6ba3ae | ||
|
|
f2d2883eee | ||
|
|
84001d1b83 | ||
|
|
b7a390cf89 | ||
|
|
59d9179642 | ||
|
|
68301cd20f | ||
|
|
4d6b7e1a46 | ||
|
|
95fe9b548f | ||
|
|
e86ae9f05e | ||
|
|
2124be83c3 | ||
|
|
a8bb17d4cd | ||
|
|
a886a68822 | ||
|
|
76bdbc670d | ||
|
|
c16ce1fc7e | ||
|
|
a578d67b1e | ||
|
|
25d1ead9f5 | ||
|
|
ae5ea66dd2 | ||
|
|
cf5f8fae16 | ||
|
|
d9c46e602a | ||
|
|
4d980bf91c | ||
|
|
cb3b0e38e9 | ||
|
|
fbf5c455ca | ||
|
|
ed5decf3e9 | ||
|
|
44a7e6ae2c | ||
|
|
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 |
@@ -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.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ dist
|
||||
# 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.
|
||||
|
||||
|
||||
1373
CHANGELOG.md
1373
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
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# Tailwind CSS Migration Status
|
||||
|
||||
## ✅ Completed (Core Infrastructure)
|
||||
|
||||
### Phase 1: Setup & Foundation
|
||||
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||
- [x] Enable Tailwind preflight (CSS reset)
|
||||
|
||||
### Phase 2: Base Styles Reconciliation
|
||||
- [x] Add CSS variables for user-settable theme colors
|
||||
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||
- `--reading-font`, `--reading-font-size`
|
||||
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||
- [x] Remove redundant base styles handled by Tailwind
|
||||
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||
|
||||
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||
- [x] Switch from pane-scrolling to document-scrolling
|
||||
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||
- [x] Update `app.css` to remove fixed container heights
|
||||
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||
- [x] Fix reading position tracking to work with document scroll
|
||||
- [x] Maintain mobile overlay behavior
|
||||
|
||||
### Phase 4: Component Migrations
|
||||
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||
- Removed 80+ lines of CSS
|
||||
- Added shimmer animation to Tailwind config
|
||||
- Z-index layering maintained (1102)
|
||||
|
||||
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||
- Mobile hamburger button
|
||||
- Mobile highlights button
|
||||
- Mobile backdrop
|
||||
- Removed 60+ lines of CSS
|
||||
|
||||
- [x] **App Container**: Tailwind utilities
|
||||
- Responsive padding (p-0 md:p-4)
|
||||
- Min-height viewport support
|
||||
|
||||
## 📊 Impact & Metrics
|
||||
|
||||
### Lines of CSS Removed
|
||||
- `global.css`: ~50 lines removed
|
||||
- `reader.css`: ~80 lines removed (progress indicator)
|
||||
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||
- **Total**: ~190 lines removed
|
||||
|
||||
### Key Achievements
|
||||
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||
3. **No Breaking Changes**: All existing functionality preserved
|
||||
4. **Type Safety**: TypeScript checks passing
|
||||
5. **Lint Clean**: ESLint checks passing
|
||||
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||
|
||||
## 🔄 Remaining Work (Incremental)
|
||||
|
||||
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||
|
||||
### High-Value Components
|
||||
- [ ] **ContentPanel** - Large component, high impact
|
||||
- Reader header, meta info, loading states
|
||||
- Mark as read button
|
||||
- Article/video menus
|
||||
|
||||
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||
- Card layouts (compact/cards/large views)
|
||||
- Bookmark metadata display
|
||||
- Interactive states
|
||||
|
||||
- [ ] **HighlightsPanel** - Feature-rich
|
||||
- Header with toggles
|
||||
- Highlight items
|
||||
- Level-based styling
|
||||
|
||||
- [ ] **Settings Components** - Forms & controls
|
||||
- Color pickers
|
||||
- Font selectors
|
||||
- Toggle switches
|
||||
- Sliders
|
||||
|
||||
### CSS Files to Prune
|
||||
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||
- `src/styles/components/cards.css` - Bookmark card styles
|
||||
- `src/styles/components/modals.css` - Modal dialogs
|
||||
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||
|
||||
## 🎯 Migration Strategy
|
||||
|
||||
### For New Components
|
||||
Use Tailwind utilities from the start. Reference:
|
||||
```tsx
|
||||
// Good: Tailwind utilities
|
||||
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||
|
||||
// Avoid: New CSS classes
|
||||
<div className="custom-component">
|
||||
```
|
||||
|
||||
### For Existing Components
|
||||
Migrate incrementally when touching files:
|
||||
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||
2. Replace color/background utilities
|
||||
3. Replace typography utilities
|
||||
4. Replace responsive variants
|
||||
5. Remove old CSS rules
|
||||
6. Keep file under 210 lines
|
||||
|
||||
### CSS Variable Usage
|
||||
Dynamic values should still use CSS variables or inline styles:
|
||||
```tsx
|
||||
// User-settable colors
|
||||
style={{ backgroundColor: settings.highlightColorMine }}
|
||||
|
||||
// Or reference CSS variable
|
||||
className="bg-[var(--highlight-color-mine)]"
|
||||
```
|
||||
|
||||
## 📝 Technical Notes
|
||||
|
||||
### Z-Index Layering
|
||||
- Mobile sidepanes: `z-[1001]`
|
||||
- Mobile backdrop: `z-[999]`
|
||||
- Progress indicator: `z-[1102]`
|
||||
- Mobile buttons: `z-[900]`
|
||||
- Relay status: `z-[999]`
|
||||
- Modals: `z-[10000]`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Mobile: `< 768px`
|
||||
- Tablet: `768px - 1024px`
|
||||
- Desktop: `> 1024px`
|
||||
|
||||
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||
|
||||
### Safe Area Insets
|
||||
Mobile notch support:
|
||||
```tsx
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||
}}
|
||||
```
|
||||
|
||||
### Custom Animations
|
||||
Add to `tailwind.config.js`:
|
||||
```js
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
- [x] Tailwind CSS fully integrated and functional
|
||||
- [x] Document scrolling working correctly
|
||||
- [x] Reading position tracking accurate
|
||||
- [x] Progress indicator always visible
|
||||
- [x] No TypeScript errors
|
||||
- [x] No linting errors
|
||||
- [x] Mobile responsiveness maintained
|
||||
- [x] Theme colors (user settings) working
|
||||
- [x] All existing features functional
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Ship It**: Current state is production-ready
|
||||
2. **Incremental Migration**: Convert components as you touch them
|
||||
3. **Monitor**: Watch for any CSS conflicts
|
||||
4. **Cleanup**: Eventually remove unused CSS files
|
||||
5. **Document**: Update component docs with Tailwind patterns
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||
**Date**: 2025-01-14
|
||||
**Commits**: 8 conventional commits
|
||||
**Lines Removed**: ~190 lines of CSS
|
||||
**Breaking Changes**: None
|
||||
|
||||
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 – Read, Highlight, Explore'
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
@@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
description: data.description || '',
|
||||
thumbnail_url: data.thumbnail_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
try {
|
||||
if (videoInfo.source === 'youtube') {
|
||||
// YouTube handling
|
||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||
const title = ''
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
@@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
thumbnail_url,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
|
||||
@@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||
// In a real implementation, you might want to use YouTube's API or other methods
|
||||
const title = '' // Will be populated from captions or other sources
|
||||
const description = ''
|
||||
// Fetch basic metadata from YouTube page
|
||||
let title = ''
|
||||
let description = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
// Extract title from HTML
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(' - YouTube', '').trim()
|
||||
}
|
||||
// Extract description from meta tag
|
||||
const descMatch = html.match(/<meta name="description" content="([^"]+)"/)
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch YouTube metadata:', error)
|
||||
}
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
@@ -9,22 +9,24 @@
|
||||
<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>Boris - Nostr Bookmarks</title>
|
||||
<title>Boris - Read, Highlight, Explore</title>
|
||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:title" content="Boris - Read, Highlight, Explore" />
|
||||
<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 -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
||||
<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>
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.13",
|
||||
"version": "0.10.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.6.13",
|
||||
"version": "0.10.23",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -23,6 +23,7 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"fetch-opengraph": "^1.0.36",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -35,6 +36,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": {
|
||||
@@ -4501,6 +4503,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||
@@ -6170,6 +6181,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-opengraph": {
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
|
||||
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -6263,6 +6284,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6895,6 +6936,22 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -11215,6 +11272,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.18",
|
||||
"version": "0.10.25",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -26,6 +26,7 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"fetch-opengraph": "^1.0.36",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -38,6 +39,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 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"name": "Boris - Read, Highlight, Explore",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
```
|
||||
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 && (
|
||||
|
||||
@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { fetchReadableContent } from '../services/readerService'
|
||||
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||
|
||||
interface AddBookmarkModalProps {
|
||||
onClose: () => void
|
||||
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
// Helper to extract metadata from HTML
|
||||
function extractMetaTag(html: string, patterns: string[]): string | null {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(new RegExp(pattern, 'i'))
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTags(html: string): string[] {
|
||||
// Helper to extract tags from OpenGraph data
|
||||
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
// Extract keywords meta tag
|
||||
const keywords = extractMetaTag(html, [
|
||||
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
])
|
||||
if (keywords) {
|
||||
keywords.split(/[,;]/)
|
||||
.map(k => k.trim().toLowerCase())
|
||||
.filter(k => k.length > 0 && k.length < 30)
|
||||
.forEach(k => tags.push(k))
|
||||
// Extract keywords from OpenGraph data
|
||||
if (ogData.keywords && typeof ogData.keywords === 'string') {
|
||||
ogData.keywords.split(/[,;]/)
|
||||
.map((k: string) => k.trim().toLowerCase())
|
||||
.filter((k: string) => k.length > 0 && k.length < 30)
|
||||
.forEach((k: string) => tags.push(k))
|
||||
}
|
||||
|
||||
// Extract article:tag (multiple possible)
|
||||
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
||||
let match
|
||||
while ((match = articleTagRegex.exec(html)) !== null) {
|
||||
const tag = match[1].trim().toLowerCase()
|
||||
if (tag && tag.length < 30) tags.push(tag)
|
||||
// Extract article:tag from OpenGraph data
|
||||
if (ogData['article:tag']) {
|
||||
const articleTagValue = ogData['article:tag']
|
||||
const articleTags = Array.isArray(articleTagValue)
|
||||
? articleTagValue
|
||||
: [articleTagValue]
|
||||
|
||||
articleTags.forEach((tag: unknown) => {
|
||||
if (typeof tag === 'string') {
|
||||
const cleanTag = tag.trim().toLowerCase()
|
||||
if (cleanTag && cleanTag.length < 30) {
|
||||
tags.push(cleanTag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(new Set(tags)).slice(0, 5)
|
||||
@@ -83,17 +82,34 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||
setIsFetchingMetadata(true)
|
||||
try {
|
||||
const content = await fetchReadableContent(normalizedUrl)
|
||||
lastFetchedUrlRef.current = normalizedUrl
|
||||
// Fetch both readable content and OpenGraph data in parallel
|
||||
const [content, ogData] = await Promise.all([
|
||||
fetchReadableContent(normalizedUrl),
|
||||
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
|
||||
])
|
||||
|
||||
console.log('🔍 Modal fetch debug:', {
|
||||
url: normalizedUrl,
|
||||
hasContent: !!content,
|
||||
hasOgData: !!ogData,
|
||||
ogDataKeys: ogData ? Object.keys(ogData) : null
|
||||
})
|
||||
|
||||
lastFetchedUrlRef.current = normalizedUrl
|
||||
let extractedAnything = false
|
||||
|
||||
// Extract title: prioritize og:title > twitter:title > <title>
|
||||
if (!title && content.html) {
|
||||
const extractedTitle = extractMetaTag(content.html, [
|
||||
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
]) || content.title
|
||||
// Extract title: prioritize og:title > twitter:title > content.title
|
||||
if (!title) {
|
||||
let extractedTitle = null
|
||||
|
||||
if (ogData) {
|
||||
extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
|
||||
}
|
||||
|
||||
// Fallback to content.title if no OpenGraph title found
|
||||
if (!extractedTitle) {
|
||||
extractedTitle = content.title
|
||||
}
|
||||
|
||||
if (extractedTitle) {
|
||||
setTitle(extractedTitle)
|
||||
@@ -102,12 +118,15 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
|
||||
// Extract description: prioritize og:description > twitter:description > meta description
|
||||
if (!description && content.html) {
|
||||
const extractedDesc = extractMetaTag(content.html, [
|
||||
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||
])
|
||||
if (!description && ogData) {
|
||||
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||
|
||||
console.log('🔍 Description extraction debug:', {
|
||||
currentDescription: description,
|
||||
hasOgData: !!ogData,
|
||||
extractedDesc: extractedDesc,
|
||||
willSetDescription: !!extractedDesc
|
||||
})
|
||||
|
||||
if (extractedDesc) {
|
||||
setDescription(extractedDesc)
|
||||
@@ -116,8 +135,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
|
||||
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||
if (!tagsInput && content.html) {
|
||||
const extractedTags = extractTags(content.html)
|
||||
if (!tagsInput && ogData) {
|
||||
const extractedTags = extractTagsFromOgData(ogData)
|
||||
|
||||
// Only add boris tag if we extracted something
|
||||
if (extractedAnything || extractedTags.length > 0) {
|
||||
|
||||
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,10 +1,11 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe } from '@fortawesome/free-solid-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'
|
||||
@@ -19,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)}`
|
||||
@@ -39,10 +42,11 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image and summary tags (per NIP-23)
|
||||
// For kind:30023 articles, extract title, image and summary tags (per NIP-23)
|
||||
// Note: We extract directly from tags here since we don't have the full event.
|
||||
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const articleTitle = isArticle ? bookmark.tags.find(t => t[0] === 'title')?.[1] : undefined
|
||||
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
||||
|
||||
@@ -57,8 +61,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 = () => {
|
||||
@@ -70,7 +72,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = (): IconDefinition => {
|
||||
if (isArticle) return faNewspaper
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassification) {
|
||||
@@ -81,7 +83,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faNewspaper
|
||||
return faLink // External article
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
@@ -89,6 +91,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,10 +111,16 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
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
|
||||
}
|
||||
@@ -133,17 +142,25 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon()
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { articleImage, ...compactProps } = sharedProps
|
||||
const compactProps = {
|
||||
bookmark,
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleTitle,
|
||||
contentTypeIcon: getContentTypeIcon(),
|
||||
readingProgress
|
||||
}
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
@@ -152,5 +169,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} articleTitle={articleTitle} />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useState } 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, faHeart, faPlus } 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'
|
||||
@@ -13,12 +13,19 @@ import { ViewMode } from './Bookmarks'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
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[]
|
||||
@@ -51,7 +58,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false,
|
||||
@@ -61,14 +67,87 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
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 (kind:39701), URL is in the 'd' tag
|
||||
if (bookmark.kind === 39701) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (dTag) {
|
||||
// Ensure URL has protocol
|
||||
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||
return readingProgressMap.get(url)
|
||||
}
|
||||
}
|
||||
|
||||
// For other bookmark types, try to extract URL from content
|
||||
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, RELAYS)
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, getActiveRelayUrls(relayPool))
|
||||
}
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
@@ -83,31 +162,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContent)
|
||||
// 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))
|
||||
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// Group non-set bookmarks by source or flatten based on mode
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
|
||||
: [
|
||||
{ key: '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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return sectionsArray
|
||||
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(allIndividualBookmarks)
|
||||
// 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]
|
||||
)
|
||||
|
||||
// Group non-set bookmarks as before
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections
|
||||
bookmarkSets.forEach(set => {
|
||||
sections.push({
|
||||
key: `set-${set.name}`,
|
||||
title: set.title || set.name,
|
||||
items: set.bookmarks
|
||||
})
|
||||
})
|
||||
const filteredBookmarks = useMemo(() =>
|
||||
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
|
||||
[allIndividualBookmarks, selectedFilter]
|
||||
)
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -140,7 +246,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
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}`}>
|
||||
@@ -153,7 +272,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>
|
||||
)
|
||||
) : (
|
||||
@@ -187,6 +305,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
readingProgress={getBookmarkReadingProgress(individualBookmark)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -204,41 +323,41 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
variant="ghost"
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
{onRefresh && (
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
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"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import RichContent from '../RichContent'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -18,12 +20,12 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
articleTitle?: string
|
||||
readingProgress?: number
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -31,26 +33,53 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
index,
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
articleTitle,
|
||||
readingProgress
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isNote = bookmark.kind === 1
|
||||
|
||||
// Extract title from tags for regular bookmarks (not just articles)
|
||||
const bookmarkTitle = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = () => {
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassificationType) {
|
||||
switch (firstUrlClassificationType) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faFileLines
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
}
|
||||
|
||||
// For notes, use sticky note icon
|
||||
if (isNote) return faStickyNote
|
||||
|
||||
// Default fallback
|
||||
return faLink
|
||||
}
|
||||
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
@@ -63,6 +92,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
}
|
||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
@@ -72,112 +102,113 @@ 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}`}
|
||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
className={`individual-bookmark card-view ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
return (
|
||||
<button
|
||||
key={urlIndex}
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
<div className="card-layout">
|
||||
<div className="card-content">
|
||||
<div className="card-content-header">
|
||||
{(cachedImage || firstUrl) && (
|
||||
<div
|
||||
className="card-thumbnail"
|
||||
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{extractedUrls.length > 1 && (
|
||||
<button
|
||||
className="expand-toggle-urls"
|
||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
{!cachedImage && firstUrl && (
|
||||
<div className="thumbnail-placeholder">
|
||||
<FontAwesomeIcon icon={getContentTypeIcon()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-text-content">
|
||||
<div className="bookmark-header">
|
||||
</div>
|
||||
|
||||
{/* Display title for articles or bookmarks with titles */}
|
||||
{(articleTitle || bookmarkTitle) && (
|
||||
<h3 className="bookmark-title">
|
||||
<RichContent content={articleTitle || bookmarkTitle || ''} className="" />
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{isArticle && articleSummary ? (
|
||||
<RichContent content={articleSummary} className="bookmark-content article-summary" />
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<RichContent content={bookmark.content} className="bookmark-content" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading progress indicator as separator - always shown for all bookmark types */}
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={1}
|
||||
marginTop="0.125rem"
|
||||
marginBottom="0.125rem"
|
||||
/>
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
|
||||
</button>
|
||||
)}
|
||||
{getAuthorDisplayName()}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bookmark-footer-right">
|
||||
{getInternalRoute() ? (
|
||||
<Link
|
||||
to={getInternalRoute()!}
|
||||
className="bookmark-date-link"
|
||||
title="Open in app"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="bookmark-content article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{shouldTruncate && bookmark.content
|
||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
||||
: renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<div className="bookmark-content">
|
||||
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</Link>
|
||||
</div>
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock } 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 RichContent from '../RichContent'
|
||||
import { naddrEncode } from 'nostr-tools/nip19'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,8 +14,9 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleSummary?: string
|
||||
articleTitle?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
readingProgress?: number
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -22,28 +25,37 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
articleTitle,
|
||||
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
|
||||
|
||||
const displayText = isArticle && articleTitle ? articleTitle : bookmark.content
|
||||
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
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}`)
|
||||
}
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl(extractedUrls[0])
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
} else if (isNote) {
|
||||
navigate(`/e/${bookmark.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
@@ -54,18 +66,28 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
>
|
||||
<span className="bookmark-type-compact">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</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 - only show when there's actual progress */}
|
||||
{readingProgress !== undefined && readingProgress > 0 && (
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={1}
|
||||
marginLeft="1.5rem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
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'
|
||||
import { ReadingProgressBar } from '../ReadingProgressBar'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -19,11 +19,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> = ({
|
||||
@@ -35,15 +35,16 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getIconForUrlType,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
contentTypeIcon,
|
||||
readingProgress
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -52,6 +53,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}`}
|
||||
@@ -84,21 +109,21 @@ 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} />
|
||||
</div>
|
||||
<RichContent content={bookmark.content} className="large-text" />
|
||||
)}
|
||||
|
||||
{/* Reading progress indicator for all bookmark types - always shown */}
|
||||
<ReadingProgressBar
|
||||
readingProgress={readingProgress}
|
||||
height={3}
|
||||
marginTop="0.75rem"
|
||||
/>
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
{bookmark.isPrivate && (
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
)}
|
||||
</span>
|
||||
<span className="large-author">
|
||||
<Link
|
||||
@@ -110,16 +135,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,13 @@ 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 { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
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 +28,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,19 +54,27 @@ 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
|
||||
|
||||
// Manage document title based on current route
|
||||
const isViewingContent = !!(naddr || externalUrl || eventId)
|
||||
useDocumentTitle({
|
||||
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
|
||||
})
|
||||
|
||||
// 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'
|
||||
@@ -73,7 +94,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -151,8 +172,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -165,11 +184,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -212,10 +233,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
settings
|
||||
})
|
||||
|
||||
// Determine which loader should be active based on route
|
||||
// Only one loader should run at a time to prevent state conflicts
|
||||
const shouldLoadArticle = !!naddr && !externalUrl && !eventId
|
||||
const shouldLoadExternal = !!externalUrl && !naddr && !eventId
|
||||
const shouldLoadEvent = !!eventId && !naddr && !externalUrl
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
naddr: shouldLoadArticle ? naddr : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -230,8 +258,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
url: shouldLoadExternal ? externalUrl : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -242,6 +271,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load event if /e/:eventId route is used
|
||||
useEventLoader({
|
||||
eventId: shouldLoadEvent ? eventId : undefined,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed
|
||||
})
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
@@ -315,10 +355,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,17 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
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, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ContentSkeleton } from './Skeletons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
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 +29,21 @@ 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 { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { shouldTrackReadingProgress } from '../utils/helpers'
|
||||
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 +73,7 @@ interface ContentPanelProps {
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -92,21 +101,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
isHighlightsCollapsed = false,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
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)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
@@ -121,6 +127,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,
|
||||
@@ -129,17 +140,194 @@ 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
|
||||
// Don't track internal sentinel URLs (nostr-event: is not a real Nostr URI per NIP-21)
|
||||
if (selectedUrl?.startsWith('nostr-event:')) 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(() => {
|
||||
@@ -148,21 +336,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
if (showArticleMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
// Check available space and position menu upward if needed
|
||||
useEffect(() => {
|
||||
@@ -185,13 +370,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (showArticleMenu) {
|
||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||
}
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
if (showExternalMenu) {
|
||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
}, [showArticleMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
@@ -202,36 +384,29 @@ 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)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
// Load YouTube metadata/captions when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(selectedUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [selectedUrl])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
|
||||
// Get article links for menu
|
||||
@@ -239,7 +414,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)
|
||||
|
||||
@@ -268,7 +444,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
@@ -345,52 +520,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
if (articleLinks) {
|
||||
// For regular notes (kind:1), open via /e/ path
|
||||
if (currentArticle?.kind === 1) {
|
||||
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
|
||||
window.open(borisUrl, '_blank', 'noopener,noreferrer')
|
||||
} else if (articleLinks) {
|
||||
// For articles, use search portal
|
||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
if (!selectedUrl) return
|
||||
const native = buildNativeVideoUrl(selectedUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = selectedUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = 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 || 'Video', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
@@ -432,7 +572,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
if (selectedUrl) {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||
if (selectedUrl.startsWith('nostr-event:')) {
|
||||
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
@@ -455,12 +601,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) {
|
||||
@@ -474,7 +633,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
|
||||
}
|
||||
|
||||
@@ -496,16 +683,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)
|
||||
@@ -523,13 +728,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
@@ -539,7 +737,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}
|
||||
@@ -549,16 +748,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}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -569,126 +767,59 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={ytMeta?.title || title}
|
||||
title={title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
{isExternalVideo ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={selectedUrl as string}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
{ytMeta?.description && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{ytMeta.description}
|
||||
</div>
|
||||
)}
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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 Watched' : 'Mark as Watched'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{isTextContent && articleText && (
|
||||
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
|
||||
<TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} />
|
||||
</div>
|
||||
)}
|
||||
{loading || !markdown && !html ? (
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
<VideoEmbedProcessor
|
||||
key={`content:${contentKey}`}
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
{!isNostrArticle && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
@@ -715,13 +846,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</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={handleSearchExternalUrl}
|
||||
@@ -808,21 +942,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>
|
||||
@@ -835,11 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</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, faSpinner } 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,242 @@ 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)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||
// 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 - load from localStorage first, fallback to settings
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>(() => {
|
||||
// Try to load from localStorage first
|
||||
try {
|
||||
const saved = localStorage.getItem('exploreScopeVisibility')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
// Validate that at least one scope is enabled
|
||||
if (parsed.nostrverse || parsed.friends || parsed.mine) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load explore scope from localStorage:', err)
|
||||
}
|
||||
|
||||
// Fallback to settings or defaults
|
||||
return {
|
||||
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
|
||||
}
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(next))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
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(() => {
|
||||
// Check if user has a saved preference
|
||||
const hasSavedPreference = (() => {
|
||||
try {
|
||||
return localStorage.getItem('exploreScopeVisibility') !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
// Only reset to defaults if no saved preference exists
|
||||
if (hasSavedPreference) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeAccount) {
|
||||
// When logged out, show nostrverse by default
|
||||
const defaultVisibility = { nostrverse: true, friends: false, mine: false }
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||
setHasLoadedNostrverseHighlights(true)
|
||||
} else {
|
||||
// When logged in, use settings defaults immediately
|
||||
const defaultVisibility = {
|
||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||
mine: settings?.defaultExploreScopeMine ?? false
|
||||
}
|
||||
setVisibility(defaultVisibility)
|
||||
try {
|
||||
localStorage.setItem('exploreScopeVisibility', JSON.stringify(defaultVisibility))
|
||||
} catch (err) {
|
||||
console.warn('Failed to save explore scope to localStorage:', err)
|
||||
}
|
||||
setHasLoadedNostrverse(false)
|
||||
setHasLoadedNostrverseHighlights(false)
|
||||
}
|
||||
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -56,162 +297,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 +480,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 +492,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 +535,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 +572,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -331,6 +585,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>
|
||||
@@ -348,7 +604,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<span>No highlights to show for the selected scope.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -357,7 +613,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -368,7 +623,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 +635,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 +652,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 +663,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 +675,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 +707,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightBut
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
bottom: '80px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
|
||||
@@ -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,14 +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 { 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 => {
|
||||
@@ -28,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
|
||||
@@ -130,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
|
||||
@@ -206,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])
|
||||
@@ -234,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
|
||||
}
|
||||
@@ -248,7 +164,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [highlight, onHighlightUpdate])
|
||||
}, [highlight, onHighlightUpdate, relayPool])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
@@ -274,15 +190,53 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}, [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) {
|
||||
@@ -473,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
|
||||
@@ -481,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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
|
||||
settings?: UserSettings
|
||||
highlights?: Highlight[]
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightCountClick?: () => void
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -32,7 +33,8 @@ 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)
|
||||
@@ -107,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>
|
||||
@@ -152,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>
|
||||
|
||||
58
src/components/ReadingProgressBar.tsx
Normal file
58
src/components/ReadingProgressBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ReadingProgressBarProps {
|
||||
readingProgress?: number
|
||||
height?: number
|
||||
marginTop?: string
|
||||
marginBottom?: string
|
||||
marginLeft?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ReadingProgressBar: React.FC<ReadingProgressBarProps> = ({
|
||||
readingProgress,
|
||||
height = 1,
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
className
|
||||
}) => {
|
||||
// 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)
|
||||
}
|
||||
|
||||
const progressWidth = readingProgress ? `${Math.round(readingProgress * 100)}%` : '0%'
|
||||
const progressBackground = readingProgress ? progressColor : 'var(--color-border)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
borderRadius: '0.5px',
|
||||
overflow: 'hidden',
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
position: 'relative',
|
||||
minHeight: `${height}px`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: progressWidth,
|
||||
background: progressBackground,
|
||||
transition: 'width 0.3s ease, background 0.3s ease',
|
||||
minHeight: `${height}px`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,11 +6,15 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||
import ExploreSettings from './Settings/ExploreSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
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,
|
||||
@@ -28,12 +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 {
|
||||
@@ -161,11 +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} />
|
||||
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<TTSSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
</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
|
||||
|
||||
@@ -104,6 +104,58 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface MediaDisplaySettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Media Display</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||
<input
|
||||
id="fullWidthImages"
|
||||
type="checkbox"
|
||||
checked={settings.fullWidthImages === true}
|
||||
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Full-width images in articles</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||
<input
|
||||
id="renderVideoLinksAsEmbeds"
|
||||
type="checkbox"
|
||||
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Render video links as embeds</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaDisplaySettings
|
||||
@@ -27,13 +27,19 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
if (isInstalled) return
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
// Installation successful
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
// 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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
@@ -151,7 +157,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
{', '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -161,6 +167,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{', and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
|
||||
86
src/components/Settings/TTSSettings.tsx
Normal file
86
src/components/Settings/TTSSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faGauge } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import TTSControls from '../TTSControls'
|
||||
|
||||
interface TTSSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3]
|
||||
const EXAMPLE_TEXT = "Boris aims to be a calm reader app with clean typography, beautiful design, and a focus on readability. Boris does not and will never have ads, trackers, paywalls, subscriptions, or any other distractions."
|
||||
|
||||
const TTSSettings: React.FC<TTSSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const currentSpeed = settings.ttsDefaultSpeed || 2.1
|
||||
|
||||
const handleCycleSpeed = () => {
|
||||
const currentIndex = SPEED_OPTIONS.indexOf(currentSpeed)
|
||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||
onUpdate({ ttsDefaultSpeed: SPEED_OPTIONS[nextIndex] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Text-to-Speech</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Playback Speed</label>
|
||||
<div className="setting-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="article-menu-btn"
|
||||
onClick={handleCycleSpeed}
|
||||
title="Cycle speed"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGauge} />
|
||||
<span>{currentSpeed}x</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Speaker language</label>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
value={settings.ttsLanguageMode || 'content'}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
onUpdate({
|
||||
ttsLanguageMode: value,
|
||||
ttsUseSystemLanguage: value === 'system',
|
||||
ttsDetectContentLanguage: value === 'content'
|
||||
})
|
||||
}}
|
||||
className="setting-select"
|
||||
>
|
||||
<option value="system">System Language</option>
|
||||
<option value="content">Content (auto-detect)</option>
|
||||
<option disabled>────────────</option>
|
||||
<option value="en-US">English (American)</option>
|
||||
<option value="en-GB">English (British)</option>
|
||||
<option value="zh">Mandarin Chinese</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="hi">Hindi</option>
|
||||
<option value="ar">Arabic</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="de">German</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ru">Russian</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<div style={{ padding: '0.75rem', backgroundColor: 'var(--color-bg)', borderRadius: '4px', marginBottom: '0.75rem', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||
{EXAMPLE_TEXT}
|
||||
</div>
|
||||
<TTSControls text={EXAMPLE_TEXT} settings={settings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TTSSettings
|
||||
99
src/components/ShareTargetHandler.tsx
Normal file
99
src/components/ShareTargetHandler.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { getActiveRelayUrls } from '../services/relayManager'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
|
||||
interface ShareTargetHandlerProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming shared URLs from the Web Share Target API.
|
||||
* Auto-saves the shared URL as a web bookmark (NIP-B0).
|
||||
*/
|
||||
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const { showToast } = useToast()
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [waitingForLogin, setWaitingForLogin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleSharedContent = async () => {
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(location.search)
|
||||
const link = params.get('link')
|
||||
const title = params.get('title')
|
||||
const text = params.get('text')
|
||||
|
||||
// Validate we have a URL
|
||||
if (!link) {
|
||||
showToast('No URL to save')
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// If no active account, wait for login
|
||||
if (!activeAccount) {
|
||||
setWaitingForLogin(true)
|
||||
showToast('Please log in to save this bookmark')
|
||||
return
|
||||
}
|
||||
|
||||
// We have account and URL, proceed with saving
|
||||
if (!processing) {
|
||||
setProcessing(true)
|
||||
try {
|
||||
await createWebBookmark(
|
||||
link,
|
||||
title || undefined,
|
||||
text || undefined,
|
||||
undefined,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
getActiveRelayUrls(relayPool)
|
||||
)
|
||||
showToast('Bookmark saved!')
|
||||
navigate('/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,12 +1,12 @@
|
||||
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, faNewspaper, faTimes } 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 IconButton from './IconButton'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -16,25 +16,11 @@ interface SidebarHeaderProps {
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(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
|
||||
@@ -50,83 +36,147 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (showProfileMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showProfileMenu])
|
||||
|
||||
const handleMenuItemClick = (action: () => void) => {
|
||||
setShowProfileMenu(false)
|
||||
// Close mobile sidebar when navigating on mobile
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
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>
|
||||
)}
|
||||
<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} />
|
||||
<div className="sidebar-header-left">
|
||||
{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>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/')
|
||||
}}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNewspaper}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount ? (
|
||||
<div className="sidebar-header-right">
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
icon={faPersonHiking}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
navigate('/explore')
|
||||
}}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={faRightToBracket}
|
||||
onClick={isConnecting ? () => {} : handleLogin}
|
||||
title={isConnecting ? "Connecting..." : "Login"}
|
||||
ariaLabel="Login"
|
||||
icon={faGear}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
onToggleCollapse()
|
||||
}
|
||||
onOpenSettings()
|
||||
}}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { 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>
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import VideoView from './VideoView'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
@@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -134,15 +136,30 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
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])
|
||||
|
||||
@@ -306,7 +323,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
{...(isMobile && !props.isSidebarOpen ? { inert: '' } : {})}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
@@ -358,40 +375,73 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.support}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
) : (() => {
|
||||
// Determine if this is a video URL
|
||||
const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type)
|
||||
|
||||
if (isExternalVideo) {
|
||||
return (
|
||||
<VideoView
|
||||
videoUrl={props.selectedUrl!}
|
||||
title={props.readerContent?.title}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
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'}
|
||||
onHighlightClick={props.onHighlightClick}
|
||||
selectedHighlightId={props.selectedHighlightId}
|
||||
highlightVisibility={props.highlightVisibility}
|
||||
onTextSelection={props.onTextSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
onOpenHighlights={() => {
|
||||
if (props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
{...(isMobile && props.isHighlightsCollapsed ? { inert: '' } : {})}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
@@ -411,10 +461,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}
|
||||
|
||||
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
|
||||
320
src/components/VideoView.tsx
Normal file
320
src/components/VideoView.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { getYouTubeThumbnail } from '../utils/imagePreview'
|
||||
|
||||
// Helper function to get Vimeo thumbnail
|
||||
const getVimeoThumbnail = (url: string): string | null => {
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (!vimeoMatch) return null
|
||||
|
||||
const videoId = vimeoMatch[1]
|
||||
return `https://vumbnail.com/${videoId}.jpg`
|
||||
}
|
||||
import {
|
||||
createWebsiteReaction,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import { unarchiveWebsite } from '../services/unarchiveService'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
|
||||
interface VideoViewProps {
|
||||
videoUrl: string
|
||||
title?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
onOpenHighlights?: () => void
|
||||
}
|
||||
|
||||
const VideoView: React.FC<VideoViewProps> = ({
|
||||
videoUrl,
|
||||
title,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
onOpenHighlights
|
||||
}) => {
|
||||
const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false)
|
||||
const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load YouTube metadata when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!videoUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(videoUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [videoUrl])
|
||||
|
||||
// Check if video is marked as watched
|
||||
useEffect(() => {
|
||||
const checkWatchedStatus = async () => {
|
||||
if (!activeAccount || !videoUrl) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
try {
|
||||
const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false
|
||||
setIsMarkedAsWatched(isWatched)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkWatchedStatus()
|
||||
}, [activeAccount, videoUrl, relayPool])
|
||||
|
||||
// Handle click outside to close menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
// Check menu position for upward opening
|
||||
useEffect(() => {
|
||||
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => {
|
||||
if (!menuRef.current) return
|
||||
|
||||
const rect = menuRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// Open upward if there's more space above and less space below
|
||||
setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200)
|
||||
}
|
||||
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
}, [showVideoMenu])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
const handleMarkAsWatched = async () => {
|
||||
if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return
|
||||
|
||||
setIsCheckingWatchedStatus(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
try {
|
||||
if (isMarkedAsWatched) {
|
||||
// Unmark as watched
|
||||
if (relayPool) {
|
||||
await unarchiveWebsite(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(false)
|
||||
} else {
|
||||
// Mark as watched
|
||||
if (relayPool) {
|
||||
await createWebsiteReaction(videoUrl, activeAccount, relayPool)
|
||||
}
|
||||
setIsMarkedAsWatched(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update watched status:', error)
|
||||
} finally {
|
||||
setIsCheckingWatchedStatus(false)
|
||||
setTimeout(() => setShowCheckAnimation(false), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenVideoExternal = () => {
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
const native = buildNativeVideoUrl(videoUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = videoUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
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: ytMeta?.title || title || 'Video',
|
||||
url: videoUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(videoUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const displayTitle = ytMeta?.title || title
|
||||
const displaySummary = ytMeta?.description || summary
|
||||
const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null
|
||||
|
||||
// Get video thumbnail for cover image
|
||||
const youtubeThumbnail = getYouTubeThumbnail(videoUrl)
|
||||
const vimeoThumbnail = getVimeoThumbnail(videoUrl)
|
||||
const videoThumbnail = youtubeThumbnail || vimeoThumbnail
|
||||
const displayImage = videoThumbnail || image
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReaderHeader
|
||||
title={displayTitle}
|
||||
image={displayImage}
|
||||
summary={displaySummary}
|
||||
published={published}
|
||||
readingTimeText={durationText}
|
||||
hasHighlights={false}
|
||||
highlightCount={0}
|
||||
settings={settings}
|
||||
highlights={[]}
|
||||
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||
onHighlightCountClick={onOpenHighlights}
|
||||
/>
|
||||
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displaySummary && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{displaySummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsWatched}
|
||||
disabled={isCheckingWatchedStatus}
|
||||
title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
style={isMarkedAsWatched ? { opacity: 0.85 } : undefined}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'}
|
||||
/>
|
||||
<span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoView
|
||||
17
src/config/bots.ts
Normal file
17
src/config/bots.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Hardcoded list of bot pubkeys (hex format) to hide articles from
|
||||
* These are accounts known to be bots or automated services
|
||||
*/
|
||||
export const BOT_PUBKEYS = new Set([
|
||||
// Step Counter Bot (npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss)
|
||||
nip19.decode('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss').data as string,
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a pubkey corresponds to a known bot
|
||||
*/
|
||||
export function isKnownBot(pubkey: string): boolean {
|
||||
return BOT_PUBKEYS.has(pubkey)
|
||||
}
|
||||
22
src/config/kinds.ts
Normal file
22
src/config/kinds.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Nostr event kinds used throughout the application
|
||||
export const KINDS = {
|
||||
Highlights: 9802, // NIP-84 user highlights
|
||||
BlogPost: 30023, // NIP-23 long-form article
|
||||
AppData: 30078, // NIP-78 application data
|
||||
ReadingProgress: 39802, // NIP-85 reading progress
|
||||
List: 30001, // NIP-51 list (addressable)
|
||||
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||
ListSimple: 10003, // NIP-51 simple list
|
||||
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||
} as const
|
||||
|
||||
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||
|
||||
// Reading progress tracking configuration
|
||||
export const READING_PROGRESS = {
|
||||
// Minimum character count to track reading progress (roughly 150 words)
|
||||
MIN_CONTENT_LENGTH: 1000
|
||||
} as const
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
export const RELAYS = [
|
||||
'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',
|
||||
]
|
||||
|
||||
|
||||
@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
|
||||
height: Math.floor(height * 0.25)
|
||||
})
|
||||
|
||||
console.log('Adaptive color detected:', {
|
||||
hex: color.hex,
|
||||
rgb: color.rgb,
|
||||
isLight: color.isLight,
|
||||
isDark: color.isDark
|
||||
})
|
||||
// Color analysis complete
|
||||
|
||||
// Use library's built-in isLight check for optimal contrast
|
||||
if (color.isLight) {
|
||||
console.log('Light background detected, using black text')
|
||||
setColors({
|
||||
textColor: '#000000'
|
||||
})
|
||||
} else {
|
||||
console.log('Dark background detected, using white text')
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState, 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'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
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 +40,7 @@ interface UseArticleLoaderProps {
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -36,79 +52,272 @@ 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
|
||||
|
||||
// Track the current article title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
|
||||
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)
|
||||
// Don't clear highlights yet - let the smart filtering logic handle it
|
||||
// when we know the article coordinate
|
||||
setHighlightsLoading(false) // Don't show loading yet
|
||||
|
||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||
if (previewData) {
|
||||
setCurrentTitle(previewData.title)
|
||||
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'
|
||||
setCurrentTitle(title)
|
||||
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'
|
||||
setCurrentTitle(title)
|
||||
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'
|
||||
setCurrentTitle(title)
|
||||
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
|
||||
setCurrentTitle(article.title)
|
||||
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)
|
||||
// Clear highlights that don't belong to this article coordinate
|
||||
setHighlights((prev) => {
|
||||
return prev.filter(h => {
|
||||
// Keep highlights that match this article coordinate or event ID
|
||||
return h.eventReference === coord || h.eventReference === eventId
|
||||
})
|
||||
})
|
||||
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,
|
||||
false, // force
|
||||
eventStore || undefined
|
||||
)
|
||||
} 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
|
||||
}
|
||||
|
||||
35
src/hooks/useDocumentTitle.ts
Normal file
35
src/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
|
||||
|
||||
interface UseDocumentTitleProps {
|
||||
title?: string
|
||||
fallback?: string
|
||||
}
|
||||
|
||||
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
|
||||
const originalTitleRef = useRef<string>(document.title)
|
||||
|
||||
useEffect(() => {
|
||||
// Store the original title on first mount
|
||||
if (originalTitleRef.current === DEFAULT_TITLE) {
|
||||
originalTitleRef.current = document.title
|
||||
}
|
||||
|
||||
// Set the new title if provided, otherwise use fallback or default
|
||||
const newTitle = title || fallback || DEFAULT_TITLE
|
||||
document.title = newTitle
|
||||
|
||||
// Cleanup: restore original title when component unmounts
|
||||
return () => {
|
||||
document.title = originalTitleRef.current
|
||||
}
|
||||
}, [title, fallback])
|
||||
|
||||
// Return a function to manually reset to default
|
||||
const resetTitle = () => {
|
||||
document.title = DEFAULT_TITLE
|
||||
}
|
||||
|
||||
return { resetTitle }
|
||||
}
|
||||
140
src/hooks/useEventLoader.ts
Normal file
140
src/hooks/useEventLoader.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useCallback, useState } 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'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
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) {
|
||||
// Track the current event title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
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
|
||||
}
|
||||
setCurrentTitle(title)
|
||||
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) {
|
||||
const updatedTitle = `Note by @${resolved}`
|
||||
setCurrentTitle(updatedTitle)
|
||||
setReaderContent({ ...baseContent, title: updatedTitle })
|
||||
}
|
||||
} 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'
|
||||
}
|
||||
setCurrentTitle('Error')
|
||||
setReaderContent(errorContent)
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useMemo, useState } 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'
|
||||
import { useDocumentTitle } from './useDocumentTitle'
|
||||
|
||||
// Helper to extract filename from URL
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
@@ -20,6 +25,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 +39,7 @@ interface UseExternalUrlLoaderProps {
|
||||
export function useExternalUrlLoader({
|
||||
url,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -42,73 +49,148 @@ 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)
|
||||
|
||||
// Track the current content title for document title
|
||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||
useDocumentTitle({ title: currentTitle })
|
||||
|
||||
// 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
|
||||
|
||||
setCurrentTitle(content.title)
|
||||
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])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { normalizeUrl } from '../utils/urlHelpers'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface UseFilteredHighlightsParams {
|
||||
highlights: Highlight[]
|
||||
@@ -24,8 +25,29 @@ export const useFilteredHighlights = ({
|
||||
|
||||
let urlFiltered = highlights
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
if (!selectedUrl.startsWith('nostr:')) {
|
||||
// Filter highlights based on URL type
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
// For Nostr articles, extract the article coordinate and filter by eventReference
|
||||
try {
|
||||
const decoded = nip19.decode(selectedUrl.replace('nostr:', ''))
|
||||
if (decoded.type === 'naddr') {
|
||||
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||
const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
// Keep highlights that match this article coordinate
|
||||
return h.eventReference === articleCoordinate
|
||||
})
|
||||
} else {
|
||||
// Not a valid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} catch {
|
||||
// Invalid naddr, clear all highlights
|
||||
urlFiltered = []
|
||||
}
|
||||
} else {
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
|
||||
@@ -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
|
||||
@@ -76,7 +71,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
// Set image max-width based on full-width setting
|
||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||
|
||||
}
|
||||
|
||||
applyStyles()
|
||||
|
||||
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 === 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
|
||||
}
|
||||
|
||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
||||
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 {
|
||||
@@ -62,7 +66,8 @@ export { dedupeNip51Events } from './bookmarkEvents'
|
||||
export const processApplesauceBookmarks = (
|
||||
bookmarks: unknown,
|
||||
activeAccount: ActiveAccount,
|
||||
isPrivate: boolean
|
||||
isPrivate: boolean,
|
||||
parentCreatedAt?: number
|
||||
): IndividualBookmark[] => {
|
||||
if (!bookmarks) return []
|
||||
|
||||
@@ -76,14 +81,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
created_at: note.created_at ?? null,
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -96,14 +101,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
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,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -114,14 +119,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
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,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -132,14 +137,14 @@ export const processApplesauceBookmarks = (
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
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,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
listUpdatedAt: parentCreatedAt || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -148,20 +153,24 @@ export const processApplesauceBookmarks = (
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
const processed = 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)
|
||||
}))
|
||||
.map((bookmark: BookmarkData) => {
|
||||
return {
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
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,
|
||||
listUpdatedAt: parentCreatedAt ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
@@ -169,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,47 +113,56 @@ 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)
|
||||
|
||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||
const 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
|
||||
setImage,
|
||||
listUpdatedAt: evt.created_at ?? null
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
|
||||
|
||||
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
||||
...processedPub.map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
@@ -73,70 +172,23 @@ export async function collectBookmarksFromEvents(
|
||||
}))
|
||||
)
|
||||
|
||||
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).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).map(i => ({
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
@@ -146,8 +198,17 @@ export async function collectBookmarksFromEvents(
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt events sequentially
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
if (decryptJobs.length > 0 && signerCandidate) {
|
||||
for (const job of decryptJobs) {
|
||||
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
|
||||
if (privateItems && privateItems.length > 0) {
|
||||
privateItemsAll.push(...privateItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
dedupeNip51Events,
|
||||
hydrateItems,
|
||||
isAccountWithExtension,
|
||||
hasNip04Decrypt,
|
||||
hasNip44Decrypt,
|
||||
dedupeBookmarksById,
|
||||
extractUrlsFromContent
|
||||
} from './bookmarkHelpers'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
|
||||
|
||||
export const fetchBookmarks = async (
|
||||
relayPool: RelayPool,
|
||||
activeAccount: unknown, // Full account object with extension capabilities
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
||||
settings?: UserSettings
|
||||
) => {
|
||||
try {
|
||||
|
||||
if (!isAccountWithExtension(activeAccount)) {
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events')
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||
{}
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
// Check for events with potentially encrypted content
|
||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
||||
if (eventsWithContent.length > 0) {
|
||||
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
|
||||
eventsWithContent.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
|
||||
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
|
||||
})
|
||||
}
|
||||
|
||||
rawEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
|
||||
// Log which events made it through deduplication
|
||||
bookmarkListEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
||||
})
|
||||
|
||||
// Check specifically for Primal's "reads" list
|
||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||
if (primalReads) {
|
||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||
} else {
|
||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
||||
}
|
||||
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||
return
|
||||
}
|
||||
// Aggregate across events
|
||||
const maybeAccount = activeAccount as AccountWithExtension
|
||||
console.log('🔐 Account object:', {
|
||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
||||
hasSigner: !!maybeAccount?.signer,
|
||||
accountType: typeof maybeAccount,
|
||||
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
|
||||
})
|
||||
|
||||
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
|
||||
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
|
||||
let signerCandidate: unknown = maybeAccount
|
||||
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
||||
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
||||
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
||||
// Fallback to the raw signer if account doesn't have nip04/nip44
|
||||
signerCandidate = maybeAccount.signer
|
||||
}
|
||||
|
||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||
if (signerCandidate) {
|
||||
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
||||
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
||||
}
|
||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
||||
bookmarkListEvents,
|
||||
activeAccount,
|
||||
signerCandidate
|
||||
)
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
|
||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
// Check if it's a hex ID (64 character hex string)
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
// Coordinate format: kind:pubkey:identifier
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
})
|
||||
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Fetch regular events by ID
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: Array.from(new Set(noteIds)) },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
events.forEach((e: NostrEvent) => {
|
||||
idToEvent.set(e.id, e)
|
||||
// Also store by coordinate if it's an addressable event
|
||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events by ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch addressable events by coordinates
|
||||
if (coordinates.length > 0) {
|
||||
try {
|
||||
// Group by kind for more efficient querying
|
||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
||||
|
||||
coordinates.forEach(coord => {
|
||||
const parts = coord.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
if (!byKind.has(kind)) {
|
||||
byKind.set(kind, [])
|
||||
}
|
||||
byKind.get(kind)!.push({ pubkey, identifier })
|
||||
})
|
||||
|
||||
// Query each kind group
|
||||
for (const [kind, items] of byKind.entries()) {
|
||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [kind], authors, '#d': identifiers },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
|
||||
events.forEach((e: NostrEvent) => {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
// Also store by event ID
|
||||
idToEvent.set(e.id, e)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch addressable events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
])
|
||||
|
||||
// Sort individual bookmarks by "added" timestamp first (most recently added first),
|
||||
// falling back to event created_at when unknown.
|
||||
const enriched = allBookmarks.map(b => ({
|
||||
...b,
|
||||
tags: b.tags || [],
|
||||
content: b.content || ''
|
||||
}))
|
||||
const sortedBookmarks = enriched
|
||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||
individualBookmarks: sortedBookmarks,
|
||||
isPrivate: privateItemsAll.length > 0,
|
||||
encryptedContent: undefined
|
||||
}
|
||||
|
||||
setBookmarks([bookmark])
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bookmarks:', error)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user